Почему во время компиляции нельзя разрешить полиморфизм во время выполнения?

Рассмотрим:

#include<iostream>
using namespace std;

class Base
{
    public:
        virtual void show() { cout<<" In Base \n"; }
};

class Derived: public Base
{
    public:
       void show() { cout<<"In Derived \n"; }
};

int main(void)
{
    Base *bp = new Derived;
    bp->show();  // RUN-TIME POLYMORPHISM
    return 0;
}

Почему этот код вызывает полиморфизм во время выполнения и почему его нельзя решить во время компиляции?

Ответ 1

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

Base *bp;
if (rand() % 10 < 5)
    bp = new Derived;
else
    bp = new Base;
bp->show(); // only known at run time

EDIT: Благодаря @nwp, здесь гораздо лучший случай. Что-то вроде:

Base *bp;
char c;
std::cin >> c;
if (c == 'd')
    bp = new Derived;
else
    bp = new Base;
bp->show(); // only known at run time 

Кроме того, по следствию доказательство Тьюринга, можно показать, что в общем случае математически невозможно, чтобы компилятор С++ знал, что такое указатель базового класса указывает на время выполнения.

Предположим, что у нас есть функция компилятора С++:

bool bp_points_to_base(const string& program_file);

В качестве своего входного значения program_file используется имя любого текстового файла исходного кода С++, где указатель bp (как в OP) вызывает функцию-член virtual show(). И может определить в общем случае (в точке последовательности A, где функция virtual member show() сначала вызывается через bp): указывает ли указатель bp на экземпляр Base или нет.

Рассмотрим следующий фрагмент программы на С++ "q.cpp" :

Base *bp;
if (bp_points_to_base("q.cpp")) // invokes bp_points_to_base on itself
    bp = new Derived;
else
    bp = new Base;
bp->show();  // sequence point A

Теперь, если bp_points_to_base определяет, что в "q.cpp" : bp указывает на экземпляр Base at A, тогда "q.cpp" указывает bp на что-то еще в A. И если он определяет, что в "q.cpp" : bp не указывает на экземпляр Base в A, тогда "q.cpp" указывает bp на экземпляр Base at A. Это противоречие. Поэтому наше первоначальное предположение неверно. Поэтому bp_points_to_base не может быть записано для общего случая.

Ответ 2

Компиляторы обычно девиртуализуют такие вызовы, когда известен статический тип объекта. Вставка вашего кода как-в Проводник компилятора создает следующую сборку:

main:                                   # @main
        pushq   %rax
        movl    std::cout, %edi
        movl    $.L.str, %esi
        movl    $12, %edx
        callq   std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)
        xorl    %eax, %eax
        popq    %rdx
        retq

        pushq   %rax
        movl    std::__ioinit, %edi
        callq   std::ios_base::Init::Init()
        movl    std::ios_base::Init::~Init(), %edi
        movl    std::__ioinit, %esi
        movl    $__dso_handle, %edx
        popq    %rax
        jmp     __cxa_atexit            # TAILCALL

.L.str:
        .asciz  "In Derived \n"

Даже если вы не можете прочитать сборку, вы можете видеть, что в исполняемом файле присутствует только "In Derived \n". Оптимизирована не только динамическая отправка, но и весь базовый класс.

Ответ 3

Почему этот код вызывает полиморфизм времени выполнения и почему его нельзя решить во время компиляции?

Почему вы думаете, что это так?

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


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

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

Некоторые возможности девиртуализации:

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

Для современного уровня, вы, скорее всего, захотите прочитать блог Honza Hubička. Хонза - разработчик gcc, а в прошлом году он работал над спекулятивной девиртуализацией: цель состоит в том, чтобы вычислить вероятности динамического типа как A, B, так и C, а затем спекулятивно девиртуализировать вызовы, несколько как преобразование:

Base& b = ...;
b.call();

в

Base& b = ...;
if      (b.vptr == &VTableOfA) { static_cast<A&>(b).call(); }
else if (b.vptr == &VTableOfB) { static_cast<B&>(b).call(); }
else if (b.vptr == &VTableOfC) { static_cast<C&>(b).call(); }
else                           { b.call(); } // virtual call as last resort

Хонза сделал сообщение из 5 частей:

Ответ 4

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

Во-первых, модель компиляции С++ основана на отдельных единицах компиляции. Когда один блок скомпилирован, компилятор знает только то, что определено в исходном файле (файлах), скомпилированном. Рассмотрим блок компиляции с базовым классом, а функция взяла ссылку на базовый класс:

struct Base {
    virtual void polymorphic() = 0;
};
void foo(Base& b) {b.polymorphic();}

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

Более фундаментальная причина исходит из теории вычислимости. Даже с полной информацией невозможно определить алгоритм, который вычисляет, будет ли достигнута определенная строка в программе или нет. Если бы вы могли решить проблему с остановкой: для программы P я создаю новую программу P', добавив дополнительную строку в конец P. Теперь алгоритм сможет решить, будет ли достигнута эта линия, которая решает проблему остановки.

Невозможность решить в общем случае означает, что компиляторы не могут решить, какое значение присваивается переменным вообще, например.

bool someFunction( /* arbitrary parameters */ ) {
     // ...
}

// ...
Base* b = nullptr;
if (someFunction( ... ))
    b = new Derived1();
else
    b = new Derived2();

b->polymorphicFunction();

Даже когда все параметры известны во время компиляции, невозможно вообще доказать, какой путь будет проходить через программу, и какой статический тип b будет иметь. Аппроксимации можно и сделать путем оптимизации компиляторов, но всегда есть случаи, когда они не работают.

Сказав это, компиляторы С++ очень стараются удалить динамическую диспетчеризацию, поскольку она открывает многие другие возможности оптимизации, главным образом, из возможности встроить и распространять знания через код. Если вы интересны, вы можете найти интересную серьезную запись в блоге для реализации GIG для виртуализации.

Ответ 5

Это может быть легко разрешено во время компиляции, если оптимизатор решил это сделать.

В стандарте указано то же поведение, что и в случае полиморфизма во время выполнения. Это не является конкретным, что достигается посредством фактического полиморфизма во время выполнения.

Ответ 6

В основном компилятор должен уметь выяснить, что это не должно приводить к полиморфизму во время выполнения в вашем очень простом случае. Скорее всего, есть компиляторы, которые на самом деле делают это, но это в основном гипотеза.

Проблематичным является Общий случай, когда вы на самом деле строите сложный процесс, а также случаи, связанные с библиотечными зависимостями, или сложность анализа множественных единиц компиляции после компиляции, что потребует сохранения нескольких версий одного и того же кода, что выпустить поколение AST, реальная проблема сводится к разрешимости и проблеме остановки.

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

Проблема остановки заключается в том, чтобы решить, остановится ли программа с заданным вводом (мы говорим, что пара входных программ). Известно, что нет общего алгоритма, например. компилятор, который решает все возможные пары входных программ.

Чтобы компилятор мог решить любую программу, если вызов должен быть сделан виртуальным или нет, он должен иметь возможность решить, что для всех возможных пар ввода программ.

Для этого компилятор должен иметь алгоритм A, который решает, что данная программа P1 и программа P2, где P2 делает виртуальный вызов, затем программируют P3 {while ({P1, I}!= {P2, я })} останавливается для любого ввода I.

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

Вот почему в вашем случае вызов может быть девиртуализирован, но не любой случай.