Изменит ли ABI производный класс C ++ "final"? - PullRequest
0 голосов
/ 20 ноября 2018

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

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

Ответы [ 3 ]

0 голосов
/ 28 ноября 2018

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

    • B1_vtable.f_ptr указывает на окончательную переопределение для B1::f()
    • B2_vtable.f_ptr указывает на окончательное переопределение для B2::f()
  2. B1_vtable.f_ptr должно быть с тем же смещением, что и B2_vtable.f_ptr (из соответствующих членов данных vptr в 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 должно иметь две разные таблицы: одну таблицу, соответствующую B1_vtable и одну с B2_vtable (на практике они объединены в одну большую таблицу для D, но концептуальноэто две разные структуры).
  • запись виртуального члена B2::g, которая переопределяется в D, требует двух записей, одна в D_B2_vtable (которая является просто B2_vtable компоновкойс другими значениями) и один в D_B1_vtable, который является расширенным B1_vtable: a B1_vtable плюс записи для новых функций времени выполнения D.

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

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

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

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

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

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

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

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

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

    struct B {virtual void 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()

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

Дублирование наследования , когда вы косвенно производите несколько раз от данного класса, подразумевает несколько vptrs, 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, но в DDD есть два наблюдаемых различных D подобъекта с различными окончательными переопределениями для D::g().Независимо от того, гарантирует ли C ++ -подобный язык (который поддерживает виртуальное и не виртуальное семантическое наследование), что разные подобъекты имеют разные адреса, адрес DDD::DD1::D не может совпадать с адресом DDD::DD2::D.

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

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

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

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

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

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

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

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

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

    • существует отношение один к одному между идентичностью подобъекта B объекта D и D (который может бытьсам подобъект);
    • обратная операция может быть выполнена с static_cast<D*>: static_cast<D*>((B*)d) is 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() виртуальной ковариацией, необходимо принять общий случай, поэтому новая запись vtable строго необходима для Da::g(), поскольку существуетневозможно преобразование указателя вниз из VB в D, которое обращает преобразование указателя D в VB в общем случае.

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

  • существуют дополнительные гарантии / инварианты для этого скалярного члена, и расширяется виртуальная таблица;
  • новый * vptr не требуется для Da.

Таким образом, 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 ++) с неявным параметром this 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.

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

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

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

Возможное расширение final

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

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

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

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

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

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

0 голосов
/ 28 ноября 2018

Если вы не вводите новые виртуальные методы в свой класс final (только методы переопределения родительского класса), все должно быть в порядке (виртуальная таблица будет такой же, как родительский объект, потому что она должна быть в состоянии вызываться с указателем на 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 существует, насколько я понимаю,

0 голосов
/ 25 ноября 2018

Я считаю, что добавление ключевого слова 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% в этом, хотя некоторые из них являются предположениями.

...