2025, Nov 01 20:17

pybind11 и C++: почему Python-колбэк не меняет буфер и что делать

Почему pybind11 теряет изменения в колбэках: обёртка хранит C++ по значению. Решение: ссылочное представление без копий, shared_ptr, NumPy, Eigen::Ref.

Связка оптимизационного ядра на C++ с API на Python часто держится на колбэках, которые заполняют выходные буферы на месте. Распространённый сюрприз с pybind11: запись со стороны Python вроде бы проходит, но исходный объект C++ не меняется. Если ваш колбэк принимает Vector по неконстантной ссылке, а после вызова изменения всё равно не сохраняются, почти наверняка вы упёрлись в границу владения и копирования.

Постановка проблемы

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

// Сторона C++ (биндинги pybind11 и место вызова)
#include "DenseVec.hpp"
#include <pybind11/pybind11.h>
namespace py = pybind11;
void optimize(const std::function<void(const DenseVec&, DenseVec&)>& apply_constraints) {
  const DenseVec xi = ...;
  DenseVec bounds = ...;
  apply_constraints(xi, bounds);
}
PYBIND11_MODULE(corebind, m) {
  py::class_<DenseVec>(m, "DenseVec")
    .def(py::init<size_t>(), "Constructor")
    .def("__getitem__", [](const DenseVec& v, size_t i) {
      return v[i];
    })
    .def("__setitem__", [](DenseVec& v, size_t i, double val) {
      v[i] = val;
    });
  m.def("optimize", &optimize);
}
# Сторона Python
import corebind
def apply_constraints(xx, out_v):
    out_v[0] = /* функция от xx */
    out_v[1] = /* функция от xx */
    ...
corebind.optimize(apply_constraints)

Несмотря на записи в объект со стороны Python, контейнер C++ после возврата из колбэка этих изменений не видит.

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

pybind11 хранит экземпляр C++ внутри Python‑объекта по значению. По сути, Python‑обёртка — это маленькая структура: одна часть — управляющий блок Python, другая — экземпляр вашего C++‑класса. Чтобы создать такой Python‑объект, pybind11 копирует C++‑объект. В итоге Python изменяет копию, находящуюся в обёртке, а не тот экземпляр, который вы создали на стеке C++. Настройка политик возврата вроде py::return_value_policy::reference_internal здесь не поможет, потому что обёртка всё равно владеет своим «по‑значению» C++‑содержимым.

Практичное решение: ссылочный (reference‑like) вид

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

// C++: тонкое ссылкоподобное представление
template <typename Vec>
struct DenseVecRefT {
  Vec* p = nullptr;
  explicit DenseVecRefT(Vec& vec) : p(&vec) {}
  double get(size_t i) const { return (*p)[i]; }
  void set(size_t i, double val) { (*p)[i] = val; }
};
using DenseVecRef = DenseVecRefT<DenseVec>;
// Обновлённый решатель: передаём во вход колбэка представление
void optimize(const std::function<void(const DenseVec&, DenseVecRef&)>& apply_constraints) {
  const DenseVec xi = ...;
  DenseVec bounds = ...;
  DenseVecRef out_view(bounds);
  apply_constraints(xi, out_view);
  // bounds обновлён через out_view
}
PYBIND11_MODULE(corebind, m) {
  py::class_<DenseVec>(m, "DenseVec")
    .def(py::init<size_t>(), "Constructor")
    .def("__getitem__", [](const DenseVec& v, size_t i) { return v[i]; })
    .def("__setitem__", [](DenseVec& v, size_t i, double val) { v[i] = val; });
  py::class_<DenseVecRef>(m, "DenseVecRef")
    .def("__getitem__", [](const DenseVecRef& ref, size_t i) { return ref.get(i); })
    .def("__setitem__", [](DenseVecRef& ref, size_t i, double val) { ref.set(i, val); });
  m.def("optimize", &optimize);
}
# Python: тот же колбэк, теперь получает представление, которое пишет насквозь
import corebind
def apply_constraints(xx, out_view):
    out_view[0] = /* функция от xx */
    out_view[1] = /* функция от xx */
    ...
corebind.optimize(apply_constraints)

Этот приём напоминает поведение span. При желании можно так же предоставить константное представление (read‑only), если нужна только читабельность без изменений. На практике такая ссылкоподобная стратегия работает надёжно.

Альтернативы и компромиссы

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

Другой путь — опереться на NumPy и выставить вашу матрицу через протокол буфера. Можно предоставить и режим только для чтения, и чтение‑запись. Однако создание буфера из Python‑объекта, как показано, требует копирования C++‑объекта в Python‑обёртку, и держать его на стеке уже нельзя. Чтобы избежать копии, пришлось бы вручную сконструировать массив NumPy, указывающий на стековую память, что делает код менее безопасным, либо создать Python‑объект (например, приведением к py::object) и просто читать и писать из копии.

Если вы уже используете Eigen, его интеграция доступна из коробки. Ограничение то же: передача матриц по ссылке требует Eigen::Ref — это тот же ссылочный подход, описанный выше.

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

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

Итоги

Суть в том, что Python‑обёртка pybind11 держит ваш C++‑объект по значению, поэтому создание обёртки копирует объект. Чтобы изменения из Python применялись к данным C++, направляйте записи через объект, который ссылается на исходное хранилище. Ссылкоподобное представление — прямое и эффективное решение, если аккуратно управлять временем жизни. Если предпочитаете совместное владение, заверните данные в std::shared_ptr и позвольте Python им управлять. Для кода с матрицами рассмотрите Eigen с Eigen::Ref или NumPy‑буферы, чётко понимая, где возникают копии и что это значит для безопасности. Выбирайте подход под вашу модель времени жизни и требования к производительности и относитесь к правилам владения как к ключевой части дизайна биндингов.

Статья основана на вопросе на StackOverflow от Charlie Vanaret - the Uno guy и ответе Ahmed AEK.