Comments 49
Конкретная стратегия зависит от компилятора, но из-за вот этого вот самого идиотского и никому не нужного требования — получаются ощутимые потери.
Я на эту тему высказывался много раз: всё это нужно только и исключительно для того, чтобы избежать виртуального вызова функции из конструктора. Что является дикостью несусветной. Похоже на то, как если бы в суровый вездеход для суровых условий типа Харьковчанки поставили в угол креслице для новорожденного.
Вот тут — на два класса три конструктора и пять (sic!) деструкторов.
да, но на самом деле нет. Если убрать виртуальное наследование (которое в реальном коде практически не встречается) будет два конструктора и четыре деструктора
Главная проблема даже не в том, что всё это ресурсоёмко, а в том, кто нарушается базовый принцип: «вы не платите за то, что не заказывали».
То, что его нарушает RTTI и исключения — всем известно. А вот то, что простые такие, бесхитростные, объекты — этом тоже страдают… про это знают немногие.
Если убрать виртуальное наследование (которое в реальном коде практически не встречается)
Его не надо убирать, это крайне полезная штука. Виртуальное наследование позволяет реализовывать механизм интерфейсов (принципы SOLID, всё такое). Взгляните на тот же C#, Java, Delphi/Builder. В C++ же виртуальное наследование почему-то не пользуется популярностью.
тот же C#, Java, DelphiНе имеют виртуального наследования, а имеют множественное наследование от интерфейсов.
Не имеют виртуального наследования, а имеют множественное наследование от интерфейсов.
множественное виртуальное наследование от интерфейсов
То, что в C++ наследование интерфейсов приходится имитировать через виртуальное наследование — ограничение C++.
Нет. Интерфейсам не нужно виртуального наследования. Так как они не содержат данных.
Вообще-то указатель на таблицу виртуальных функций — это тоже поле данных, просто скрытое от программиста.
И, кстати, как же вы без виртуального наследования будете разруливать ромбовидную иерархию интерфейсов?
То, что в C++ наследование интерфейсов приходится имитировать через виртуальное наследование — ограничение C++.
Да, причём получается не очень эффективно. Сложная иерархия интерфейсов приводит к тому, что в объекте вместо одного vptr оказывается несколько, из-за чего размер объекта нехило так разрастается.
В языках же с нативной поддержкой интерфейсов vptr всегда один.
И, кстати, как же вы без виртуального наследования будете разруливать ромбовидную иерархию интерфейсов?С данными проблема в том, что они должны быть согласованы (поле, записанное через левую сторону ромба, должно прочитаться через правую). Со ссылками на методы этой проблемы нет, они определяются на этапе компиляции и не меняются. Можно составить плоский список всех классов, к которым динамически кастится наш объект, и в объект положить соответствующее количество указателей на vtable, в которых сохранены указатели на методы объекта (дублирующиеся, чего нельзя было делать для виртуального наследования данных)
С данными проблема в том, что они должны быть согласованы (поле, записанное через левую сторону ромба, должно прочитаться через правую). Со ссылками на методы этой проблемы нет, они определяются на этапе компиляции и не меняются.
Если метод виртуальный, то ссылка на него определяется в процессе выполнения программы и лежит в vptr. То же самое происходит при доступе к полям при виртуальном наследовании.
Можно составить плоский список всех классов, к которым динамически кастится наш объект, и в объект положить соответствующее количество указателей на vtable, в которых сохранены указатели на методы объекта (дублирующиеся, чего нельзя было делать для виртуального наследования данных)
Можно, но тогда объект будет занимать слишком много места в памяти из-за большого количества vptr.
В случае .NET же этот указатель единственный, просто реализация таблицы становится гораздо более сложной:
https://stackoverflow.com/questions/9808982/clr-implementation-of-virtual-method-calls-to-interface-members
Если метод виртуальный, то ссылка на него определяется в процессе выполнения программы и лежит в vptrХм, по вашему,
struct A {
virtual void Draw() { }
};
struct B: public A { }
Это и есть виртуальное наследование в C++, потому что ссылка на метод определяется в процессе выполнения программы и лежит в vptr? А это тогда как называется:
struct B: public virtual A
То же самое происходит при доступе к полям при виртуальном наследовании.
Не то же самое. Для данных нужно 2 уровня косвенности — считываем адрес vtable, из неё смещение поля в классе, и только потом данные. Для методов только 1 уровень.
Можно, но тогда объект будет занимать слишком много места в памяти из-за большого количества vptr.Это всё оптимизируется, сворачивая линейные участки до 1 записи, оставляя только развилки (если B наследуется от A, достаточно подставить vptr от B, и он будет совместим по разметке с vptr A).
ссылка на метод определяется в процессе выполнения программы и лежит в vptr?
Ссылка на метод определяется в процессе компиляции, но подстановка ссылки на vptr — в процессе работы программы. Причём что при обычном наследовании, что при виртуальном.
Не то же самое. Для данных нужно 2 уровня косвенности — считываем адрес vtable, из неё смещение поля в классе, и только потом данные. Для методов только 1 уровень.
А вот и нет: https://stackoverflow.com/questions/30870096/c-virtual-inheritance-memory-layout
Для данных — один уровень косвенности: получение смещения дочернего объекта из vtable текущего объекта.
Для методов — два уровня: получение смещения дочернего объекта и поиск метода в таблице виртуальных методов дочернего объекта.
Ссылка на метод определяется в процессе компиляции, но подстановка ссылки на vptr — в процессе работы программы. Причём что при обычном наследовании, что при виртуальном.Тут вопрос в терминологии — class B: public A — это виртуальное наследование, по-вашему?
Для данных — один уровень косвенности: получение смещения дочернего объекта из vtable текущего объекта.Имея ссылку на объект, для чтения поля нужно сделать 2 лишних чтения:
1) vptr
2) смещение поля
3) чтение значения (уже не «лишнее», необходимо и без наследований).
Для методов — два уровня: получение смещения дочернего объекта и поиск метода в таблице виртуальных методов дочернего объекта.Для получения смещения не нужно делать чтение из памяти — оно известно при компиляции
Тут вопрос в терминологии — class B: public A — это виртуальное наследование, по-вашему?
Нет, конечно. А причём тут это?
Для получения смещения не нужно делать чтение из памяти — оно известно при компиляции
При виртуальном наследовании это смещение зависит от родительского объекта и хранится в vtable.
Нет, конечно. А причём тут это?При том, что
И, кстати, как же вы без виртуального наследования будете разруливать ромбовидную иерархию интерфейсов?
Я считаю, что в конструкции типа
class A: IDrawable, ISerializable
нет виртуального наследования, но возможна ромбовидная иерархия интерфейсов.
При виртуальном наследовании это смещение неизвестно во время компиляции и хранится в vtable.А пример кода можете привести?
Я считаю, что в конструкции типа
class A: IDrawable, ISerializable
нет виртуального наследования, но возможна ромбовидная иерархия интерфейсов.
Это исключительно вопрос терминологии.
А пример кода можете привести?
Да легко:
#include <iostream>
struct A
{
int f;
virtual ~A() {}
};
struct B : public virtual A
{
virtual ~B() {}
};
struct C : public virtual A
{
virtual ~C() {}
};
struct D : public virtual B, public virtual C
{
virtual ~D() {}
};
void PrintOffset(C &c)
{
std::cout << "Offset = " << (char *)&c.f - (char *)&c << "\n";
};
int main (int argc, char **argv)
{
C c {};
D d {};
PrintOffset(c);
PrintOffset(d);
return 0;
}
Результат:
Offset = 16
Offset = -8
А пример кода можете привести?Для полей данных — понятно, что так будет. Мы же вызов метода обсуждаем, обеспечивается ли он тем же самым виртуальным наследованием. Покажите пример кода, где будет работать это ваше
Да легко:
Для методов — два уровня: получение смещения дочернего объекта и поиск метода в таблице виртуальных методов дочернего объекта.
Мы же вызов метода обсуждаем, обеспечивается ли он тем же самым виртуальным наследованием.
Это зависит от реализации. Если реализация предполагает дублирование указателей в vtable родительского объекта, то будет один уровень индирекции.
И причин тому несколько:
1) При вызове метода появляется лишний уровень индирекции.
2) Сами объекты сильно раздуваются при обычном линейном наследовании, даже не множественном и не виртуальном. Если глубина линейной иерархии — 5, потребуется 5 vptr, когда в стандартной реализации только 1.
3) vtable одна на класс, и бессмысленно экономить на её длине в ущерб увеличения размера объекта или сложности кода.
2) Сами объекты сильно раздуваются при обычном линейном наследовании, даже не множественном и не виртуальном. Если глубина линейной иерархии — 5, потребуется 5 vptr, когда в стандартной реализации только 1.
При линейной иерархии нет никаких проблем всё держать в одной таблице. Но при множественном наследовании каждый дочерний объект, имеющий виртуальные методы, будет иметь отдельный vptr.
Решить проблему с увеличением размера объекта в этом случае можно (см. C#/Java), но ценой усложнения кода и падения производительности.
Так как C++ предназначен для написания, в первую очередь, высокопроизводительного кода, активно задействуется статический полиморфизм (вместо множественного наследования интерфейсов — концепты), то множественное наследование проще объявить антипаттерном, приводящим к падению производительности, увеличению потребления памяти и усложнению логики программы.
Его не надо убирать, это крайне полезная штука. Виртуальное наследование позволяет реализовывать механизм интерфейсов (принципы SOLID, всё такое)
в с++, виртуальное наследование по сравнению с обычным наследованием от классов с виртуальными функциями позволяет одну и только одну возможность — реализовывать ромбовидное наследование. Всё. SOLID тут совсем не причем. Не путайте наследование от классов с виртуальными методами и виртуальное наследование.
Взгляните на тот же C#, Java
в c#/java нет множественного наследования как такового, а значит, там нет и ромбовидного наследования. В любом случае, множественное наследование считается антипаттерном, отчего и ромбовидное встречается (ну, по крайней мере должно встречаться) крайне редко.
SOLID тут совсем не причем
Прнципы SOLID призывают вместо одного большого интерфейса создавать множество мелких. Виртуальное наследование позволяет не заботиться о том, что один и тот же интерфейс может встретиться несколько раз.
в c#/java нет множественного наследования как такового, а значит, там нет и ромбовидного наследования
Неверно. Если оперерировать терминами C++, то в C#/Java интерфейсы наследуются множественно и всегда виртуально.
В любом случае, множественное наследование считается антипаттерном, отчего и ромбовидное встречается (ну, по крайней мере должно встречаться) крайне редко.
Не согласен. Вот пример, где без ромбовидного наследования ну вообще никуда:
interface IEnumerable<T> {}
interface IReadOnlyCollection<T> : IEnumerable<T> {}
interface ICollection<T> : IReadOnlyCollection<T> {}
interface IReadOnlyList<T> : IReadOnlyCollection<T> {}
interface IList<T> : IReadOnlyList<T>, ICollection<T> {}
Прнципы SOLID призывают вместо одного большого интерфейса создавать множество мелких
если возводить принцип interface segregation в абсолют, создавая по одному интерфейсу на каждый публичный метод класса, а потом комбинируя их всеми возможными способами через наследование, то да, придется иметь дело и с ромбовидным. Посмею предположить, что этот принцип неудачно сформулирован.
Не согласен. Вот пример, где без ромбовидного наследования ну вообще никуда:
в с++ (о котором идет речь) конкретно для этого набора (реализации списка и других контейнеров) можно обойтись вообще без наследования. Касательно виртуального наследования — лично мне за всю карьеру оно понадобилось дважды, и оба использования я считаю скорее вынужденными хаками, нежели элегантной архитектурой. Более того, даже сам термин «виртуальное наследование» существует только применительно к с++.
vtable один на объект (для каждого интерфейса), корректно инициализированный наследниками, и множественное/виртуальное наследование тут ничего не сломает при хождении по иерархии.
Во время работы конструктора и деструктора абстрактного класса (а они абстрактными быть не могут)Почему вы считаете, что у абстрактного класса обязаны быть конструктор и деструктор?
Практически — это слишком опасноЕдинственный проблемный случай — это инстанциирование абстрактного класса в куче (а зачем это нужно?) и затем его удаление в контексте, когда компилятор не знает тип объекта (чтобы не применил virtual call elimination). В этом случае у класса не заполнен vtable, и вызов вирт. метода упадёт. Например,
struct IDrawable
{
virtual void draw() = 0;
virtual ~IDrawable() { }
};
__declspec(noinline) void del(IDrawable* e) { delete e; }
int main()
{
IDrawable* abstract = new IDrawable();
del(abstract);
}
error C2259: 'IDrawable': cannot instantiate abstract class
В остальных случаях, когда в абстрактном классе есть вирт. деструктор, а по интерфейсной ссылке передан объект-наследник (у которого vtable уже содержит адрес своего деструктора), никаких проблем с отсутствием конструктора и vtable у класса-интерфейса.
В остальных случаях, когда в абстрактном классе есть вирт. деструкторТо у нас в абстрактном классе появится неабстрактный метод. Об чём и речь.
никаких проблем с отсутствием конструктора и vtable у класса-интерфейса.А что будет в vtable во время работы вышеупомянутого деструктора, извините?
А что будет в vtable во время работы вышеупомянутого деструктора, извините?Вы очень хорошо умеете находить крайние тестовые случаи )))
Но всё равно, оптимизация, которая убирает необходимость в концепциях interface и __desclspec(novtable), возможна, и её можно сформулировать следующим образом:
— если класс содержит только абстрактные виртуальные методы (кроме, возможно, деструктора)
— и деструктор (возможно, виртуальный) либо отсутствует, либо пустой (либо = default, что характерно для интерфейсов)
— то можно в конструкторе такого класса не инициализировать vtable. Соответственно, такой конструктор, который раньше состоял только из инициализации vtable, может вообще исчезнуть, и в конструкторах классов-наследников его вызов тоже можно удалить.
Вроде, всё предусмотрел?
Вы очень хорошо умеете находить крайние тестовые случаи )))Я просто несколько лет назад «смотрел на молоток со стороны гвоздя» — в смысле работал с компилятором и чинил там разные странности.
А так — да, в некоторых случаях действительно можно избавиться от конструктора/деструктора и vtable. Примерно в описанных вами случаях — посмотрите на пример, который мы уже обсуждали.
В функции bar никакого двойного переписывания vptr нету. А почему тогда сгенерированы все эти бесконечные конструкторы и деструкторы? А потому что ABI так говорит: вдруг другой компилятор решит их вызвать?
Так что описанное в статье — это, грубо говоря, обещание компилятору: «друг, верь, я не идиот, вот эту вот всю муть, которая никому не нужна — можешь выкидывать смело».
В Linux принят другой подход: если сделать вот так — то все эти бесконечные таблицы по-прежнему будут сгенерированы, но линкер сможет от них избавится…
А почему тогда сгенерированы все эти бесконечные конструкторы и деструкторы? А потому что ABI так говорит: вдруг другой компилятор решит их вызвать?эти методы может выкинуть линкер при сборке exe. Для dll останутся, но это какая-то ерундовая экономия, стоит ли она поддержки в компиляторе.
Только этот случай покрывается уже существующими оптимизациями (инлайнинг конструктора базового класса + удаление лишнего присвоения + удаление «мертвого» кода конструктора базового класса).
godbolt.org/z/HxCGjt
Дык конструктор-то быть как раз обязанДля чего классу-интерфейсу конструктор?
И виртуальная функция, как минимум одна, таки есть: деструкторНе нужна. Вот пример:
struct IDrawable
{
virtual void draw() = 0;
};
class Line: public IDrawable
{
void draw() override { };
};
class Rect: public IDrawable
{
void draw() override { };
};
int main()
{
Line l;
Rect r;
IDrawable* objs[2] = { &l, &r };
for (auto& e : objs) e->draw();
return 0;
}
Заметьте, что вы теперь не можете передать куда-то IDrawable и потом от этого объекта избавиться: удалять его должен тот же, кто его создалМожно и через интерфейс удалять, без вирт. деструктора — IUnknown::Release ))
В остальном вы не правы. Детально ответил выше.
novtable оптимизация