2025, Dec 11 12:03
Почему в pybind11 меняется id() при возврате shared_ptr и как стабилизировать обёртку
Разбираем поведение pybind11 со std::shared_ptr: почему id() Python-обёртки меняется между вызовами и как обеспечить стабильную обёртку без правок биндингов.
При связывании C++‑классов с Python через pybind11 легко перепутать время жизни и идентичность базового C++‑экземпляра с Python‑обёрткой, которая его представляет. Отсюда частое удивление: метод, возвращающий объект, хранящийся в shared_ptr, будто бы каждый раз отдаёт новый Python‑объект, из‑за чего id() меняется между вызовами — пока вы не сохраните результат. Разберёмся, что на самом деле происходит и почему на стороне C++ всё в порядке.
Минимальная конфигурация, воспроизводящая поведение
Ниже — C++‑модуль, который экспортирует простой класс и «контейнер», хранящий std::shared_ptr. Имена условные; важно лишь, как устроено владение.
#include <memory>
#include <iostream>
#include <pybind11/pybind11.h>
class Leaf {
};
class Box {
public:
Box(std::shared_ptr<Leaf> leaf) : leaf_ref{leaf} {
show_leaf_addr();
}
std::shared_ptr<Leaf> acquire_leaf() {
return leaf_ref;
}
void show_leaf_addr() {
std::cout << leaf_ref << std::endl;
}
private:
std::shared_ptr<Leaf> leaf_ref;
};
namespace py = pybind11;
PYBIND11_MODULE(ptrs, m) {
py::class_<Leaf, std::shared_ptr<Leaf>>(m, "Leaf")
.def(py::init<>());
py::class_<Box>(m, "Box")
.def(py::init<const std::shared_ptr<Leaf>&>(), py::arg("leaf"))
.def("acquire_leaf", &Box::acquire_leaf)
.def("show_leaf_addr", &Box::show_leaf_addr);
}
CMakeLists.txt предельно простой:
cmake_minimum_required(VERSION 3.21)
project(ptrs VERSION 1.0 LANGUAGES CXX)
list(APPEND CMAKE_PREFIX_PATH "${CMAKE_BINARY_DIR}")
find_package(pybind11 REQUIRED)
pybind11_add_module(ptrs module.cpp)
Что вы увидите из Python
Если создавать объект в два шага, обёртка остаётся стабильной. Числа ниже — лишь пример; на вашей системе они будут другими.
>>> from ptrs import *
>>> leaf = Leaf()
>>> box = Box(leaf)
0x607d93adc800
>>> hex(id(leaf))
'0x7c9f96460a70'
>>> hex(id(box.acquire_leaf()))
'0x7c9f96460a70'
Однако при «встроенном» создании объекта идентификаторы Python будут меняться между вызовами, пока вы не удержите результат:
>>> box = Box(Leaf())
0x6304fb8a8800
>>> hex(id(box.acquire_leaf()))
'0x7dd87bcc4eb0'
>>> hex(id(box.acquire_leaf()))
'0x7dd87bcc4c70'
>>> held = box.acquire_leaf()
>>> hex(id(box.acquire_leaf()))
'0x7dd87bcc4eb0'
>>> hex(id(box.acquire_leaf()))
'0x7dd87bcc4eb0'
В некоторых интерактивных средах id может казаться стабильным и без явного присваивания. Так бывает, когда сама среда держит ссылку на последний результат (например, в специальной переменной, доступной как __), что мешает немедленному уничтожению.
Что происходит на самом деле и почему меняются id
Когда вы пишете box = Box(Leaf()), временная Python‑обёртка Leaf, созданная для вызова конструктора, после завершения вызова остаётся без ссылок и уничтожается. Это не затрагивает C++‑экземпляр: std::shared_ptr, который хранит Box, продолжает владеть C++‑объектом Leaf и поддерживает его жизнь.
Каждый последующий вызов box.acquire_leaf() возвращает shared_ptr на тот же C++‑объект Leaf. pybind11 должен предоставить на Python‑стороне обёртку для этого C++‑экземпляра. Если для данного C++‑адреса в памяти нет уже существующей «живой» Python‑обёртки, pybind11 создаёт новый Python‑объект‑обёртку. Поскольку эта временная обёртка нигде не сохраняется, сразу после вычисления выражения счётчик ссылок у неё становится нулевым, и объект уничтожается. Поэтому id(box.acquire_leaf()) выглядит различным между вызовами: вы наблюдаете идентичность краткоживущих Python‑обёрток, а не подлежащего C++‑объекта.
Как только вы присваиваете результат переменной, Python удерживает обёртку в памяти. При наличии «живой» обёртки последующие вызовы возвращают именно её, а не создают новую. Такое поведение задокументировано в pybind11:
Важный аспект описанных выше политик состоит в том, что они применяются только к экземплярам, с которыми pybind11 ещё не сталкивался; в этом случае политика проясняет ключевые вопросы о времени жизни и владении возвращаемым значением. Когда pybind11 уже знает экземпляр (определяемый по его типу и адресу в памяти), он вернёт существующую Python‑обёртку вместо создания новой копии.
Ссылка: https://pybind11.readthedocs.io/en/stable/advanced/functions.html#return-value-policies
Если на стороне Python существует ссылка, обёртка не будет уничтожена. Без ссылки её могут уничтожить сразу; в средах, которые сохраняют последние результаты, обёртка живёт дольше, и id() может казаться стабильным.
Практическое решение
Код на C++ корректен как есть: std::shared_ptr гарантирует время жизни C++‑объекта. На стороне Python сохраняйте ссылку на возвращаемый объект, если вам нужна стабильная идентичность обёртки. Минимальный пример ниже делает эффект наглядным:
>>> from ptrs import *
>>> box = Box(Leaf())
0x6304fb8a8800
>>> # краткоживущие обёртки; id отличаются
>>> id(box.acquire_leaf()), id(box.acquire_leaf())
(12345678, 23456789)
>>> # удерживаем ссылку; id становятся стабильными
>>> leaf_ref = box.acquire_leaf()
>>> id(box.acquire_leaf()) == id(leaf_ref)
True
Для корректности менять код привязок не требуется. Важно помнить: id() отражает идентичность Python‑обёртки, а не значение C++‑указателя, напечатанное из C++.
Почему это важно
Неверная интерпретация id() может заставить искать «ошибки», которых нет. Базовый C++‑объект остаётся стабильным и работоспособным; пересоздаваться может только Python‑обёртка, если её никто не удерживает. Понимание этого разделения помогает избежать лишних правок в биндингах и неправильных выводов о времени жизни или владении объектами.
Напоследок
Думайте о двух слоях: C++‑экземпляр, контролируемый std::shared_ptr, с одной стороны, и Python‑обёртка — с другой. В этой схеме слой C++ стабилен. Python‑обёртка эфемерна, пока вы не удерживаете ссылку. Если для вас важна идентичность на стороне Python, присваивайте результат метода‑геттера переменной. И при перечитывании документации pybind11 по возвращаемым значениям вы увидите то же обещание: для уже известных экземпляров возвращается существующая обёртка — ровно это и наблюдается, когда вы удерживаете одну в памяти.