Почему сборка различается, когда метод производного класса вызывает реализацию чисто виртуального метода базового класса по сравнению с его непосредственной реализацией в производном классе?

Вопрос или проблема

Код 1:

#include <iostream>

struct Интерфейс
{
    virtual void pr_fn() = 0;
    virtual void pr_fn2() = 0;
    virtual void pr_fn3() = 0;
};

struct Основание : Интерфейс
{
    void pr_fn2() final
    {
        std::cout << "Основание\n";
    }
};

struct Производный : Основание
{
    void pr_fn() final
    {
        std::cout << "Производный2\n";
    }

    void pr_fn3() final
    {
        pr_fn2(); pr_fn();
    }
};

int main()
{
    Производный d;
    d.pr_fn3();
    return 0;
}

Код 2:

#include <iostream>

struct Интерфейс
{
    virtual void pr_fn() = 0;
    virtual void pr_fn2() = 0;
    virtual void pr_fn3() = 0;
};

void Интерфейс::pr_fn3()
{
    pr_fn2();
    pr_fn();
}

struct Основание : Интерфейс
{
    void pr_fn2() final
    {
        std::cout << "Основание\n";
    }
};

struct Производный : Основание
{
    void pr_fn() final
    {
        std::cout << "Производный\n";
    }

    void pr_fn3() final
    {
        Интерфейс::pr_fn3();
    }
};

int main()
{
    Производный d;
    d.pr_fn3();
    return 0;
}

Сборка кода 1: (Компилятор: x86-64 gcc 14.2, флаги: -O3)

.LC0:
        .string "Основание\n"
.LC1:
        .string "Производный2\n"
main:
        sub     rsp, 8
        mov     edx, 5
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:std::cout
        call    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)
        mov     edx, 9
        mov     esi, OFFSET FLAT:.LC1
        mov     edi, OFFSET FLAT:std::cout
        call    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)
        xor     eax, eax
        add     rsp, 8
        ret

Сборка кода 2: (Компилятор: x86-64 gcc 14.2, флаги: -O3)

.LC0:
        .string "Основание\n"
Base::pr_fn2():
        mov     edx, 5
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:std::cout
        jmp     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)
.LC1:
        .string "Производный\n"
Derived::pr_fn():
        mov     edx, 8
        mov     esi, OFFSET FLAT:.LC1
        mov     edi, OFFSET FLAT:std::cout
        jmp     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)
Derived::pr_fn3():
        sub     rsp, 8
        mov     edx, 5
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:std::cout
        call    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)
        mov     edx, 8
        mov     esi, OFFSET FLAT:.LC1
        mov     edi, OFFSET FLAT:std::cout
        add     rsp, 8
        jmp     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)
Interface::pr_fn3():
        push    rbx
        mov     rax, QWORD PTR [rdi]
        mov     rbx, rdi
        mov     rax, QWORD PTR [rax+8]
        cmp     rax, OFFSET FLAT:Base::pr_fn2()
        jne     .L7
        mov     edx, 5
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:std::cout
        call    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)
        mov     rax, QWORD PTR [rbx]
        mov     rax, QWORD PTR [rax]
        cmp     rax, OFFSET FLAT:Derived::pr_fn()
        jne     .L9
.L11:
        mov     edx, 8
        mov     esi, OFFSET FLAT:.LC1
        mov     edi, OFFSET FLAT:std::cout
        pop     rbx
        jmp     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)
.L7:
        call    rax
        mov     rax, QWORD PTR [rbx]
        mov     rax, QWORD PTR [rax]
        cmp     rax, OFFSET FLAT:Derived::pr_fn()
        je      .L11
.L9:
        mov     rdi, rbx
        pop     rbx
        jmp     rax
main:
        sub     rsp, 8
        mov     edx, 5
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:std::cout
        call    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)
        mov     edx, 8
        mov     esi, OFFSET FLAT:.LC1
        mov     edi, OFFSET FLAT:std::cout
        call    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)
        xor     eax, eax
        add     rsp, 8
        ret

Смотрите код здесь

Наблюдения:

  1. Для Base::pr_fn2(), Derived::pr_fn() и Derived::pr_fn3() в Коде 1 не сгенерирована сборка.
  2. Все вышеупомянутые функции встроены в сборку main() в Коде 1.
  3. Сборка сгенерирована для Interface::pr_fn3(), Base::pr_fn2(), Derived::pr_fn() и Derived::pr_fn3() в Коде 2.
  4. Все вышеупомянутые функции встроены в сборку main() в Коде 2.
  5. Вызовы к Interface::pr_fn3(), Base::pr_fn2() и Derived::pr_fn() встроены в сборку Derived::pr_fn3() в Коде 2.

Мой вопрос:
Когда программа выполняется и вызывается функция main в обоих случаях, выполняются одни и те же инструкции сборки. Почему компилятор генерирует сборку для других функций в Коде 2, но не в Коде 1?

Примечание: Пожалуйста, поправьте меня, если я где-то ошибаюсь в понимании.

Я провел эксперименты с некоторыми виртуальными методами и наблюдал сборку. Я ожидал не увидеть разницы в сборке с оптимизацией -O3, но обнаружил, что генерируется несколько ненужных устройств. Я искал в Google, но безуспешно. Поэтому я задаю вопрос здесь.

Ответ или решение

Проблема, о которой идет речь, касается обработки чисто виртуальных методов и их вызовов в контексте классов на C++. Для анализа, давайте более подробно рассмотрим два представленных фрагмента кода и их соответствующий ассемблерный вывод.

Понимание кода

В первом коде (Code 1) классы реализуют взаимодействие с чисто виртуальным классом Interface, где методы pr_fn, pr_fn2 и pr_fn3 объявлены как виртуальные. Однако в этом случае вызов метода pr_fn3 происходит непосредственно в производном классе Derived, и он вызывает pr_fn2 и pr_fn() без промежуточного уровня.

Во втором коде (Code 2) метод pr_fn3 реализован в самом базовом классе Interface, что предъявляет иную структуру вызовов. Метод pr_fn3 из Interface сначала вызывает pr_fn2 и pr_fn(), что приводит к более продолжительной цепочке вызовов.

Различия в ассемблерном выводе

  1. Отсутствие ассемблера для функций в Code 1:

    • В первом коде компилятор на этапе оптимизации -O3 принял решение встраивать функции pr_fn2, pr_fn, и pr_fn3 непосредственно в код функции main(). Это может происходить из-за того, что компилятор определяет, что вызовы этих методов могут быть полностью разрешены на этапе компиляции. Директива final говорит о том, что метод не может быть переопределен в производных классах, что дает компилятору больше уверенности в возможности инлайн-оптимизации.
  2. Генерация ассемблера для функций в Code 2:

    • Во втором коде компилятор должен обработать виртуальную таблицу для методов, и хотя он также оптимизирует вызовы, он не может отказаться от явного определения функций, которые уже объявлены в Interface. Поскольку pr_fn3 реализован в интерфейсе и вызывает другие виртуальные методы, он не может быть инлайновым, поскольку это взаимодействие требует разрешения на уровне выполнения.

Влияние стиля кодирования на производительность

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

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

Оцените материал
Добавить комментарий

Капча загружается...