Подтвердить что ты не робот

Существуют ли какие-либо затраты на использование виртуальной функции, если объекты передаются в их фактический тип?

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

Что делать, если я опускаю указатель переменной на его точный тип? Есть ли еще дополнительные расходы?

class Base { virtual void foo() = 0; };
class Derived : public Base { void foo() { /* code */} };

int main() {
    Base * pbase = new Derived();
    pbase->foo(); // Can't inline this and have to go through vtable
    Derived * pderived = dynamic_cast<Derived *>(pbase);
    pderived->foo(); // Are there any costs due to the virtual method here?
}

Моя интуиция подсказывает мне, что, поскольку я передал объект его фактическому типу, компилятор должен уметь избегать недостатков использования виртуальной функции (например, он должен иметь возможность встроить вызов метода, если он захочет). Правильно ли это?

Может ли компилятор действительно знать, что pderived имеет тип Derived после того, как я его понизил? В приведенном выше примере тривиально видеть, что pbase имеет тип Derived, но в реальном коде он может быть неизвестен во время компиляции.

Теперь, когда я написал это, я полагаю, что, поскольку класс Derived сам по себе может быть унаследован другим классом, downbing pbase на Derived-указатель фактически не обеспечивает что-либо компилятору, и поэтому он не может избежать затраты на виртуальную функцию?

4b9b3361

Ответ 1

Всегда есть пробел между тем, что может сделать мифический Достаточно умный компилятор, и что делают реальные компиляторы. В вашем примере, поскольку нет ничего наследуемого от Derived, последние компиляторы, скорее всего, девиртуализуют вызов foo. Однако, поскольку успешная девиртуализация и последующая встраивание являются сложной проблемой в целом, помогите компилятору, когда это возможно, с помощью final.

class Derived : public Base { void foo() final { /* code */} }

Теперь компилятор знает, что существует только один возможный foo, который может вызвать Derived*.

(Для углубленного обсуждения того, почему девиртуализация сложна и как gcc4.9 + справляется с ней, прочитайте статьи Jan Hubicka Devirtualization in С++).

Ответ 2

Совет Pradhan для использования final является звуковым, если изменение класса Derived является для вас вариантом, и вы не хотите дальнейшего вывода.

Другой вариант, непосредственно доступный для определенных сайтов вызовов, префикс имени функции с помощью Derived::, запрещающий виртуальную отправку на любое последующее переопределение:

#include <iostream>

struct Base { virtual ~Base() { } virtual void foo() = 0; };

struct Derived : public Base
{
    void foo() override { std::cout << "Derived\n"; }
};

struct FurtherDerived : public Derived
{
    void foo() override { std::cout << "FurtherDerived\n"; }
};

int main()
{
    Base* pbase = new FurtherDerived();
    pbase->foo(); // Can't inline this and have to go through vtable
    if (Derived* pderived = dynamic_cast<Derived *>(pbase))
    {
        pderived->foo();  // still dispatched to FurtherDerived
        pderived->Derived::foo();  // static dispatch to Derived
    }
}

Вывод:

FurtherDerived
FurtherDerived
Derived

Это может быть опасно: фактический тип времени выполнения может зависеть от его переопределений, вызываемых для поддержания его инвариантов, поэтому его плохая идея использовать, если только не возникают проблемы с производительностью.

Доступный код здесь.

Ответ 3

Девиртуализация - это, по сути, очень частный случай постоянного распространения, где распространенная константа - это тип (физически представленный как v-ptr вообще, но стандарт не дает такой гарантии).


Общая девиртуализация

Существует множество ситуаций, когда компилятор может фактически девиртуализировать вызов, о котором вы можете не думать:

int main() {
    Base* base = new Derived();
    base->foo();
}

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

В аналогичном ключе:

struct Base { virtual void foo() = 0; };
struct Derived: Base { virtual void foo() override {} };

Base* create() { return new Derived(); }

int main() {
    Base* base = create();
    base->foo();
}

в то время как этот пример немного сложнее, и интерфейсный модуль Clang не будет понимать, что base обязательно имеет тип Derived, оптимизатор LLVM, который приходит после этого, будет:

  • inline create в main
  • сохраните указатель на v-таблицу Derived в base->vptr
  • понимают, что base->foo() поэтому base->Derived::foo() (путем разрешения косвенности через v-ptr)
  • и, наконец, оптимизировать все, потому что нечего делать в Derived::foo

И вот окончательный результат (который, как я полагаю, не требует комментариев даже для тех, которые не были инициированы для LLVM IR):

define i32 @main() #0 {
  ret i32 0
}

Существует несколько экземпляров, в которых компилятор (как внешний, так и внешний) может девиртуализировать вызовы в ситуациях, которые могут быть не очевидны, во всех случаях он сводится к его способности доказывать тип времени выполнения объекта указал на.


Частичная девиртуализация

В своей серии о улучшениях в gcc-компиляторе по теме devirutalization Ян Хубичка вводит частичную девиртуализацию.

Последние воплощения gcc имеют возможность кратковременного перечисления нескольких вероятных типов времени выполнения объекта и, в частности, производят следующий псевдокод (в этом случае два считаются вероятными, и не все известны или вероятно, достаточно, чтобы оправдать специальный случай):

// Source
void doit(Base* base) { base->foo(); }

// Optimized
void doit(Base* base) {
    if (base->vptr == &Derived::VTable) { base->Derived::foo(); }
    else if (base->ptr == &Other::VTable) { base->Other::foo(); }
    else {
        (*base->vptr[Base::VTable::FooIndex])(base);
    }
}

Хотя это может показаться немного запутанным, оно действительно дает некоторую прибыль (как вы увидите из серии статей), если прогнозы правильные.

Кажется удивительным? Ну, есть больше тестов, но теперь base->Derived::foo() и base->Other::foo() могут быть встроены, что само по себе открывает дополнительные возможности оптимизации:

  • в этом конкретном случае, так как Derived::foo() ничего не делает, вызов функции можно оптимизировать; штраф за тест if меньше, чем у вызова функции, поэтому стоит того, чтобы условие соответствовало достаточно часто.
  • в случаях, когда один из аргументов функции известен или, как известно, имеет некоторые конкретные свойства, последующие постоянные пропуски распространения могут упростить встроенный кусок функции

Впечатляет, правильно?


Хорошо, хорошо, это довольно затянуто, но я собираюсь поговорить о dynamic_cast<Derived*>(base)!

Прежде всего, стоимость a dynamic_cast не следует недооценивать; на самом деле это может быть более дорогостоящим, чем вызов base->foo(), в первую очередь, вы были предупреждены.

Во-вторых, использование dynamic_cast<Derived*>(base)->foo() может позволить девиртуализировать вызов функции, если он дает достаточную информацию для этого компилятора (он всегда дает больше информации, по крайней мере). Как правило, это может быть:

  • потому что Derived::foo есть final
  • потому что Derived есть final
  • потому что Derived определяется в анонимном пространстве имен и не имеет переопределения потомков foo и поэтому доступен только в этой части перевода (примерно, .cpp файл) и поэтому все его потомки известны и могут быть проверены
  • и множество других случаев (например, обрезка множества потенциальных кандидатов в случае частичной девиртуализации).

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