>
>
>
Таблица виртуальных методов и техника б…

Михаил Никишин
Статей: 3

Таблица виртуальных методов и техника безопасности

В качестве небольшой разминки перед статьёй хотелось бы, чтобы читатель задал себе следующий вопрос: нужно ли фотографу для получения качественных снимков знать, как работает фотоаппарат? Ну, по крайней мере, должен ли он знать понятие "диафрагма"? "Отношение сигнал-шум"? "Глубина резкости"? Практика подсказывает, что даже со знанием таких сложных слов снимки могут получиться у наиболее "рукастых" не особо лучше снятых на мобильник через 0.3-МПикс-дупло. И наоборот, по-настоящему хорошие снимки могут получаться благодаря исключительно опыту и наитию при полном незнании матчасти (хотя это, скорее, исключения из правил, но всё же). Однако вряд ли со мной кто-то будет спорить, что профессионалам, которые хотят выжать из своей техники всё (а не только количество мегапикселей на квадратный миллиметр матрицы), эти знания нужны в обязательном порядке, поскольку в противном случае ему и называться профессионалом-то нельзя. И верно это не только для отрасли цифровой фотографии, но и для практически любой другой.

Верно это и для программирования, а для программирования на языке С++ – вдвойне. В этой статье будет описано важное понятие языка, известное как "Виртуальный табличный указатель", что присутствует почти во всех сложных классах, и то, каким образом его можно случайно повредить. Это может, в свою очередь, вести к едва поддающимся отладке ошибкам. Сначала напомню, что это вообще такое, а затем и поделюсь своими соображениями по поводу того, как и что может там сломаться.

К нашему огромному сожалению, в этой статье будет много рассуждений, связанных с низким уровнем. Но больше никак проблему, увы, не проиллюстрировать. Заодно оговорюсь, что статья написана по большей части для компилятора Visual C++ Compiler в режиме сборки 64-битной программы – результаты работы программы в других компиляторах и под другую архитектуру могут отличаться.

Виртуальный табличный указатель

В теории сказано, что указатель vptr – указатель на таблицу виртуальных методов, или виртуальный табличный указатель – присутствует в каждом классе, в котором есть хотя бы один виртуальный метод. Разберёмся поподробнее, что же это за зверь-то такой. Для этого напишем простенькую демонстрационную программу на языке С++.

#include <iostream>
#include <iomanip>
using namespace std;
int nop() {
  static int nop_x; return ++nop_x; // Не удаляй меня, компилятор!
};

class A
{
public:
  unsigned long long content_A;
  A(void) : content_A(0xAAAAAAAAAAAAAAAAull)
      { cout << "++ A has been constructed" << endl;};
  ~A(void) 
      { cout << "-- A has been destructed" << endl;};

  void function(void) { nop(); };
};

void PrintMemory(const unsigned char memory[],
                 const char label[] = "contents")
{
  cout << "Memory " << label << ": " << endl;
  for (size_t i = 0; i < 4; i++) 
  {
    for (size_t j = 0; j < 8; j++)
      cout << setw(2) << setfill('0') << uppercase << hex
           << static_cast<int> (memory[i * 8 + j]) << " ";
    cout << endl;
  }
}

int main()
{
  unsigned char memory[32];
  memset(memory, 0x11, 32 * sizeof(unsigned char));
  PrintMemory(memory, "before placement new");

  new (memory) A;
  PrintMemory(memory, "after placement new");
  reinterpret_cast<A *>(memory)->~A();

  system("pause");
  return 0;
};

Несмотря на относительно большой объём кода, логика его работы должна быть достаточно очевидна: на стеке выделяется 32 байта, которые заполняются значениями 0x11 (считаем, что это как бы "мусор" в памяти). Затем поверх этих 32 байт при помощи оператора placement new создаётся достаточно тривиальный объект класса A. Наконец, производится печать содержимого памяти, после чего программа разрушает объект и завершает своё выполнение. Ниже представлен вывод данной программы (Microsoft Visual Studio 2012, x64).

Memory before placement new:
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
++ A has been constructed
Memory after placement new:
AA AA AA AA AA AA AA AA
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
-- A has been destructed
Press any key to continue . . .

Нетрудно заметить, что размер класса в памяти составляет 8 байт и равен размеру единственного его члена unsigned long long content_A.

Немного усложним программу, добавив к объявлению функции void function(void) ключевое слово virtual:

virtual void function(void) {nop();};

Вывод программы (здесь и далее будет показываться лишь часть вывода за исключением Memory before placement new и Press any key...):

++ A has been constructed
Memory after placement new:
F8 D1 C4 3F 01 00 00 00
AA AA AA AA AA AA AA AA
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
-- A has been destructed

Опять же, нетрудно заметить, что размер класса в памяти теперь составляет 16 байт. Первые восемь байт теперь занимает указатель на таблицу виртуальных методов. Указатель при этом запуске программы оказался равен 0x000000013FC4D1F8 (указатель и content_A "развёрнуты" в памяти, так как Intel64 использует little-endian порядок байт; правда, в случае с content_A так сразу и не скажешь об этом).

Таблица виртуальных методов – специальная структура в памяти, генерируемая автоматически, в которой перечислены указатели на виртуальные методы. В случае, если где-то в коде вызывается метод function() применительно к указателю на класс A, вместо вызова непосредственно функции A::function() будет произведён вызов функции, находящейся в таблице виртуальных методов по нужному смещению – это поведение реализует полиморфизм. Сама по себе таблица виртуальных функций представлена ниже (получена путём компиляции с ключом /FAs; дополнительно обратите внимание на несколько странное имя функции в ассемблерном коде – оно прошло через "манглинг имён"):

CONST SEGMENT
??_7A@@6B@ DQ  FLAT:??_R4A@@6B@   ; A::'vftable'
 DQ FLAT:?function@A@@UEAAXXZ
CONST ENDS

__declspec(novtable)

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

CONST SEGMENT
??_7A@@6B@ DQ FLAT:??_R4A@@6B@ ; A::'vftable'
 DQ FLAT:_purecall
CONST ENDS

Очевидно, что попытка вызова такой функции приведёт к прострелу собственной ноги.

Встаёт вопрос: если класс никогда не инстанцируется, то зачем устанавливать виртуальный табличный указатель? Для того, чтобы компилятор не генерировал лишний код, ему можно дать указание в виде __declspec(novtable) (осторожно: Microsoft-specific!). Перепишем наш пример класса с виртуальной функции с использованием атрибута __declspec(novtable):

class __declspec(novtable) A { .... }

Вывод программы станет следующим:

++ A has been constructed
Memory after placement new:
11 11 11 11 11 11 11 11
AA AA AA AA AA AA AA AA
11 11 11 11 11 11 11 11
11 11 11 11 11 11 11 11
-- A has been destructed

В первую очередь обратим внимание на то, что размер объекта не изменился: он по-прежнему занимает 16 байт. Итого после внесения атрибута __declspec(novtable) появилось всего два отличия: во-первых, теперь на том месте, где раньше располагался адрес таблицы виртуальных методов, находится неинициализированная область памяти; во-вторых – в ассемблерном коде таблицы виртуальных методов класса A теперь нет вообще. Но виртуальный табличный указатель по-прежнему есть и по-прежнему "весит" восемь байт! Это нужно помнить, потому что...

Наследование

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

class __declspec(novtable) A // Я никогда не инстанцируюсь
{
public:
  unsigned long long content_A;
  A(void) : content_A(0xAAAAAAAAAAAAAAAAull)
      { cout << "++ A has been constructed" << endl;};
  ~A(void) 
      { cout << "-- A has been destructed" << endl;};

  virtual void function(void) = 0;
};

class B : public A // Я всегда инстанцируюсь вместо A
{
public:
  unsigned long long content_B;
  B(void) : content_B(0xBBBBBBBBBBBBBBBBull)
      { cout << "++ B has been constructed" << endl;};
  ~B(void) 
      { cout << "-- B has been destructed" << endl;};

  virtual void function(void) { nop(); };
};

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

....
new (memory) B;
PrintMemory(memory, "after placement new");
reinterpret_cast<B *>(memory)->~B();
....

Вывод программы будет следующим:

++ A has been constructed
++ B has been constructed
Memory after placement new:
D8 CA 2C 3F 01 00 00 00
AA AA AA AA AA AA AA AA
BB BB BB BB BB BB BB BB
11 11 11 11 11 11 11 11
-- B has been destructed
-- A has been destructed

Попробуем разобраться, что произошло. Был вызван конструктор B::B(). Этот конструктор, прежде чем выполняться, вызывает конструктор базового класса, конструктор A::A(). В первую очередь тот должен был бы инициализировать виртуальный табличный указатель, однако из-за атрибута __declspec(novtable) он не был инициализирован. Затем конструктор устанавливает значение поля content_A в 0xAAAAAAAAAAAAAAAAull (второе поле в памяти) и возвращает управление конструктору B::B().

Поскольку объект B не имеет атрибута __declspec(novtable), конструктор устанавливает виртуальный табличный указатель (первое поле в памяти) на таблицу виртуальных методов класса B, а затем устанавливает content_B в 0xBBBBBBBBBBBBBBBBull (третье поле в памяти) и возвращает управление основной программе. По содержимому памяти можно без труда понять, что объект класса B был сконструирован правильно, а из логики ясно, что ненужная в данном контексте операция была пропущена. Если запутались: под ненужной операцией имеется в виду инициализация указателя на виртуальную таблицу в конструкторе базового класса.

Казалось бы, пропущена всего одна операция – смысл избавляться от неё? Но если в программе тысячи и тысячи классов, унаследованные от одного и того же абстрактного класса, избавление от одной автогенерируемой команды может серьёзно повлиять на производительность. И повлияет. Не верите?

Функция memset

Основная идея функции memset() – заполнение области памяти некоторым константным значением (чаще всего нулями). В языке Си её можно было использовать для быстрой инициализации всех полей структуры. А чем отличается класс С++ от структуры Си по расположению в памяти, если в нём нет виртуального табличного указателя? В принципе, ничем, данные – они и есть данные. Для инициализации действительно простых классов (в терминологии С++11 – типов со стандартным устройством) вполне возможно применять функцию memset(). Но, по идее, функцию memset() можно применять для инициализации вообще всех классов, вот только каковы будут последствия? Неправильный memset() может одним махом привести виртуальный табличный указатель в негодность. Но тут же встаёт вопрос: а, может, всё-таки можно, если класс объявлен как __declspec(novtable)?

Ответ: можно, но только осторожно.

Перепишем классы следующим образом: добавим метод wipe, который будет устанавливать всё содержимое класса A в 0xAA:

class __declspec(novtable) A // Я никогда не инстанцируюсь
{
public:
  unsigned long long content_A;
  A(void)
    {
      cout << "++ A has been constructed" << endl;
      wipe();
    };
    // { cout << "++ A has been constructed" << endl; };
  ~A(void) 
    { cout << "-- A has been destructed" << endl;};

  virtual void function(void) = 0;
  void wipe(void)
  {
    memset(this, 0xAA, sizeof(*this));
    cout << "++ A has been wiped" << endl;
  };
};

class B : public A // Я всегда инстанцируюсь вместо A
{
public:
  unsigned long long content_B;
  B(void) : content_B(0xBBBBBBBBBBBBBBBBull)
      { cout << "++ B has been constructed" << endl;};
      // {
      //   cout << "++ B has been constructed" << endl;
      //   A::wipe();
      // };

  ~B(void) 
      { cout << "-- B has been destructed" << endl;};

  virtual void function(void) {nop();};
};

Вывод программы в этом случае получится достаточно ожидаемым:

++ A has been constructed
++ A has been wiped
++ B has been constructed
Memory after placement new:
E8 CA E8 3F 01 00 00 00
AA AA AA AA AA AA AA AA
BB BB BB BB BB BB BB BB
11 11 11 11 11 11 11 11
-- B has been destructed
-- A has been destructed

Пока всё работает хорошо.

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

++ A has been constructed
++ B has been constructed
++ A has been wiped
Memory after placement new:
AA AA AA AA AA AA AA AA
AA AA AA AA AA AA AA AA
BB BB BB BB BB BB BB BB
11 11 11 11 11 11 11 11
-- B has been destructed
-- A has been destructed

Почему так произошло? Функция wipe() была вызвана уже после того, как конструктор класса B инициализировал указатель на таблицу виртуальных методов. В итоге этот указатель испортился. Иными словами – не стоит обнулять класс с виртуальным табличным указателем, даже если он объявлен с __declspec(novtable). Полное обнуление будет уместно только в конструкторе того класса, который никогда не будет инстанцирован, да и то делать это нужно с большой осторожностью.

Функция memcpy

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

class __declspec(novtable) A
{
  ....
  A(const A &source) { memcpy(this, &source, sizeof(*this)); }
  virtual void foo() { }
  ....
};
class B : public A { .... };

Конструктор копирования может писать всё, что его цифровой душе угодно, в указатель на виртуальную таблицу абстрактного класса: туда всё равно в классах-наследниках будет помещено правильное значение. А вот в реализации оператора присваивания использовать функцию memcpy() уже нельзя:

class __declspec(novtable) A
{
  ....
  A &operator =(const A &source)
  {
    memcpy(this, &source, sizeof(*this)); 
    return *this;
  }
  virtual void foo() { }
  ....
};
class B : public A { .... };

А теперь вспомните, насколько мы привыкли, что оператор присваивания и конструктор копирования – это фактически одно и то же. Нет, не всё так плохо: на практике код оператора присваивания даже может исправно работать, но вовсе не потому, что он корректен, а потому, что так сложились звёзды. В коде копируется указатель на таблицу виртуальных методов из другого объекта, и во что это выльется – неизвестно.

PVS-Studio

Эта статья появилась как результат детального исследования касательно загадочного __declspec(novtable), а также когда можно, а когда нельзя использовать функции memset() и memcpy() в высокоуровневом коде. Нам время от времени пишут разработчики, что анализатор PVS-Studio слишком часто выдаёт предупреждения касательно виртуального табличного указателя. Программисты считают, что если есть __declspec(novtable), то нет ни таблицы виртуальных методов, ни виртуального табличного указателя. Мы начали внимательно разбираться с этим вопросом и поняли, что не всё так просто.

Это надо запомнить. Если при объявлении класса используется __declspec(novtable), это не значит, что класс не содержит указателя на таблицу виртуальных методов! А вот инициализируется этот указатель или нет – это уже совсем другой вопрос.

Мы сделаем так, чтобы анализатор не ругался на функции memset()/memcpy(), но только если они используются в конструкторах базового класса, объявленного с __declspec(novtable).

Заключение

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