Pull to refresh

Comments 35

UFO just landed and posted this here

В Itanium C++ ABI есть некоторые гарантии на этот счет:


The order of the virtual function pointers in a virtual table is the order of declaration of the corresponding member functions in the class. If an implicitly-declared copy assignment operator, move assignment operator, or destructor is virtual, it is treated as if it were declared at the end of the class, in that order.

UFO just landed and posted this here

Ну в общем-то да, такие детали реализации относятся к ABI, а стандарт языка их особенно не касается.

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

Люди из Qt/KDE достаточно давно сделали хорошую подборку вопросов ABI-совместимости для C++. На Windows, например, порядок внутри vtable может поменяться даже если новые методы будут добавляться в конце определения класса.

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

Если не ошибаюсь можно посмотреть в сторону pimpl, для совместимости ABI.

В данном случае поменялся интерфейс и порядок функций в vtable. PIMPL к сожалению про другое.

Ну почему же? В данном случае базовый класс всегда будет содержать только виртуальный деструктор и невиртуальные методы, а реализация через PIMPL (точнее нечто рождённое из симбиоза PIMPL и NVI) будет выглядеть примерно так:


//
// my.hpp
//

namespace my
{

class Interface
{
public:
    virtual ~Interface() = default;

    void a() = 0;
    void b() = 0; // +
    void c() = 0;

protected:
    std::unique_ptr<class InterfaceImpl> m_impl;
};

class Implementation : public Interface
{
public:
    Implementation();
};

} // namespace my

//
// my_priv.hpp
//

namespace my {

class InterfaceImpl 
{
public:
    virtual ~InterfaceImpl() = default;

    virtual void a() = 0;
    virtual void b() = 0;
    virtual void c() = 0;
};

}

//
// my.cpp
//

#include "my.hpp"
#include "my_priv.hpp"

namespace my {

//
// Interface
//

// Non-virtual Interface
void Interface::a()
{
    m_impl->a();
}

void Interface::b()
{
    m_impl->b();
}

void Interface::c()
{
    m_impl->c();
}

//
// Implementation Implementation :-)
//
class ImplementationImpl : public InterfaceImpl
{
public:
    void a() override 
    {
        std::cout << "Implementation::a()" << std::endl;
    }

    void b() override
    {
        std::cout << "Implementation::b()" << std::endl;
    }

    void c() override
    {
        std::cout << "Implementation::c()" << std::endl;
    }
};

//
// Implementation contstruction
//

Implementation::Implementation()
{
    m_impl.reset(new ImplementationImpl);
}

} // namespace my

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


Если я что-то упустил, поправьте.

Во-первых, у вас там =0 в "невиртуальном" интерфейсе лишние.


Во-вторых, теперь при добавлении нового метода старый void Interface::c() будет вызывать новый void Implementation::b(). То есть проблема никуда не делась.

Во-первых, у вас там =0 в "невиртуальном" интерфейсе лишние.

Ага, из оригинала не удалил.


Во-вторых, теперь при добавлении нового метода старый void Interface::c() будет вызывать новый void Implementation::b(). То есть проблема никуда не делась.

Судя по всему вы невнимательно прочитали и вникли. void Interface::c() будет вызывать всегда только m_impl->c() и ничего более. Он не виртуальный. Более того, в публичном коде вообще нет void Implementation::a()/b()/c(). А реализация для m_impl — вся со стороны библиотеки, а уж она точно знает какой у вас vtable.


Проблемы могут возникнуть только в том случае, если автор библиотеки решит предоставить my_priv.hpp как часть публичного интерфейса, тогда всё станет ровно так, как и было раньше.


В противном случае в публичном интерфейсе только классы Interface и Implementation у которых всегда ровно один виртуальный метод: это деструктор. И то, для того, что бы можно было корректно вызывать оные по всей иерархии.


Т.е. добавляя метод Interface::c() в любое место класса, ты никак не изменяешь vtable, так как он не виртуальный, изменяется только внутренняя кухня, т.е. детали реализации.


Надеюсь сейчас стало понятнее.

А, ну если my_priv.hpp никому не отдавать — то да, должно сработать. Но за два уровня индирекции при каждом вызове перфекционисты могут и покусать...

Хех, перфекционизм, программирование, embedded (в моём случае), C++… это ингредиенты для когнитивного диссонанса :)


По сути, my_priv.hpp был выделен только для внутреннего использования, в конкретном примере, для простоты, можно было бы его расположить в my.cpp, что сняло бы несколько вопросов.

Суть в том, что реализации Interface и Implementation находятся в… э-э-э… одной «единице линковки» — в одной библиотеке, с которой связано приложение. Тогда при обновлении библиотеки её пользователи будут вызывать методы, правильные адреса которых подставит загрузчик, а они уже вызовут правильные вирутальные методы.


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

Да, как и многое, это компромиссное решение, и это не единственная его проблема.

Не совсем понятно что удивило автора, вполне ожидаемое поведение компилятора.

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

Ну да, он настолько нечастый, что все знают, что так делать нельзя (даже не думая о причинах), и плодят интерфейсы вида IDispatch, IDispatch2 и так далее.

Часто встречал это в коде, но только сейчас полноценно осознал, зачем и для чего это. Спасибо за пояснение, добавил примечание к статье.

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

интерфейс с тех пор, напомню, лишь дополнялся

Нет, он менялся. Дополняется — это записывается в конец (быть может, кроме особых функций вроде деструктора, но не могу найти документацию).
Для gcc проверить, как оно ляжет, можно через флаг "-fdump-lang-class" gcc.gnu.org/onlinedocs/gcc/Developer-Options.html
Именно, если бы он действительно дополнил класс, то унаследовал бы новый «class Interface2: public Interface» с новым мембером «b()» и сделал бы новую имплементацию от нового интерфейса. А так, подумал одно, а реализовал другое.

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


struct Interface
{
    virtual ~Interface() = default;
    virtual void a() = 0;
};

class Implementation : public Interface
{
    virtual void a() override;
};

// в следующей версии
struct Interface2 : Interface
{
    virtual void b() = 0;
};

class Implementation2 : public Interface2, Implemetation
{
    virtual void a() override;
};

Еще можно почитать на ту же тему статью Interface Versioning in C++

ABI у плюсов — вещь условная (MSVC, например, обещает, что если поменялась версия компилятора, то все сломается). Поэтому, самым верным способом для библиотек будет использовать FFI (т.е. использовать ABI от C). Ну, либо всегда компилировать одним компилятором, на одной машине и в один день (а лучше вообще линковать статически).
P.S. Разве плюсовый манглинг не поможет в случае, когда у измененных методов другая сигнатура? Или vtable не содержит в себе самих символов?
P.S. Разве плюсовый манглинг не поможет в случае, когда у измененных методов другая сигнатура? Или vtable не содержит в себе самих символов?

Не поможет. Вызовы через vtable обычно осуществляются по фиксированным смещениям.


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


Не то, чтобы это было невозможным. Apple когда-то делала стабильные поля классов для Objective-C, как раз с целью избежать проблем с двоичной совместимостью. Но, похоже, в мире C++ такой подход не особо прижился.

В Objective-C диспатч же абсолютно по-другому работает. Он намного гибче, но и куда медленее, существуют даже хаки как уменьшать оверхед диспатча

Я не про сообщения — я про доступ к полям (instance variables). Если к полям обращаются не через аксессоры, а напрямую — через смещения — то возникает проблема, схожая с vtable: при обновлении базового класса его наследники могут сломаться, потому что смещения съедут. В Objective-C ввели непрямой доступ к полям, смещения к которым хранятся в глобальных переменных (которые разруливаются поимённо загрузчиком). Это позволяет избежать расхождения смещений, но требует лишнего обращения к памяти.


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

Про ABI в MSVC, Microsoft немного поработал в этом направлении, и теперь:
We've changed this behavior in Visual Studio 2015, 2017, and 2019. The runtime libraries and apps compiled by any of these versions of the compiler are binary-compatible.
Впрочем, все равно прийдет момент когда им прийдется все сломать.

Кстати, вот тут у вас тоже есть подводный камень, на который можно неожиданной нарваться:


    my::Interface* ptr = function();
    ptr->a(); // Implementation::a() with both old and new shared library
    ptr->c(); // Implementation::c() with old, Implementation::b() with new shared library

    delete ptr;

function(), который my::Interface* make_instance() вполне может оказаться чем-то вроде:


my::Interface* make_instance()
{
    auto ptr = my::custom_chunk_allocator(sizeof(my::implementation));
    return new(ptr) my::Implementation();
}

Тогда delete ptr в клиентском коде делать нельзя. В таком подходе лучше сделать парные функции make_instance()/delete_instance() (имена на ваше усмотрение).

Согласен, в реальном коде действительно нужно делать так. В синтетическом примере просто не хотелось плодить лишних сущностей, заметка все-таки про другое.
У GCC есть специальные опции:
-fpic: Generate position-independent code (PIC) suitable for use in a shared library, if supported for the target machine.
-fPIC: If supported for the target machine, emit position-independent code, suitable for dynamic linking and avoiding any limit on the size of the global offset table.
-fpie -fPIE: These options are similar to -fpic and -fPIC, but the generated position-independent code can be only linked into executables.
Sign up to leave a comment.

Articles