2025, Oct 06 11:18

Чисто виртуальный вызов в pybind11: решение через smart_holder и trampoline

Почему при связке абстрактных классов C++ и Python в pybind11 возникает чисто виртуальный вызов и как исправить: py::smart_holder и trampoline life support.

При связывании абстрактных базовых классов C++ с Python через pybind11 легко угодить в ловушку времени жизни. Симптом сбивает с толку: вы создаёте в Python класс‑наследник, вызываете из C++ переопределённый метод — и в итоге срабатывает чисто виртуальный вызов базового класса C++. Никаких падений в коде привязок, лишь ошибка времени выполнения именно там, где ожидалось срабатывание Python‑переопределения.

Минимальный пример, воспроизводящий проблему

Ниже — C++‑модуль, который экспортирует абстрактные Actor и ActorFactory, а также небольшой оркестратор, создающий и запускающий актёра. Переопределение в Python есть, но при обратном вызове из C++ происходит чисто виртуальный вызов.

#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <memory>
#include <string>
#include <iostream>
// Абстрактный класс для реализации в Python
struct Actor {
    virtual ~Actor() = default;
    virtual void setup() = 0;
};
// Абстрактная фабрика для реализации в Python
struct ActorFactory {
    virtual ~ActorFactory() = default;
    virtual std::shared_ptr<Actor> fetchActor(const std::string& type) = 0;
};
// Использует фабрику, чтобы получить актёра, а затем вызывает его
class Orchestrator {
private:
    std::shared_ptr<Actor> actorPtr;
    std::shared_ptr<ActorFactory> makerPtr;
public:
    Orchestrator(std::shared_ptr<ActorFactory> fact) : makerPtr(fact) {}
    void execute(const std::string& name) {
        actorPtr = makerPtr->fetchActor(name);
        actorPtr->setup(); // Здесь вместо Python-переопределения срабатывает чисто виртуальный вызов
    }
};
// ------------------- Трамплины -------------------
namespace py = pybind11;
struct PyActorShim : public Actor {
    using Actor::Actor;
    void setup() override {
        PYBIND11_OVERRIDE_PURE(
            void,
            Actor,
            setup
        );
    }
};
struct PyActorFactoryShim : public ActorFactory {
    using ActorFactory::ActorFactory;
    std::shared_ptr<Actor> fetchActor(const std::string& type) override {
        PYBIND11_OVERRIDE_PURE(
            std::shared_ptr<Actor>,
            ActorFactory,
            fetchActor,
            type
        );
    }
};
// ------------------- Привязки -------------------
PYBIND11_MODULE(CoreBridge, m) {
    py::class_<Actor, PyActorShim, std::shared_ptr<Actor>>(m, "Actor")
        .def(py::init<>())
        .def("setup", &Actor::setup);
    py::class_<ActorFactory, PyActorFactoryShim, std::shared_ptr<ActorFactory>>(m, "ActorFactory")
        .def(py::init<>())
        .def("fetchActor", &ActorFactory::fetchActor);
    py::class_<Orchestrator>(m, "Orchestrator")
        .def(py::init<std::shared_ptr<ActorFactory>>())
        .def("execute", &Orchestrator::execute);
}

И соответствующая часть на Python, которая её воспроизводит:

import CoreBridge as cb
class CustomFactory(cb.ActorFactory):
    def __init__(self):
        super().__init__()
        self.creators = {}
    def add_actor(self, key, fn):
        self.creators[key] = fn
    def fetchActor(self, tag):
        return self.creators[tag]()
class CustomActor(cb.Actor):
    def __init__(self):
        super().__init__()
    def setup(self):
        print("Hello, world!")
fac = CustomFactory()
fac.add_actor("ActorA", lambda: CustomActor())
runner = cb.Orchestrator(fac)
runner.execute("ActorA")

Что на самом деле происходит

Корень проблемы — тип владения (holder). Если для объектов, реализованных в Python, использовать std::shared_ptr, времена жизни в Python и C++ оказываются несинхронными. Python‑объект удерживает C++‑экземпляр, но обратного гарантированного удержания нет. Если Python‑экземпляр исчезает (на него не остаётся ссылок в Python), последующий вызов из C++ в то, что раньше было Python‑переопределением, придёт к чисто виртуальной функции в C++, и вы получите чисто виртуальный вызов во время выполнения.

Решение: py::smart_holder плюс поддержка времени жизни self в трамплинах

Начиная с pybind11 v3, в качестве holder можно использовать py::smart_holder. Он гарантирует, что Python‑объект живёт, пока на стороне C++ есть ссылка, выравнивая времена жизни так, чтобы виртуальный вызов доходил до Python‑переопределения. Трамплины следует унаследовать от py::trampoline_self_life_support. Эта комбинация устраняет несоответствие времён жизни и сохраняет корректный виртуальный диспетч из C++ в Python.

Ниже — исправленная C++‑привязка с использованием py::smart_holder в объявлениях классов и py::trampoline_self_life_support в трамплинах. Конструктор оркестратора, принимающий std::shared_ptr, остаётся валидным, поскольку py::smart_holder при необходимости приводится к std::shared_ptr.

#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <memory>
#include <string>
#include <iostream>
struct Actor {
    virtual ~Actor() = default;
    virtual void setup() = 0;
};
struct ActorFactory {
    virtual ~ActorFactory() = default;
    virtual std::shared_ptr<Actor> fetchActor(const std::string& type) = 0;
};
class Orchestrator {
private:
    std::shared_ptr<Actor> actorPtr;
    std::shared_ptr<ActorFactory> makerPtr;
public:
    Orchestrator(std::shared_ptr<ActorFactory> fact) : makerPtr(fact) {}
    void execute(const std::string& name) {
        actorPtr = makerPtr->fetchActor(name);
        actorPtr->setup();
    }
};
namespace py = pybind11;
struct PyActorShim : public Actor, py::trampoline_self_life_support {
    using Actor::Actor;
    void setup() override {
        PYBIND11_OVERRIDE_PURE(
            void,
            Actor,
            setup
        );
    }
};
struct PyActorFactoryShim : public ActorFactory, py::trampoline_self_life_support {
    using ActorFactory::ActorFactory;
    std::shared_ptr<Actor> fetchActor(const std::string& type) override {
        PYBIND11_OVERRIDE_PURE(
            std::shared_ptr<Actor>,
            ActorFactory,
            fetchActor,
            type
        );
    }
};
PYBIND11_MODULE(CoreBridge, m) {
    py::class_<Actor, PyActorShim, py::smart_holder>(m, "Actor")
        .def(py::init<>())
        .def("setup", &Actor::setup);
    py::class_<ActorFactory, PyActorFactoryShim, py::smart_holder>(m, "ActorFactory")
        .def(py::init<>())
        .def("fetchActor", &ActorFactory::fetchActor);
    py::class_<Orchestrator>(m, "Orchestrator")
        .def(py::init<std::shared_ptr<ActorFactory>>())
        .def("execute", &Orchestrator::execute);
}

Код на Python, использующий модуль, менять не нужно. Переопределение остаётся прежним, а вызов из C++ корректно уйдёт в Python и не упрётся в чисто виртуальный метод.

Почему это важно

Фабрики и плагин‑архитектуры естественно сочетаются с паттерном «Python поверх C++», но одновременно усиливают риски, связанные с временем жизни. Объекты создаются в Python, а используются из C++, пересекают границы владения — и неверный выбор holder незаметно рассинхронизирует их жизненные циклы. Так и появляются ложные чисто виртуальные вызовы. Применение py::smart_holder и включение self‑life‑support в трамплинах согласуют обе стороны по факту существования объекта и обеспечивают надёжный виртуальный диспетч.

Выводы

Если при вызове из C++ Python‑переопределение чисто виртуального метода приводит к чисто виртуальному вызову, проверьте тип holder. В pybind11 v3 предпочитайте py::smart_holder для классов, реализованных в Python и вызываемых из C++, а трамплины наследуйте от py::trampoline_self_life_support. Это выравнивает времена жизни и сохраняет корректное поведение без изменений в общей архитектуре.

Статья основана на вопросе на StackOverflow от Arturo 550 и ответе Ahmed AEK.