Как компилятор узнает, какая запись в vtable соответствует виртуальной функции?

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

Как компилятор узнает, какая запись в vtable соответствует какой виртуальной функции?

Пример:

class Animal{
public:
 void fakeMethod1(){}
 virtual void getWeight(){}
 void fakeMethod2(){}
 virtual void getHeight(){}
 virtual void getType(){}
};

class Tiger:public Animal{
public:
 void fakeMethod3(){}
 virtual void getWeight(){}
 void fakeMethod4(){}
 virtual void getHeight(){}
 virtual void getType(){}
};
main(){
Animal a* = new Tiger();
a->getHeight(); // A  will now point to the base address of vtable Tiger
//How will the compiler know which entry in the vtable corresponds to the function getHeight()?
}

Я не нашел точного объяснения в своем исследовании -

https://stackoverflow.com/a/99341/437894 =

«Эта таблица используется для разрешения вызова функции, поскольку она содержит адреса всех виртуальных функций этого класса».

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

https://stackoverflow.com/a/203136/437894 =

«Таким образом, во время выполнения код просто использует vptr объекта, чтобы найти vtbl, а оттуда - адрес фактической замещаемой функции».

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


person Ashwin    schedule 08.10.2015    source источник
comment
Нет ничего, что прописывает макет vtbl. Но естественный способ для компилятора - пронумеровать виртуальные функции в классе в последовательном порядке. Эти числа служат индексами в vtbl, который фактически представляет собой массив указателей на функции.   -  person Gene    schedule 08.10.2015
comment
Компилятор знает, что находится в vtable, потому что он создал vtable. Непонятно, о чем вы на самом деле спрашиваете.   -  person user207421    schedule 08.10.2015
comment
@Gene досадно, что MSVC также группирует перегрузки вместе, даже если они не объявлены в таком порядке. (О, и, естественно, с виртуальным наследованием все становится странно)   -  person Yakk - Adam Nevraumont    schedule 08.10.2015
comment
@EJP Я не могу понять, как компилятор отображает виртуальные функции в классе на записи в vtable. Надеюсь, это проясняет мой вопрос.   -  person Ashwin    schedule 08.10.2015
comment
Но это не имеет значения, не так ли? Он помещает записи в vtable и генерирует код для их повторного считывания. Какое отображение он использует, значения не имеет. Страуструп использовал порядок объявления, но он мог быть любым, лишь бы он был согласован.   -  person user207421    schedule 08.10.2015
comment
NB VTable содержит адрес виртуальной функции, а не адрес фактической замещаемой функции. Он содержит адрес переопределения для текущего класса. Иначе было бы бессмысленно.   -  person user207421    schedule 08.10.2015


Ответы (2)


Я немного изменю ваш пример, чтобы он показал более интересные аспекты объектной ориентации.

Предположим, у нас есть следующее:

#include <iostream>

struct Animal
{
  int age;
  Animal(int a) : age {a} {}
  virtual int setAge(int);
  virtual void sayHello() const;
};

int
Animal::setAge(int a)
{
  int prev = this->age;
  this->age = a;
  return prev;
}

void
Animal::sayHello() const
{
  std::cout << "Hello, I'm an " << this->age << " year old animal.\n";
}

struct Tiger : Animal
{
  int stripes;
  Tiger(int a, int s) : Animal {a}, stripes {s} {}
  virtual void sayHello() const override;
  virtual void doTigerishThing();
};

void
Tiger::sayHello() const
{
  std::cout << "Hello, I'm a " << this->age << " year old tiger with "
            << this->stripes << " stripes.\n";
}

void
Tiger::doTigerishThing()
{
  this->stripes += 1;
}


int
main()
{
  Tiger * tp = new Tiger {7, 42};
  Animal * ap = tp;
  tp->sayHello();         // call overridden function via derived pointer
  tp->doTigerishThing();  // call child function via derived pointer
  tp->setAge(8);          // call parent function via derived pointer
  ap->sayHello();         // call overridden function via base pointer
}

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

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

struct animal прост:

struct animal
{
  const void * vptr;
  int age;
};

В дополнение к члену age мы добавили vptr, который будет указателем на vtable. Я использую для этого указатель void, потому что нам все равно придется делать уродливые преобразования, а использование void * немного уменьшает уродство.

Затем мы можем реализовать функции-члены.

static int
animal_set_age(void * p, int a)
{
  struct animal * this = (struct animal *) p;
  int prev = this->age;
  this->age = a;
  return prev;
}

Обратите внимание на дополнительный 0-й аргумент: указатель this, который неявно передается в C ++. Опять же, я использую указатель void *, поскольку это упростит задачу в дальнейшем. Обратите внимание, что внутри любой функции-члена мы всегда знаем тип указателя this статически, поэтому приведение не является проблемой. (И на машинном уровне он в любом случае ничего не делает.)

Член sayHello определяется аналогично, за исключением того, что указатель this на этот раз квалифицирован const.

static void
animal_say_hello(const void * p)
{
  const struct animal * this = (const struct animal *) p;
  printf("Hello, I'm an %d year old animal.\n", this->age);
}

Время для животных vtable. Сначала мы должны дать ему простой тип.

struct animal_vtable_type
{
  int (*setAge)(void *, int);
  void (*sayHello)(const void *);
};

Затем мы создаем единственный экземпляр vtable и настраиваем его с правильными функциями-членами. Если бы Animal имел чистый virtual член, соответствующая запись имела бы значение NULL и лучше не разыменовывалась.

static const struct animal_vtable_type animal_vtable = {
  .setAge = animal_set_age,
  .sayHello = animal_say_hello,
};

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

Теперь мы можем реализовать конструктор для Animal

void
animal_ctor(void * p, int age)
{
  struct animal * this = (struct animal *) p;
  this->vptr = &animal_vtable;
  this->age = age;
}

… И соответствующий operator new:

void *
animal_new(int age)
{
  void * p = malloc(sizeof(struct animal));
  if (p != NULL)
    animal_ctor(p, age);
  return p;
}

Единственное, что интересно, это строка, в которой в конструкторе задано vptr.

Перейдем к тиграм.

Tiger наследуется от Animal, поэтому получает struct tiger подобъект. Я делаю это, помещая struct animal в качестве первого члена. Важно, чтобы это был первый член, потому что это означает, что первый член этого объекта - vptr - имеет тот же адрес, что и наш объект. Нам это понадобится позже, когда мы проведем сложный кастинг.

struct tiger
{
  struct animal base;
  int stripes;
};

Мы также могли бы просто скопировать члены struct animal лексически в начале определения struct tiger, но это может быть труднее поддерживать. Компилятор не заботится о таких стилистических проблемах.

Мы уже знаем, как реализовать функции-члены для тигров.

void
tiger_say_hello(const void * p)
{
  const struct tiger * this = (const struct tiger *) p;
  printf("Hello, I'm an %d year old tiger with %d stripes.\n",
         this->base.age, this->stripes);
}

void
tiger_do_tigerish_thing(void * p)
{
  struct tiger * this = (struct tiger *) p;
  this->stripes += 1;
}

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

Рядом с vtable:

struct tiger_vtable_type
{
  int (*setAge)(void *, int);
  void (*sayHello)(const void *);
  void (*doTigerishThing)(void *);
};

Обратите внимание, что первые два члена точно такие же, как для animal_vtable_type. Это важный и, по сути, прямой ответ на ваш вопрос. Возможно, это было бы более ясно, если бы я поместил struct animal_vtable_type в качестве первого члена. Я хочу подчеркнуть, что макет объекта был бы точно таким же, за исключением того, что в этом случае мы не могли разыграть наши неприятные трюки с приведением типов. Опять же, это аспекты языка C, которых нет на машинном уровне, поэтому компилятора это не беспокоит.

Создайте экземпляр vtable:

static const struct tiger_vtable_type tiger_vtable = {
  .setAge = animal_set_age,
  .sayHello = tiger_say_hello,
  .doTigerishThing = tiger_do_tigerish_thing,
};

И реализуем конструктор:

void
tiger_ctor(void * p, int age, int stripes)
{
  struct tiger * this = (struct tiger *) p;
  animal_ctor(this, age);
  this->base.vptr = &tiger_vtable;
  this->stripes = stripes;
}

Первым делом конструктор тигра вызывает конструктор животного. Помните, как конструктор животных устанавливает vptr в &animal_vtable? Это причина того, почему вызов virtual функций-членов из конструктора базового класса часто удивляет людей. Только после запуска конструктора базового класса мы повторно назначаем vptr производному типу, а затем выполняем нашу собственную инициализацию.

operator new - это просто шаблон.

void *
tiger_new(int age, int stripes)
{
  void * p = malloc(sizeof(struct tiger));
  if (p != NULL)
    tiger_ctor(p, age, stripes);
  return p;
}

Были сделаны. Но как вызвать виртуальную функцию-член? Для этого я определю вспомогательный макрос.

#define INVOKE_VIRTUAL_ARGS(STYPE, THIS, FUNC, ...)                     \
  (*((const struct STYPE ## _vtable_type * *) (THIS)))->FUNC( THIS, __VA_ARGS__ )

Это уродливо. Он принимает статический тип STYPE, this указатель THIS и имя функции-члена FUNC, а также любые дополнительные аргументы, передаваемые в функцию.

Затем он создает имя типа vtable из статического типа. (## - это оператор вставки токена препроцессора. Например, если STYPE равно animal, тогда STYPE ## _vtable_type будет расширяться до animal_vtable_type.)

Затем указатель THIS приводится к указателю на указатель только что производного типа vtable. Это работает, потому что мы позаботились о том, чтобы vptr был первым элементом в каждом объекте, чтобы у него был один и тот же адрес. Это очень важно.

Как только это будет сделано, мы можем разыменовать указатель (чтобы получить фактический vptr), а затем запросить его член FUNC и, наконец, вызвать его. (__VA_ARGS__ расширяется до дополнительных аргументов макроса с переменным числом аргументов.) Обратите внимание, что мы также передаем указатель THIS как 0-й аргумент функции-члену.

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

#define INVOKE_VIRTUAL(STYPE, THIS, FUNC)                               \
  (*((const struct STYPE ## _vtable_type * *) (THIS)))->FUNC( THIS )

И это работает:

#include <stdio.h>
#include <stdlib.h>

/* Insert all the code from above here... */

int
main()
{
  struct tiger * tp = tiger_new(7, 42);
  struct animal * ap = (struct animal *) tp;
  INVOKE_VIRTUAL(tiger, tp, sayHello);
  INVOKE_VIRTUAL(tiger, tp, doTigerishThing);
  INVOKE_VIRTUAL_ARGS(tiger, tp, setAge, 8);
  INVOKE_VIRTUAL(animal, ap, sayHello);
  return 0;
}

Вам может быть интересно, что происходит в

INVOKE_VIRTUAL_ARGS(tiger, tp, setAge, 8);

вызов. Что мы делаем, так это вызываем непереопределенный setAge член Animal на Tiger объекте, на который имеется ссылка через указатель struct tiger. Этот указатель сначала неявно приводится к указателю void и как таковой передается как указатель this на animal_set_age. Затем эта функция преобразует его в указатель struct animal. Это правильно? Это потому, что мы осторожно поместили struct animal в качестве самого первого члена в struct tiger, чтобы адрес объекта struct tiger был таким же, как адрес для подобъекта struct animal. Это тот же трюк (только на один уровень меньше), который мы использовали с vptr.

person 5gon12eder    schedule 08.10.2015
comment
Спасибо за подробное объяснение. Это помогло. Также нашел еще один пример. Здесь они говорят о примере с двумя базовыми классами и производными классами, использующими виртуальную функцию из обоих базовых классов. ссылка - person Ashwin; 26.10.2015
comment
Для вашего следующего класса, пожалуйста, расскажите virtual о наследовании и о том, как имитировать его в C. :) +1 Кроме того, преимущество этой системы в том, что вы можете отделить vtable от данных (не хранить их непрерывно), что может разрешите некоторые уловки. Эти методы полезны в C ++, когда вам нужно сделать это разделение (скажем, если вы хотите хранить свои данные где-то во внутреннем буфере, но действовать на них полиморфно: или крошечные легкие объекты, упакованные в один массив, с длиной выполнения - закодированные vtables в другом месте, которые вы можете использовать при обработке текста) - person Yakk - Adam Nevraumont; 07.06.2016
comment
Большое спасибо за ответ. Вопрос: как компилятор в первую очередь узнает, что он должен выполнить вызов виртуальной функции? Если бы у меня было: X * z = new z (); Таким образом, X не имеет виртуальных функций, Y наследуется от X и имеет виртуальную функцию, а Z наследуется от Y. Все вызовы функций из z выше должны осуществляться через vptr, но типом является X *, поэтому как компилятор узнает, что вызовы функций по z должны проходить через vptr или нет? - person Dean Leitersdorf; 31.05.2017
comment
@DeanLeitersdorf В вашем примере статическим типом переменной будет X*. И поскольку (вы это сказали) X не имеет виртуальных функций, вы не могли бы их вызвать. Проблема возникает тогда, когда (в C ++) вы снова используете delete, чтобы освободить объект. В самом деле, компилятор не имеет возможности узнать на данном этапе, что он должен пройти через vtable, чтобы найти деструктор динамического типа. Что произойдет в C ++, так это то, что будет вызван деструктор статического типа (X), и ваша программа вызовет неопределенное поведение. tl; dr Не делай этого. - person 5gon12eder; 12.06.2017

Это может помочь реализовать нечто подобное самостоятельно.

struct Bob;
struct Bob_vtable {
  void(*print)(Bob const*self) = 0;
  Bob_vtable(void(*p)(Bob const*)):print(p){}
};
template<class T>
Bob_vtable const* make_bob_vtable(void(*print)(Bob const*)) {
  static Bob_vtable const table(+print);
  return &table;
}
struct Bob {
  Bob_vtable const* vtable;
  void print() const {
    vtable->print(this);
  }
  Bob():vtable( make_bob_vtable<Bob>([](Bob const*self){
    std::cout << "Bob\n";
  })) {}
protected:
  Bob(Bob_vtable const* t):vtable(t){}
};
struct Alice:Bob {
  int x = 0;
  Alice():Bob( make_bob_vtable<Alice>([](Bob const*self){
    std::cout << "Alice " << static_cast<Alice const*>(self)->x << '\n';
  })) {}
};

живой пример.

Здесь у нас есть явная vtable, хранящаяся в Bob. Он указывает на таблицу функций. Невиртуальная функция-член print использует ее для динамической отправки правильному методу.

Конструктор Bob и производный класс Alice устанавливают для vtable другое значение (в данном случае создается как статический локальный) с разными значениями в таблице.

Какой указатель использовать, встроен в определение того, что означает Bob::print - он знает смещение в таблице.

Если мы добавим еще одну виртуальную функцию в Алису, это просто означает, что указатель vtable на самом деле будет указывать на struct Alice_vtable:Bob_vtable. Статическое / переинтерпретируемое приведение даст нам "настоящую" таблицу, и мы сможем легко получить доступ к дополнительным указателям на функции.

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

person Yakk - Adam Nevraumont    schedule 08.10.2015