Изменит ли ABI производный класс C++ "final"? - программирование
Подтвердить что ты не робот

Изменит ли ABI производный класс C++ "final"?

Мне любопытно, если пометка существующего производного класса C++ в качестве final позволяющего оптимизировать де-виртуализацию, изменит ABI при использовании C++ 11. Я ожидаю, что это не должно иметь никакого эффекта, так как я рассматриваю это как прежде всего подсказку компилятору о том, как он может оптимизировать виртуальные функции, и поэтому я не вижу никакого способа изменить размер структуры или виртуальной таблицы, но возможно я что-то упустил

Мне известно, что API изменений здесь, так что код, который в дальнейшем наследуется от этого производного класса, больше не будет работать, но меня беспокоит только ABI в данном конкретном случае.

4b9b3361

Ответ 1

Final в объявлении функции X::f() подразумевает, что объявление не может быть переопределено, поэтому все вызовы, которые называют это объявление, могут быть связаны рано (не те вызовы, которые называют объявление в базовом классе): если виртуальная функция является конечной в ABI созданные vtables могут быть несовместимы с производимыми практически того же класса без final: вызов виртуальных функций, объявления имен которых помечены как final, можно считать прямым: попытка использовать запись vtable (которая должна существовать в final- меньше ABI) незаконно.

Компилятор может использовать окончательную гарантию, чтобы сократить размер таблиц vtables (которые могут иногда сильно увеличиваться), не добавляя новую запись, которая обычно добавляется и которая должна соответствовать ABI для не окончательного объявления.

Записи добавляются для объявления, переопределяющего функцию, а не (по сути, всегда) первичную базу или для нетривиально ковариантного возвращаемого типа (ковариант возвращаемого типа на неосновной основе).

По своей сути первичный базовый класс: простейший случай полиморфного наследования

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

Эти свойства имеют значение true, независимо от того, является ли производный класс полным объектом (объектом, не являющимся подобъектом), наиболее производным объектом или базовым классом. (Это классовые инварианты, гарантированные на уровне ABI для указателей неизвестного происхождения.)

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

Тривиальная ковариация

Пример: случай, когда он ковариантен с тем же типом, что и *this; как в:

struct B { virtual B *f(); };
struct D : B { virtual D *f(); }; // trivial covariance

Здесь B изначально является неизменным основным в D: во всех когда-либо созданных D (под) объектах B находится по одному адресу: преобразование D* в B* тривиально, поэтому ковариация также тривиальна: это проблема статической типизации,

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

Заключение

В этих случаях тип объявления переопределяющей функции тривиально отличается от типа базы:

  • все параметры почти одинаковы (только с разницей в типе this)
  • тип возвращаемого значения почти одинаков (с возможной разницей в типе возвращаемого типа указателя (*))

(*), поскольку возврат ссылки точно такой же, как возврат указателя на уровне ABI, ссылки конкретно не обсуждаются

Таким образом, запись vtable для производного объявления не добавляется.

(Таким образом, сделать финал класса не было бы приемлемым упрощением.)

Никогда первичная база

Очевидно, что класс может иметь только один подобъект, содержащий конкретный элемент скалярных данных (например, vptr (*)), со смещением 0. Другие базовые классы с членами-скалярными данными будут иметь нетривиальное смещение, что требует нетривиальных производных для базовых преобразований. указателей. Таким образом, множественное интересное (**) наследование создаст неосновные базы

(*) Vptr не является обычным элементом данных на уровне пользователя; но в сгенерированном коде это в значительной степени обычный скалярный член данных, известный компилятору. (**) Компоновка неполиморфных баз здесь не интересна: для целей vtable ABI неполиморфная база обрабатывается как подобъект-член, так как она никак не влияет на vtables.

Концептуально простейший интересный пример неосновного и нетривиального преобразования указателей:

struct B1 { virtual void f(); };
struct B2 { virtual void f(); };
struct D : B1, B2 { };

Каждая база имеет свой собственный скалярный член vptr, и эти vptr имеют разные цели:

  • B1::vptr указывает на структуру B1_vtable
  • B2::vptr указывает на структуру B2_vtable

и они имеют идентичный макет (поскольку определения классов являются суперпозиционными, ABI должен генерировать суперпозиционные макеты); и они строго несовместимы, потому что

  1. Vtables имеют различные записи:

    • B1_vtable.f_ptr указывает на окончательное переопределение для B1::f()
    • B2_vtable.f_ptr указывает на окончательный переопределение для B2::f()
  2. B1_vtable.f_ptr должен быть в том же смещении, что и B2_vtable.f_ptr (из их соответствующих членов данных B2_vtable.f_ptr в B1 и B2)

  3. Конечные переопределения B1::f() и B2::f() по своей природе (всегда, неизменно) не эквивалентны (*): они могут иметь различные конечные переопределения, которые делают разные вещи. (***)

(*) Две вызываемые функции времени выполнения (**) эквивалентны, если они имеют одинаковое наблюдаемое поведение на уровне ABI. (Эквивалентные вызываемые функции могут не иметь одно и то же объявление или типы C++.)

(**) Вызываемая функция времени выполнения - это любая точка входа: любой адрес, по которому можно вызвать/перейти; это может быть обычный код функции, блок/батут, конкретная запись в функции множественного ввода. Вызываемые во время выполнения функции часто не имеют возможных объявлений C++, например, "конечный переопределитель вызывается с указателем базового класса".

(***) Иногда они имеют одинаковое окончательное переопределение в следующем производном классе:

struct DD : D { void f(); }

не полезно с целью определения ABI D

Итак, мы видим, что D несомненно, нуждается в не первичной полиморфной основе; условно это будет D2; первая назначенная полиморфная основа (B1) становится первичной.

Таким образом, B2 должен иметь нетривиальное смещение, а преобразование D в B2 нетривиально: для этого требуется сгенерированный код.

Таким образом, параметры функции-члена D не могут быть эквивалентны параметрам функции-члена B2, так как неявное this не является тривиально конвертируемым; так:

  • D должно быть два разных vtables: vtable, соответствующий B1_vtable и один с B2_vtable (на практике они объединены в один большой vtable для D но концептуально они представляют собой две различные структуры).
  • запись виртуальной D_B2_vtable виртуального члена B2::g которая переопределена в D требует двух записей: одна в D_B2_vtable (которая является просто компоновкой B2_vtable с другими значениями) и одна в D_B1_vtable которая является расширенной B1_vtable: плюс B1_vtable записи для новых функций времени выполнения D

Поскольку D_B1_vtable из B1_vtable, указатель на D_B1_vtable является тривиальным указателем на B1_vtable, и значение B1_vtable одинаково.

Обратите внимание, что в теории можно было бы опустить запись для D::g() в D_B1_vtable если бремя выполнения всех виртуальных вызовов D::g() через базу B2, что, если нет нетривиальной ковариации используется (#), также возможно.

(#) или если происходит нетривиальная ковариация, "виртуальная ковариация" (ковариация в производном отношении к основному, включающему виртуальное наследование) не используется

Не по своей сути первичная база

Обычное (не виртуальное) наследование просто как членство:

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

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

Это смещение никогда не может быть известно, потому что C++ поддерживает как унифицирующее, так и дублирующее наследование:

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

    struct B {виртуальная пустота f(); }; struct D1: B {виртуальная пустота f(); }; //окончательный переопределитель struct D2: B {virtual void f(); }; // финальная структура переопределения DD: D1, D2 {};

Здесь DD имеет два различных окончательных переопределения B::f():

  • DD::D1::f() является окончательным переопределением для DD::D1::B::f()
  • DD::D2::f() является окончательным переопределением для DD::D2::B::f()

в двух разных записях.

Дублирование наследования, когда вы косвенно производите несколько раз из данного класса, подразумевает множество vptr, vtables и, возможно, отдельный конечный код vtable (конечная цель использования записи vtable: высокоуровневая семантика вызова виртуальной функции, а не точки входа),

Не только C++ поддерживает оба, но допустимы комбинации фактов: дублирование наследования класса, который использует унифицирующее наследование:

struct VB { virtual void f(); };
struct D : virtual VB { virtual void g(); int dummy; };
struct DD1 : D { void g(); };
struct DD2 : D { void g(); };
struct DDD : DD1, DD2 { };

Существует только один DDD::VB но есть два различные Наблюдаемая D субобъектов в DDD с различным конечным overriders для D::g(). Независимо от того, [или нет] C++ -подобный язык (который поддерживает виртуальное и не виртуальное семантическое наследование) гарантирует, что разные подобъекты имеют разные адреса, адрес DDD::DD1::D не может совпадать с адресом DDD::DD2::D

Таким образом, смещение VB в D не может быть зафиксировано (на любом языке, который поддерживает унификацию и дублирование баз).

В этом конкретном примере реальный объект VB (объект во время выполнения) не имеет конкретного элемента данных, кроме vptr, а vptr является специальным скалярным элементом, так как он является общим элементом типа "инвариант" (не const): он зафиксирован на конструктор (инвариант после завершения построения) и его семантика разделяются между базами и производными классами. Поскольку у VB нет скалярного члена, который не является инвариантом типа, то в DDD подобъект VB может быть наложением на DDD::DD1::D, если виртуальная таблица D совпадает с виртуальной таблицей VB.

Это, однако, не может иметь место для виртуальных баз, которые имеют неинвариантные скалярные элементы, то есть обычные элементы данных с идентичностью, то есть элементы, занимающие различный диапазон байтов: эти "реальные" элементы данных не могут быть наложены ни на что другое. Таким образом, виртуальный базовый подобъект с элементами данных (элементы с адресом, который гарантированно будет отличаться от C++ или любым другим C++ -подобным языком, который вы реализуете) должен быть размещен в отдельном месте: виртуальные базы с элементами данных обычно (##) имеют по своей сути нетривиальные смещения.

(##) с потенциально очень узким частным случаем с производным классом без элемента данных с виртуальной базой с некоторыми элементами данных

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

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

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

Морально виртуальная база - это отношение базового класса, которое включает в себя виртуальное наследование (возможно, плюс не виртуальное наследование). Выполнение преобразования производного в базовое, в частности, преобразование указателя d в производное D, в базовое B, преобразование в...

  • ... неморальная виртуальная база по своей сути обратима в каждом случае:

    • существует взаимно-однозначное соотношение между идентичностью подобъекта B в D и D (который может быть самим подобъектом);
    • обратная операция может быть выполнена с помощью static_cast<D*>: static_cast<D*>((B*)d) равно d;
  • (в любом C++ подобном языке с полной поддержкой унификации и дублирования наследования)... морально виртуальная база по своей сути необратима в общем случае (хотя она обратима в общем случае с простыми иерархиями). Обратите внимание, что:

    • static_cast<D*>((B*)d) плохо сформирован;
    • dynamic_cast<D*>((B*)d) будет работать для простых случаев.

Итак, пусть называется виртуальной ковариацией тот случай, когда ковариация возвращаемого типа основана на морально-виртуальной основе. При переопределении с виртуальной ковариацией соглашение о вызовах не может предполагать, что основание будет с известным смещением. Таким образом, новая запись vtable необходима для виртуальной ковариации, независимо от того, является ли переопределенное объявление первичным:

struct VB { virtual void f(); }; // almost empty
struct D : virtual VB { }; // VB is potential primary

struct Ba { virtual VB * g(); };
struct Da : Ba { // non virtual base, so Ba is inherent primary
  D * g(); // virtually covariant: D->VB is morally virtual
};

Здесь VB может иметь нулевое смещение в D и никакая корректировка может не потребоваться (например, для полного объекта типа D), но это не всегда так в подобъекте D: при работе с указателями на D невозможно знать будь то так.

Когда Da::g() переопределяет Ba::g() с помощью виртуальной ковариации, следует предполагать общий случай, поэтому для Da::g() строго необходима новая запись vtable, поскольку невозможно преобразование нисходящего указателя из VB в D который обращает преобразование указателя D в VB в общем случае.

Ba является неотъемлемой частью Da поэтому семантика Ba::vptr является общей/улучшенной:

  • на этом скалярном члене есть дополнительные гарантии/инварианты, и vtable расширяется;
  • Для Da нет нового vptr.

Таким образом, Da_vtable (изначально совместимый с Ba_vtable) требует двух разных записей для виртуальных вызовов g():

  • в части Ba_vtable vtable: Ba::g() vtable: вызывает окончательный переопределитель Ba::g() с неявным параметром this Ba* и возвращает значение VB*.
  • в части новых членов записи vtable: Da::g() vtable: вызывает окончательный переопределение Da::g() (которое по своей сути совпадает с окончательным переопределением Ba::g() в C++) с неявным этим параметром Da* и возвращает значение D*.

Обратите внимание, что на самом деле здесь нет никакой свободы ABI: основы дизайна vptr/vtable и их внутренние свойства подразумевают наличие этих нескольких записей для уникальной виртуальной функции на высоком уровне языка.

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

[Пример виртуальной ковариации, которая оказывается только тривиально ковариантной, так как в полном D смещение для VB является тривиальным, и в этом случае не потребовался бы никакой корректирующий код:

struct Da : Ba { // non virtual base, so inherent primary
  D * g() { return new D; } // VB really is primary in complete D
                            // so conversion to VB* is trivial here
};

Обратите внимание, что в этом коде некорректная генерация кода для виртуального вызова с ошибочным компилятором, который будет использовать запись Ba_vtable для вызова g(), на самом деле будет работать, потому что ковариация оказывается тривиальной, так как VB является первичным в полном D

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

--end пример]

Но если Da::g() является окончательным в ABI, только виртуальные вызовы могут быть сделаны через VB * g(); объявление: ковариация сделана чисто статической, преобразование производного в базовое выполняется во время компиляции как последний шаг виртуального блока, как если бы виртуальная ковариация никогда не использовалась.

Возможное продление финала

В C++ есть два типа виртуальности: функции-члены (соответствующие сигнатуре функции) и наследование (соответствие по имени класса). Если final перестает переопределять виртуальную функцию, может ли она применяться к базовым классам на языке, подобном C++?

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

"Почти прямое" отношение подобъекта означает, что косвенный подобъект контролируется почти как прямой подобъект:

  • почти прямой подобъект может быть инициализирован как прямой подобъект;
  • контроль доступа никогда не является действительно препятствием для доступа (недоступные частные почти прямые субобъекты могут быть доступны по усмотрению).

Виртуальное наследование обеспечивает практически прямой доступ:

  • конструктор для каждой виртуальной базы должен вызываться ctor-init-list конструктора самого производного класса;
  • когда виртуальный базовый класс недоступен из-за того, что объявлен как частный в базовом классе, или публично унаследован в частном базовом классе базового класса, производный класс может по своему усмотрению снова объявить виртуальную базу как виртуальную базу, сделав ее доступной.

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

struct VB { virtual void f(); };
struct D : virtual VB { };
struct DD : D
  // , virtual VB  // imaginary overrider of D inheritance of VB
  {
  // DD () : VB() { } // implicit definition
}; 

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

struct VB { virtual void f(); };
struct D : virtual VB { };
struct DD : D, virtual final VB {
  // DD () : VB() { } // implicit definition
}; 

Здесь виртуальность базы VB заморожена и не может использоваться в дальнейших производных классах; виртуальность делается невидимой и недоступной для производных классов, а местоположение VB фиксируется.

struct DDD : DD {
  DD () : 
    VB() // error: not an almost direct subobject
  { } 
}; 
struct DD2 : D, virtual final VB {
  // DD2 () : VB() { } // implicit definition
}; 
struct Diamond : DD, DD2 // error: no unique final overrider
{                        // for ": virtual VB"
}; 

Замораживание виртуальности делает незаконным объединение Diamond::DD::VB и Diamond::DD2::VB но виртуальность VB требует объединения, что делает Diamond противоречивым, недопустимым определением класса: ни один класс не может быть производным от обоих DD и DD2 [аналог/пример: точно так же, как никакой полезный класс не может быть напрямую получен из A1 и A2:

struct A1 {
  virtual int f() = 0;
};
struct A2 {
  virtual unsigned f() = 0;
};
struct UselessAbstract : A1, A2 {
  // no possible declaration of f() here
  // none of the inherited virtual functions can be overridden
  // in UselessAbstract or any derived class
};

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

- конец аналога/пример]

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

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

Ответ 2

Я считаю, что добавление final ключевого слова не должно нарушать ABI, однако удаление его из существующего класса может сделать некоторые оптимизации недействительными. Например, рассмотрим это:

// in car.h
struct Vehicle { virtual void honk() { } };
struct Car final : Vehicle { void honk() override { } };

// in car.cpp

// Here, the compiler can assume that no derived class of Car can be passed,
// and so 'honk()' can be devirtualized. However, if Car is not final
// anymore, this optimization is invalid.
void foo(Car* car) { car->honk(); }

Если foo скомпилирован отдельно и, например, отправлен в разделяемую библиотеку, удаление final (и, следовательно, предоставление пользователям возможности извлекать из Car) может сделать оптимизацию недействительной.

Я не уверен на 100% в этом, хотя, некоторые из них являются предположениями.

Ответ 3

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

class A {
    virtual void f();
};

class B final : public A {
    virtual void f(); // <- should be ok
    virtual void g(); // <- not ok
};

Идея состоит в том, что каждый раз, когда в C++ вы можете вызывать метод g() вас есть указатель/ссылка, статический и динамический тип которых B: статический, потому что метод не существует, за исключением B и его потомков, динамический, потому что final гарантирует, что B не имеет детей. По этой причине вам никогда не нужно делать виртуальную диспетчеризацию для вызова правильной реализации g() (потому что может быть только одна), и компилятор может (и не должен) добавить ее в виртуальную таблицу для B - пока он вынужден сделайте это, если метод может быть переопределен. Это, в общем-то, и есть смысл, для которого существует final ключевое слово, насколько я понимаю.