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.