Pull to refresh

Comments 49

Красота! Ещё чуть-чуть и они догонят Turbo Pascal 5.5, выпущенный 30 лет назад. Который просто инициализировал vtbl при создании класса один раз (независимо от схемы наследования). Потом эту «фичу» переняла Java…
Но ведь в Turbo Pascal 5.5 инициализация vptr — тоже задача конструктора; значит, конструктор предка, вызванный конструктором потомка, все равно будет ее выполнять, разве нет? Все равно получаются «напрасные» присваивания.
Нет, не будет. Конструктор в Turbo Pascal вызывается после того, как память аллоцирована и vptr прописан. И в C++, обычно, тоже порождается не один конструктор и не один деструктор. Вот тут — на два класса три конструктора и пять (sic!) деструкторов.

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

Я на эту тему высказывался много раз: всё это нужно только и исключительно для того, чтобы избежать виртуального вызова функции из конструктора. Что является дикостью несусветной. Похоже на то, как если бы в суровый вездеход для суровых условий типа Харьковчанки поставили в угол креслице для новорожденного.
Спасибо, теперь все понятно.
Вот тут — на два класса три конструктора и пять (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 родительского объекта, то будет один уровень индирекции.

Навряд ли есть реализации, не дублирующие в 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 в абсолют, создавая по одному интерфейсу на каждый публичный метод класса, а потом комбинируя их всеми возможными способами через наследование, то да, придется иметь дело и с ромбовидным. Посмею предположить, что этот принцип неудачно сформулирован.

Не согласен. Вот пример, где без ромбовидного наследования ну вообще никуда:

в с++ (о котором идет речь) конкретно для этого набора (реализации списка и других контейнеров) можно обойтись вообще без наследования. Касательно виртуального наследования — лично мне за всю карьеру оно понадобилось дважды, и оба использования я считаю скорее вынужденными хаками, нежели элегантной архитектурой. Более того, даже сам термин «виртуальное наследование» существует только применительно к с++.
И это является хорошим примером, показывающим, почему множественное наследование классов (ещё и виртуальное) — зло.

Паскаль не догонишь, там есть checked arithmetics и helper классы.
В C++ не хватает такой сущности как interface, — структурой в которой такая оптимизация была бы включена по умолчанию и компилятор, который выдавал ошибку если в интерфейс добавить что либо кроме виртуальных функций.
А кто мешает компилятору понять, что у класса нет реализаций вирт. ф-ций (т.е., это абстрактный класс) и не инициализировать vtable и заодно конструктор не создавать, чтобы наследники этот конструктор не вызывали.
Наверное то, что если вы захотите привести указатель на интерфейс к его реализации (dynamic_cast'ом), то программа проведёт себя очень странно.
И почему же? Корректный указатель на интерфейс всегда фактически указывает на наследника (реализацию), который имеет корректный vtable, инициализированный в конструкторе наследника.
Да, соглашусь, поторопился.
vtable один на объект (для каждого интерфейса), корректно инициализированный наследниками, и множественное/виртуальное наследование тут ничего не сломает при хождении по иерархии.
Не всегда. Во время работы конструктора и деструктора абстрактного класса (а они абстрактными быть не могут) — vtable должна указывать на сам этот класс.

Во время работы конструктора и деструктора абстрактного класса (а они абстрактными быть не могут)
Почему вы считаете, что у абстрактного класса обязаны быть конструктор и деструктор?
Потому что так устроен C++, однако. Теоретически да, можно сделать невиртуальный деструктор и гарантировать как-нибудь, что через ваш интерфейс объект никогда-никогда не удалят… Практически — это слишком опасно.
Практически — это слишком опасно
Единственный проблемный случай — это инстанциирование абстрактного класса в куче (а зачем это нужно?) и затем его удаление в контексте, когда компилятор не знает тип объекта (чтобы не применил 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 останутся, но это какая-то ерундовая экономия, стоит ли она поддержки в компиляторе.
Ну это у разработчиков Visual Studio нужно спросить. Это ж новейшая технология нужна! В Turbo Pascal она появилась в версии 4.0, 1987й год (и была включена по умолчанию!), в gcc — тоже в прошлом веке (но до сих пор не включается автоматом, ручками нужно заказывать), а MSVC, похоже, ниасилил…
Сейчас, вроде, все.
Только этот случай покрывается уже существующими оптимизациями (инлайнинг конструктора базового класса + удаление лишнего присвоения + удаление «мертвого» кода конструктора базового класса).
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;
}
Ну если вам так хочется выстрелить себе в ногу, то в C++ есть много других способов. Заметьте, что вы теперь не можете передать куда-то IDrawable и потом от этого объекта избавиться: удалять его должен тот же, кто его создал. В большинстве случаев так использовать интерфейсы черезвычайно неудобно. А если вы создадите виртуальный деструктор (чтобы ваши Line и Rect освобождались как Line и Rect при доступе через IDrawable) — то получите весь букет обсуждающихся проблем… даже если этот виртуальный деструктор будет пустой…
Заметьте, что вы теперь не можете передать куда-то IDrawable и потом от этого объекта избавиться: удалять его должен тот же, кто его создал
Можно и через интерфейс удалять, без вирт. деструктора — IUnknown::Release ))

В остальном вы не правы. Детально ответил выше.
Можно и через интерфейс удалять, без вирт. деструктора — IUnknown::Release ))
Который вам придётся отдельно реализовывать в каждом потомке. Да, так тоже можно. Но C++ достаточно сложен и без того, чтобы самому себе создавать подобные проблемы.
Есть такая штука — C++ Builder, которая умеет делать подобное. Отдельного ключевого слова для интерфейсов в нём нет, но если класс удовлетворяет свойствам интерфейса, то он реализуется особым образом.
Sign up to leave a comment.

Articles