Компилятор Microsoft позволяет добавить расширение «novtable» для атрибута «__declspec» при объявлении класса.
Заявленная цель — значительно уменьшить размер генерируемого кода. На экспериментах с нашими компонентами уменьшение составило от 0,6 до 1,2 процента от размера DLL.
Применимость: классы, не предназначенные для создания экземпляров напрямую из них.
Например: чисто интерфейсные классы.
В коде это выглядит так:
struct __declspec(novtable) IDrawable
{
virtual void Draw() const = 0;
};
Примечание: ключевое слово struct использовалось для декларации интерфейсного класса, чтобы избавить пример от не относящихся к теме статьи деталей; тогда как в случае использования class пришлось бы использовать public для указания «публичности» методов. По той же причине я не буду в этой статье добавлять виртуальный деструктор в интерфейсный класс.
Название «novtable» обещает, что виртуальной таблицы не будет… Но как же работает механизм вызова виртуальных функций в следующем коде:
// Добавим декларацию прямоугольника, реализующего интерфейс IDrawable:
class Rectangle : public IDrawable
{
virtual void Draw() const override
{
}
int width;
int height;
};
…
IDrawable* drawable = new Rectangle;
drawable->Draw(); // происходит вызов Rectangle::Draw
…
Вспомним, что добавляется при объявлении виртуальной функции в классе:
- Определение таблицы виртуальных функций. Используется один экземпляр этой таблицы для всех экземпляров класса.
- В члены данных класса добавляется указатель на таблицу виртуальных функций.
- Код по инициализации этого указателя в конструкторе класса.
Таким образом, в нашем примере будет существовать декларация двух таблиц виртуальных функций: для IDrawable и для Rectangle. При создании объекта Rectangle первым выполняется конструктор IDrawable, который инициализирует указатель на свою таблицу виртуальных функций. Схематично это выглядит так:
Так как функция draw в IDrawable объявлена чисто-виртуальной (указано "=0" вместо тела функции), то в таблице виртуальных функций записан адрес генерируемой компилятором функции purecall.
Затем выполняется конструктор Rectangle, который инициализирует тот же указатель, но на свою таблицу виртуальных функций:
Что же делает «novtable», и почему Microsoft обещает уменьшение размера кода?
Именно ненужное определение таблицы виртуальных функций IDrawable и инициализация указателя на нее в конструкторе IDrawable исключаются из результирующего кода при добавлении «novtable».
В этом случае при конструировании IDrawable указатель на таблицу виртуальных функций будет содержать непредсказуемое значение. Но это не должно нас беспокоить, так как создание реализации с обращением к виртуальным функциям до полного конструирования объекта, как правило, является ошибкой. Если, например, в конструкторе базового класса вызывать невиртуальную функцию этого класса, которая в свою очередь вызывает виртуальную функцию, то без novtable будет вызвана функция purecall, а с novtable — будет непредсказуемое поведение; ни один из вариантов не может быть приемлемым.
Заметим, что происходит не только уменьшение размера, но и некоторое ускорение работы программы.
RTTI
Как известно, std::dynamic_cast позволяет приводить указатели и ссылки одного экземпляра класса к указателю и ссылке на другой, если эти классы связаны иерархией и являются полиморфными (содержат таблицу виртуальных функций). В свою очередь оператор typeid позволяет получать в runtime информацию об объекте по переданному ему указателю (ссылке) на этот объект. Эти возможности обеспечиваются механизмом RTTI, который использует информацию о типах, расположенную с привязкой к vtable класса. Детали структуры и расположения зависят от компилятора. В случае компилятора Microsoft схематично это выглядит так:
Поэтому если при сборке компилятору приказано включить RTTI, то novtable исключает еще и создание определения type_info для IDrawable и требуемых для нее служебных данных.
Заметим, что если у вас каким-то образом обеспечивается знание, что приводимый указатель (ссылка) на базовый класс указывает на реализацию производного, то std::static_cast эффективнее и не требует RTTI.
Microsoft specific
Помимо MSVC, данная возможность с тем же самым синтаксисом присутствует в Clang при компиляции под Windows.
Выводы
- __declspec(novtable) — никак не влияет на объем памяти, занимаемый экземплярами класса.
- Уменьшение размера и некоторое ускорение работы программы обеспечивается за счет исключения определения неиспользуемых таблицы виртуальных функций, служебных данных RTTI и исключения кода инициализации указателя на таблицу виртуальных функций в конструкторах интерфейсных классов.
khim
Красота! Ещё чуть-чуть и они догонят Turbo Pascal 5.5, выпущенный 30 лет назад. Который просто инициализировал vtbl при создании класса один раз (независимо от схемы наследования). Потом эту «фичу» переняла Java…
iliazeus
Но ведь в Turbo Pascal 5.5 инициализация vptr — тоже задача конструктора; значит, конструктор предка, вызванный конструктором потомка, все равно будет ее выполнять, разве нет? Все равно получаются «напрасные» присваивания.
khim
Нет, не будет. Конструктор в Turbo Pascal вызывается после того, как память аллоцирована и vptr прописан. И в C++, обычно, тоже порождается не один конструктор и не один деструктор. Вот тут — на два класса три конструктора и пять (sic!) деструкторов.
Конкретная стратегия зависит от компилятора, но из-за вот этого вот самого идиотского и никому не нужного требования — получаются ощутимые потери.
Я на эту тему высказывался много раз: всё это нужно только и исключительно для того, чтобы избежать виртуального вызова функции из конструктора. Что является дикостью несусветной. Похоже на то, как если бы в суровый вездеход для суровых условий типа Харьковчанки поставили в угол креслице для новорожденного.
iliazeus
Спасибо, теперь все понятно.
Antervis
да, но на самом деле нет. Если убрать виртуальное наследование (которое в реальном коде практически не встречается) будет два конструктора и четыре деструктора
khim
Удвоенное количество деструкторов всё равно остаётся. И лишние таблицы, которые прописываются — тоже.
Главная проблема даже не в том, что всё это ресурсоёмко, а в том, кто нарушается базовый принцип: «вы не платите за то, что не заказывали».
То, что его нарушает RTTI и исключения — всем известно. А вот то, что простые такие, бесхитростные, объекты — этом тоже страдают… про это знают немногие.
DistortNeo
Его не надо убирать, это крайне полезная штука. Виртуальное наследование позволяет реализовывать механизм интерфейсов (принципы SOLID, всё такое). Взгляните на тот же C#, Java, Delphi/Builder. В C++ же виртуальное наследование почему-то не пользуется популярностью.
qw1
DistortNeo
множественное виртуальное наследование от интерфейсов
khim
Нет. Интерфейсам не нужно виртуального наследования. Так как они не содержат данных.
То, что в C++ наследование интерфейсов приходится имитировать через виртуальное наследование — ограничение C++.
DistortNeo
Вообще-то указатель на таблицу виртуальных функций — это тоже поле данных, просто скрытое от программиста.
И, кстати, как же вы без виртуального наследования будете разруливать ромбовидную иерархию интерфейсов?
Да, причём получается не очень эффективно. Сложная иерархия интерфейсов приводит к тому, что в объекте вместо одного vptr оказывается несколько, из-за чего размер объекта нехило так разрастается.
В языках же с нативной поддержкой интерфейсов vptr всегда один.
qw1
DistortNeo
Если метод виртуальный, то ссылка на него определяется в процессе выполнения программы и лежит в vptr. То же самое происходит при доступе к полям при виртуальном наследовании.
Можно, но тогда объект будет занимать слишком много места в памяти из-за большого количества vptr.
В случае .NET же этот указатель единственный, просто реализация таблицы становится гораздо более сложной:
https://stackoverflow.com/questions/9808982/clr-implementation-of-virtual-method-calls-to-interface-members
qw1
Это и есть виртуальное наследование в C++, потому что ссылка на метод определяется в процессе выполнения программы и лежит в vptr? А это тогда как называется:
Не то же самое. Для данных нужно 2 уровня косвенности — считываем адрес vtable, из неё смещение поля в классе, и только потом данные. Для методов только 1 уровень.
Это всё оптимизируется, сворачивая линейные участки до 1 записи, оставляя только развилки (если B наследуется от A, достаточно подставить vptr от B, и он будет совместим по разметке с vptr A).
DistortNeo
Ссылка на метод определяется в процессе компиляции, но подстановка ссылки на vptr — в процессе работы программы. Причём что при обычном наследовании, что при виртуальном.
А вот и нет: https://stackoverflow.com/questions/30870096/c-virtual-inheritance-memory-layout
Для данных — один уровень косвенности: получение смещения дочернего объекта из vtable текущего объекта.
Для методов — два уровня: получение смещения дочернего объекта и поиск метода в таблице виртуальных методов дочернего объекта.
qw1
Имея ссылку на объект, для чтения поля нужно сделать 2 лишних чтения:
1) vptr
2) смещение поля
3) чтение значения (уже не «лишнее», необходимо и без наследований).
Для получения смещения не нужно делать чтение из памяти — оно известно при компиляции
DistortNeo
Нет, конечно. А причём тут это?
При виртуальном наследовании это смещение зависит от родительского объекта и хранится в vtable.
qw1
И, кстати, как же вы без виртуального наследования будете разруливать ромбовидную иерархию интерфейсов?
Я считаю, что в конструкции типа
class A: IDrawable, ISerializable
нет виртуального наследования, но возможна ромбовидная иерархия интерфейсов.
А пример кода можете привести?
DistortNeo
Это исключительно вопрос терминологии.
Да легко:
qw1
Для методов — два уровня: получение смещения дочернего объекта и поиск метода в таблице виртуальных методов дочернего объекта.
DistortNeo
Это зависит от реализации. Если реализация предполагает дублирование указателей в vtable родительского объекта, то будет один уровень индирекции.
qw1
Навряд ли есть реализации, не дублирующие в vtable указатели на функции родительского объекта.
И причин тому несколько:
1) При вызове метода появляется лишний уровень индирекции.
2) Сами объекты сильно раздуваются при обычном линейном наследовании, даже не множественном и не виртуальном. Если глубина линейной иерархии — 5, потребуется 5 vptr, когда в стандартной реализации только 1.
3) vtable одна на класс, и бессмысленно экономить на её длине в ущерб увеличения размера объекта или сложности кода.
DistortNeo
При линейной иерархии нет никаких проблем всё держать в одной таблице. Но при множественном наследовании каждый дочерний объект, имеющий виртуальные методы, будет иметь отдельный vptr.
Решить проблему с увеличением размера объекта в этом случае можно (см. C#/Java), но ценой усложнения кода и падения производительности.
Так как C++ предназначен для написания, в первую очередь, высокопроизводительного кода, активно задействуется статический полиморфизм (вместо множественного наследования интерфейсов — концепты), то множественное наследование проще объявить антипаттерном, приводящим к падению производительности, увеличению потребления памяти и усложнению логики программы.
Antervis
в с++, виртуальное наследование по сравнению с обычным наследованием от классов с виртуальными функциями позволяет одну и только одну возможность — реализовывать ромбовидное наследование. Всё. SOLID тут совсем не причем. Не путайте наследование от классов с виртуальными методами и виртуальное наследование.
в c#/java нет множественного наследования как такового, а значит, там нет и ромбовидного наследования. В любом случае, множественное наследование считается антипаттерном, отчего и ромбовидное встречается (ну, по крайней мере должно встречаться) крайне редко.
DistortNeo
Прнципы SOLID призывают вместо одного большого интерфейса создавать множество мелких. Виртуальное наследование позволяет не заботиться о том, что один и тот же интерфейс может встретиться несколько раз.
Неверно. Если оперерировать терминами C++, то в C#/Java интерфейсы наследуются множественно и всегда виртуально.
Не согласен. Вот пример, где без ромбовидного наследования ну вообще никуда:
Antervis
если возводить принцип interface segregation в абсолют, создавая по одному интерфейсу на каждый публичный метод класса, а потом комбинируя их всеми возможными способами через наследование, то да, придется иметь дело и с ромбовидным. Посмею предположить, что этот принцип неудачно сформулирован.
в с++ (о котором идет речь) конкретно для этого набора (реализации списка и других контейнеров) можно обойтись вообще без наследования. Касательно виртуального наследования — лично мне за всю карьеру оно понадобилось дважды, и оба использования я считаю скорее вынужденными хаками, нежели элегантной архитектурой. Более того, даже сам термин «виртуальное наследование» существует только применительно к с++.
DistortNeo
И это является хорошим примером, показывающим, почему множественное наследование классов (ещё и виртуальное) — зло.
dim2r
Паскаль не догонишь, там есть checked arithmetics и helper классы.
Ivaneo
В C++ не хватает такой сущности как interface, — структурой в которой такая оптимизация была бы включена по умолчанию и компилятор, который выдавал ошибку если в интерфейс добавить что либо кроме виртуальных функций.
qw1
А кто мешает компилятору понять, что у класса нет реализаций вирт. ф-ций (т.е., это абстрактный класс) и не инициализировать vtable и заодно конструктор не создавать, чтобы наследники этот конструктор не вызывали.
to_climb
Наверное то, что если вы захотите привести указатель на интерфейс к его реализации (dynamic_cast'ом), то программа проведёт себя очень странно.
qw1
И почему же? Корректный указатель на интерфейс всегда фактически указывает на наследника (реализацию), который имеет корректный vtable, инициализированный в конструкторе наследника.
to_climb
Да, соглашусь, поторопился.
vtable один на объект (для каждого интерфейса), корректно инициализированный наследниками, и множественное/виртуальное наследование тут ничего не сломает при хождении по иерархии.
khim
Не всегда. Во время работы конструктора и деструктора абстрактного класса (а они абстрактными быть не могут) — vtable должна указывать на сам этот класс.
qw1
khim
Потому что так устроен C++, однако. Теоретически да, можно сделать невиртуальный деструктор и гарантировать как-нибудь, что через ваш интерфейс объект никогда-никогда не удалят… Практически — это слишком опасно.
qw1
khim
А что будет в vtable во время работы вышеупомянутого деструктора, извините?
qw1
Но всё равно, оптимизация, которая убирает необходимость в концепциях interface и __desclspec(novtable), возможна, и её можно сформулировать следующим образом:
— если класс содержит только абстрактные виртуальные методы (кроме, возможно, деструктора)
— и деструктор (возможно, виртуальный) либо отсутствует, либо пустой (либо = default, что характерно для интерфейсов)
— то можно в конструкторе такого класса не инициализировать vtable. Соответственно, такой конструктор, который раньше состоял только из инициализации vtable, может вообще исчезнуть, и в конструкторах классов-наследников его вызов тоже можно удалить.
Вроде, всё предусмотрел?
khim
А так — да, в некоторых случаях действительно можно избавиться от конструктора/деструктора и vtable. Примерно в описанных вами случаях — посмотрите на пример, который мы уже обсуждали.
В функции bar никакого двойного переписывания vptr нету. А почему тогда сгенерированы все эти бесконечные конструкторы и деструкторы? А потому что ABI так говорит: вдруг другой компилятор решит их вызвать?
Так что описанное в статье — это, грубо говоря, обещание компилятору: «друг, верь, я не идиот, вот эту вот всю муть, которая никому не нужна — можешь выкидывать смело».
В Linux принят другой подход: если сделать вот так — то все эти бесконечные таблицы по-прежнему будут сгенерированы, но линкер сможет от них избавится…
qw1
khim
Ну это у разработчиков Visual Studio нужно спросить. Это ж новейшая технология нужна! В Turbo Pascal она появилась в версии 4.0, 1987й год (и была включена по умолчанию!), в gcc — тоже в прошлом веке (но до сих пор не включается автоматом, ручками нужно заказывать), а MSVC, похоже, ниасилил…
iliazeus
Сейчас, вроде, все.
Только этот случай покрывается уже существующими оптимизациями (инлайнинг конструктора базового класса + удаление лишнего присвоения + удаление «мертвого» кода конструктора базового класса).
godbolt.org/z/HxCGjt
khim
Дык конструктор-то быть как раз обязан… И виртуальная функция, как минимум одна, таки есть: деструктор…
qw1
Не нужна. Вот пример:
khim
Ну если вам так хочется выстрелить себе в ногу, то в C++ есть много других способов. Заметьте, что вы теперь не можете передать куда-то IDrawable и потом от этого объекта избавиться: удалять его должен тот же, кто его создал. В большинстве случаев так использовать интерфейсы черезвычайно неудобно. А если вы создадите виртуальный деструктор (чтобы ваши Line и Rect освобождались как Line и Rect при доступе через IDrawable) — то получите весь букет обсуждающихся проблем… даже если этот виртуальный деструктор будет пустой…
qw1
В остальном вы не правы. Детально ответил выше.
khim
DistortNeo
Есть такая штука — C++ Builder, которая умеет делать подобное. Отдельного ключевого слова для интерфейсов в нём нет, но если класс удовлетворяет свойствам интерфейса, то он реализуется особым образом.