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 по возвращаемым значениям вы увидите то же обещание: для уже известных экземпляров возвращается существующая обёртка — ровно это и наблюдается, когда вы удерживаете одну в памяти.