С++: специализация класса - допустимое преобразование для соответствующего компилятора?

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

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

(Очень) основная идея выглядит примерно так: предположим, у вас есть класс C, подобный следующему:

class C : public SomeInterface
{
public:
    C(Foo * f) : _f(f) { }

    virtual void quack()
    {
        _f->bark();
    }

    virtual void moo()
    {
        quack(); // a virtual call on this because quack() might be overloaded
    }

    // lots more virtual functions that call virtual functions on *_f or this

private:
    Foo * const _f; // technically doesn't have to be const explicitly
                    // as long as it can be proven not be modified
};

И вы знали, что существуют конкретные подклассы Foo, такие как FooA, FooB и т. д., с известными полными типами (не обязательно имея исчерпывающий список), тогда вы могли бы предварительно скомпилировать специализированные версии C для некоторых выбранных подклассов Foo, например, ( обратите внимание, что конструктор не включен здесь намеренно, так как он не будет вызываться):

class C_FooA final : public SomeInterface
{
public:
    virtual void quack() final
    {
        _f->FooA::bark(); // non-polymorphic, statically bound
    }

    virtual void moo() final
    {
        C_FooA::quack(); // also static, because C_FooA is final
        // _f->FooA::bark(); // or you could even do this instead
    }

    // more virtual functions all specialized for FooA (*_f) and C_FooA (this)

private:
    FooA * const _f;
};

И замените конструктор C чем-то вроде следующего:

C::C(Foo * f) : _f(f)
{
    if(f->vptr == vtable_of_FooA) // obviously not Standard C++
        this->vptr = vtable_of_C_FooA; 
    else if(f->vptr == vtable_of_FooB)
        this->vptr = vtable_of_C_FooB;
    // otherwise leave vptr unchanged for all other values of f->vptr
}

Таким образом, динамический тип создаваемого объекта изменяется на основе динамического типа аргументов его конструктора. (Обратите внимание, что вы не можете сделать это с шаблонами, потому что вы можете создать C<Foo>, только если вы знаете тип f во время компиляции). Отныне любой вызов с FooA::bark() по C::quack() включает только один виртуальный вызов: либо вызов C::quack() статически привязывается к неспециализированной версии, которая динамически вызывает FooA::bark(), либо вызов C::quack() динамически перенаправляется на C_FooA::quack(), который статически вызывает FooA::bark(). Кроме того, в некоторых случаях динамическая диспетчеризация может быть полностью устранена, если анализатор потока имеет достаточно информации для статического вызова C_FooA::quack(), что может быть очень полезно в тесном цикле, если он допускает встраивание. (Хотя технически на тот момент вы, вероятно, были бы в порядке даже без этой оптимизации...)

(Обратите внимание, что это преобразование безопасно, хотя и менее полезно, даже если _f не является константным и защищенным, а не частным, а C наследуется от другой единицы перевода... единица перевода, создающая виртуальную таблицу для унаследованного класса, не будет знать вообще ничего о специализациях и конструкторе унаследованного класса просто установит this->vptr в свою собственную виртуальную таблицу, которая не будет ссылаться ни на какие специализированные функции, потому что ничего о них не знает.)

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

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

Мне кажется, что нет, поскольку стандарт вообще не определяет, как выполняется виртуальная диспетчеризация или как представляются указатели на функции-члены. Я почти уверен, что в механизме RTTI нет ничего, что мешало бы C и C_FooA маскироваться под один и тот же тип для всех целей, даже если у них разные виртуальные таблицы. Единственная другая вещь, которая, как я мог подумать, может иметь значение, — это внимательное прочтение ODR, но, вероятно, нет.

Я что-то упускаю из виду? За исключением проблем с ABI/связыванием, возможны ли подобные преобразования без нарушения соответствующих программ на C++? (Кроме того, если да, можно ли это сделать в настоящее время с помощью Itanium и/или MSVC ABI? Я вполне уверен, что ответ тоже да, но, надеюсь, кто-то может это подтвердить.)

EDIT: кто-нибудь знает, реализовано ли что-то подобное в каком-либо основном компиляторе/JIT для C++, Java или C#? (См. обсуждение и связанный чат в комментариях ниже...) Я знаю, что JIT выполняют спекулятивное статическое связывание/встраивание виртуальных машин непосредственно на сайтах вызовов, но я не знаю, делают ли они что-то подобное (с совершенно новыми vtables генерируется и выбирается на основе проверки одного типа, выполняемой в конструкторе, а не в каждом месте вызова).


person Community    schedule 01.03.2013    source источник
comment
Разве это не то же самое, что сделать C шаблоном ‹typename Foo›?   -  person cooky451    schedule 01.03.2013
comment
Да, но вы не можете выбрать специализацию во время выполнения, если не сделаете это вручную... вы можете выбрать конкретный шаблон только во время компиляции, а это означает, что вы не можете обернуть полиморфизм времени компиляции полиморфизмом времени выполнения, не выполняя type проверяет себя вручную, что подвержено ошибкам и является хрупким. Этот метод в основном заставляет компилятор делать это за вас, основываясь на информации времени выполнения.   -  person Stephen Lin    schedule 01.03.2013
comment
В любом случае правильный foo нужно выбирать вручную, поэтому я не вижу разницы. Что ж, теперь можно поместить несколько C с разными foos в один контейнер — но, вероятно, этого никогда не произойдет, поскольку C сам имеет виртуальные функции, и, вероятно, нужен container‹unique_ptr‹SomeInterface››.   -  person cooky451    schedule 01.03.2013
comment
Что вы имеете в виду, что foo нужно выбирать вручную? Это делается во время выполнения, и для этого вам не нужно исчерпывающее дерево типов... вы просто специализируетесь на известных вам типах.   -  person Stephen Lin    schedule 01.03.2013
comment
Да, именно, вы вызываете через SomeInterface, и вы динамически получаете правильный C_FooX, но вы затем статически вызываете правильный FooX. Или вы статически вызываете C и динамически получаете правильный FooX. Два виртуальных вызова вместо одного, и вы можете сделать это с любым уровнем вложенности, если информация о типе находится в текущей TU, и она устойчива к добавлению дополнительных типов в другие TU (поэтому вам не нужен анализ всей программы). ).   -  person Stephen Lin    schedule 01.03.2013
comment
(Большая проблема заключается в том, что, в отличие от шаблонов, вы не знаете, какая из этих специализаций вам действительно нужна, до момента выполнения, поэтому, если вы делаете это вслепую, вы будете дублировать огромное количество кода... поэтому вам нужны эвристики или подсказки программиста)   -  person Stephen Lin    schedule 01.03.2013
comment
Этот ideone.com/71VWTG должен давать идеальные инструкции. Есть только один виртуальный вызов, если мы знаем его тип. Я не вижу проблемы. :)   -  person cooky451    schedule 01.03.2013
comment
но вы создали шаблон поверх foo! в этом весь смысл... что, если foo был передан вам откуда-то еще? Кроме того, вызов foo_->foo(); не будет надежно статически отправлен... это может произойти здесь, с анализом потока, поскольку все находится в одном TU, но вы можете создать unique_ptr‹Foo›, который содержит подтип Foo. Тот факт, что это unique_ptr‹Foo›, не дает никакой гарантии, что он неполиморфный, потому что в C++ нет синтаксиса для указания неполиморфного указателя на полиморфный тип.   -  person Stephen Lin    schedule 01.03.2013
comment
Она не только статически диспетчеризируется, функция полностью исчезает (инлайнится), потому что мы уже знаем точный тип. Если мы не знаем тип, я просто передаю unique_ptr‹foo_base›, как показано. Изменить: обратите внимание, что тип параметра шаблона не обязательно должен быть полиморфным! Это может быть все, что вы хотите!   -  person cooky451    schedule 01.03.2013
comment
Это зависит от анализа потока, который может быть ненадежным. Что делать, если вам передали unique_ptr‹Foo› из другой TU? Нет никакого способа узнать, что он на самом деле содержит Foo, а не подтип Foo. И дело в том, что в случае unique_ptr<foo_base> будет два виртуальных звонка вместо одного.   -  person Stephen Lin    schedule 01.03.2013
comment
Послушайте, я все время программирую с шаблонами :) Я не говорю, что они не работают, когда у вас есть информация о типе времени компиляции. На самом деле, они работают настолько хорошо, что я думаю, что компилятор должен генерировать их псевдошаблоны автоматически, чтобы их можно было использовать, когда у вас нет информации о типах во время компиляции. В этом весь смысл.   -  person Stephen Lin    schedule 01.03.2013
comment
Да, конечно, потому что класс unique_ptr‹foo_base› демонстрирует неизвестный случай. И определенно надежно, что другой вызов будет встроен, потому что он не виртуальный. В этом весь смысл.   -  person cooky451    schedule 01.03.2013
comment
давайте продолжим это обсуждение в чате   -  person Stephen Lin    schedule 01.03.2013
comment
Извините, моя ошибка в части анализа потока, недостаточно внимательно посмотрела на реализацию (но остальные мои комментарии о получении неизвестного foo действительны)   -  person Stephen Lin    schedule 01.03.2013
comment
(Приятное обсуждение в чате, если кто-то хочет присоединиться)   -  person Stephen Lin    schedule 01.03.2013


Ответы (2)


Есть ли в стандарте C++ что-нибудь, что помешало бы компилятору выполнить такое преобразование?

Нет, если вы уверены, что наблюдаемое поведение не изменилось - это правило «как если бы», которое является стандартным разделом 1.9.

Но это может затруднить доказательство правильности вашего преобразования: 12.7/4:

Когда виртуальная функция вызывается прямо или косвенно из конструктора (включая mem-initializer или brace-or-equal-initializer для нестатического члена данных) или из деструктор, а объект, к которому применяется вызов, является строящимся или уничтожаемым объектом, вызывается функция, определенная в конструкторе или собственном классе деструктора или в одной из его баз, но не функция, переопределяющая ее в классе, производном из собственного класса конструктора или деструктора или переопределить его в одном из других базовых классов самого производного объекта.

Таким образом, если деструктор Foo::~Foo() прямо или косвенно вызывает C::quack() для объекта c, где c._f указывает на уничтожаемый объект, вам необходимо вызвать Foo::bark(), даже если _f был FooA, когда вы конструировали объект c.

person Community    schedule 01.03.2013
comment
Да, похоже, вам нужен исходник всех нечистых виртуальных деструкторов всех базовых классов FooA, доступных в TU, чтобы сделать это, тогда, и только если вы можете доказать, что оттуда нет обращений к какой-либо виртуальной функции C. Неудобно, но работать можно. В любом случае, спасибо, это было полезно... какие еще дыры вы можете придумать? - person Stephen Lin; 01.03.2013
comment
Черт, вам также нужно защититься от случая, когда конструктор C вызывается во время конструктора подкласса FooA... Я думаю, вы можете обойти это, но это требует, чтобы вы контролировали FooA и предоставили некоторый механизм, который позволяет C проверять что FooA полностью построен. - person Stephen Lin; 01.03.2013
comment
нечистый здесь не имеет значения. Чистый виртуальный деструктор имеет определение и вызывается точно так же, как и любой другой, и может вызвать точно такой же сценарий. - person aschepler; 01.03.2013
comment
Право, не уверен, что я думал с этим. - person Stephen Lin; 01.03.2013
comment
На самом деле, второй сценарий тоже хорош, вам просто нужно иметь возможность статически анализировать конструктор FooA - то, что делают производные типы, не имеет значения, если конструктор FooA не вызывает конструктор C... Я думаю, что я долго работал над этим назад и просто забыл подробности. - person Stephen Lin; 01.03.2013
comment
Я собираюсь принять это только на том основании, что вы помогли мне обнаружить и закрыть дыру в этом методе, если только у кого-то другого нет более авторитетного ответа. - person Stephen Lin; 03.03.2013

На первый взгляд это звучит как вариант полиморфного встроенного кэширования, ориентированный на C++. Я думаю, что его используют и V8, и JVM Oracle, и я знаю, что .NET делает.

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

person Community    schedule 01.03.2013
comment
Однако полиморфное встроенное кэширование AFAIK выполняется только непосредственно на сайте вызова, верно, поэтому проверки защиты все еще необходимо выполнять для каждого вызова? (Этот метод выполняет только одну защитную проверку в конструкторе, если класс соответствует определенному шаблону, и после этого не несет никаких накладных расходов). - person Stephen Lin; 01.03.2013
comment
Да, но на практике, если у вас есть PIC, у вас также есть JIT, поэтому, если вы можете статически доказать, что цель имеет определенный тип, вы также можете скомпилировать ветку. Но я не уверен, что вы бы беспокоились; ветки дешевы, пока их легко предсказать, поэтому, пока тип всегда один и тот же, вы выигрываете. - person David Seiler; 01.03.2013
comment
Однако это не всегда один и тот же тип, вот в чем проблема. Это один и тот же тип для одного заданного объекта, но один и тот же код может вызываться поочередно между множеством разных объектов, приводящих к разным целям, искажающим ваш прогноз ветвления. Это специфицирует весь класс для каждого типа, поэтому каждый класс может предсказывать ветвь независимо. - person Stephen Lin; 01.03.2013
comment
(Это в основном позволяет Pimpl с почти нулевыми накладными расходами, с некоторыми ограничениями.) - person Stephen Lin; 01.03.2013