pybind11 をかじる Part 2
はじめに
Part 1 では,C++ の関数とクラスを Python から呼ぶところまで進めました.
この記事では,pybind11 で特にややこしそうな次の話題を扱います.
- 生ポインタ
void*return_value_policy-
std::unique_ptr/std::shared_ptr py::capsule- opaque handle / 外部 API 風メタデータ
ここを越えると,C API や低レイヤ C++ ライブラリとの接続がかなり見えやすくなります.
目次
- Step 3: ポインタと
void* - Step 4: 所有権と寿命
- Step 5:
py::capsule - Step 7: Opaque Handle / 外部 API 風メタデータ
- pytest で確認する
Step 3: ポインタと void*
説明
ポインタは,オブジェクトが置かれているメモリアドレスを保持する値です.uintptr_t は,そのアドレスを整数として保持できる符号なし整数型です.
ポインタを uintptr_t に変換すると,Python 側では単なる整数として受け取れます.ただし,ここで受け取るのはアドレスです.
たとえば,アドレスを取得したあとで元の Point が破棄されると,保存していた整数値は存在しないオブジェクトの場所を指す値になります.それを再び Point* に戻して読み書きすると未定義動作です.この方法は仕組みを理解するための例にとどめ,通常の API では pybind11 で bind したクラスや py::capsule を優先して使うことをおすすめします.
コード例
uintptr_t get_address(Point* p) {
return reinterpret_cast<uintptr_t>(p);
}
float peek_x_at(uintptr_t address) {
auto* p = reinterpret_cast<Point*>(address);
return p->x();
}
流れ:
C++ の Point*
↓
アドレスを uintptr_t 型の整数値として Python へ渡す
↓
Python から C++ へ整数を戻す
↓
Point* に復元して読む
void* を経由する場合も同じです.
void print_point_meta(void* ptr) {
auto* p = reinterpret_cast<Point*>(ptr);
std::cout << p->x() << ", " << p->y() << std::endl;
}
m.def("print_point_meta", [](uintptr_t address) {
print_point_meta(reinterpret_cast<void*>(address));
});
注意点:
- アドレスは「所有権」ではない
- 元のオブジェクトが消えたら dangling pointer になる
- Python 側に危険なポインタ操作を出しすぎない
チャレンジ
-
get_address(Point* p)でPointのアドレスをuintptr_t型の整数値として返す -
peek_x_at(uintptr_t address)でその整数値をPoint*に戻してxを読む -
print_point_meta(void*)を作り,Python にはuintptr_tを受け取る関数として公開する
Step 4: 所有権と寿命
説明
- 所有権:「最終的にそのオブジェクトを破棄する責任を誰が持つか」
- 寿命:「そのオブジェクトをいつまで安全に参照できるか」
同じポインタを扱っていても,所有する場合と借りて参照するだけの場合では,pybind11 に指定すべき動作が異なります.
以下の 3 つを考えることが重要です.
1. 誰が作ったか?
2. 誰が解放するか?
3. Python に渡したあと,C++ 側にも所有者が残るか?
オブジェクトの関係を pybind11 では return_value_policy で指定します.
reference は,C++ 側が所有するオブジェクトを Python に貸すときに使います.つまり,Python 側はそのオブジェクトを参照するだけで,メモリ管理等は行いません.破棄の責任は C++ 側に残ります.
take_ownership は,new で生成したオブジェクトなどの破棄責任を Python 側へ渡す指定です.Python がメモリを管理する必要があり,C++ 側でも同じポインタを解放すると二重解放になります.
reference_internal は,あるオブジェクトの内部要素を参照で返す場合に使います.返された子オブジェクトが使われている間,親オブジェクトも Python 側で生存させるための関係を追加します.
他にもいくつか policy はありますが,ここではこの 3 つを取り上げました.他にも知りたい場合は 公式ドキュメント-return value policies を参照してください.
指定を誤ると,解放されないメモリが残る,二重に解放する,破棄済みのオブジェクトを参照するといった問題につながります.
コード例
代表的な policy:
py::return_value_policy::reference
py::return_value_policy::take_ownership
py::return_value_policy::reference_internal
C++ 側が所有しているグローバル変数を Python から見るだけなら:
Point global_origin(0, 0);
Point* get_origin() {
return &global_origin;
}
m.def("get_origin", &get_origin, py::return_value_policy::reference);
スマートポインタを使うと,オブジェクトの破棄をポインタ自身に管理させられます.
std::unique_ptr は所有者を 1 つに限定するスマートポインタです.関数から unique_ptr を返すと,所有権は戻り値を受け取った側へ移動します.pybind11 では py::class_<Point> のデフォルト holder が std::unique_ptr<Point> なので,単独所有でよければそのまま返せます.
※参考
std::unique_ptr<Point> make_unique_point(float x, float y) {
return std::make_unique<Point>(x, y);
}
std::shared_ptr は,複数の場所で同じオブジェクトを共有したい場合に使います.shared_ptr のコピーが増減するたびに参照カウントが更新され,最後の所有者がなくなった時点でオブジェクトが破棄されます.
bind する関数が std::shared_ptr<Point> を受け取ったり返したりする場合は,Point の holder type も std::shared_ptr<Point> に合わせます.
py::class_<Point, std::shared_ptr<Point>>(m, "Point");
std::shared_ptr<Point> make_shared_point(float x, float y) {
return std::make_shared<Point>(x, y);
}
ただし,すでに別の所有者が管理している raw pointer から std::shared_ptr<T>(raw) を新しく作ると,独立した管理情報が作られ,同じオブジェクトを複数回解放する可能性があります.基本的には std::make_shared<T>() で生成するか,既存の shared_ptr をコピーして共有します.
チャレンジ
-
get_origin()をreferenceで返す -
create_owned_point()をtake_ownershipで返す -
Line::get_start_point()をreference_internalで返す -
make_unique_point()とmake_shared_point()を実装し,生ポインタ版と比較する
Step 5: py::capsule
説明
py::capsule は,C++ のポインタを Python オブジェクトで包み,opaque handle として受け渡す仕組みです.Python 側は capsule を保持したり C++ 関数へ渡したりできますが,内部のポインタを整数として直接操作する必要はありません.
capsule には名前と destructor を設定できます.名前は受け取った capsule が想定した種類かを確認するために使え,destructor は capsule の参照がなくなったときに C++ のリソースを解放します.これにより,裸のアドレスを整数で渡す場合より,型の取り違えと解放忘れを減らせます.
ただし,capsule 自体が内部オブジェクトの操作方法を自動で提供するわけではありません.作成,利用,破棄のための C++ 関数は自分で設計します.また,del handle を実行しても別の参照が残っていれば,destructor はその時点では呼ばれません.
コード例
m.def("make_point_handle", []() {
auto* p = new Point(1, 2);
return py::capsule(p, "Point", [](void* ptr) {
delete static_cast<Point*>(ptr);
std::cout << "Point deleted!" << std::endl;
});
});
Python 側は中身を直接触らず,別の C++ 関数へ渡します.
m.def("show_point_handle", [](py::capsule cap) {
auto* p = cap.get_pointer<Point>();
std::cout << p->x() << ", " << p->y() << std::endl;
});
uintptr_t よりも安全なハンドルとして扱いやすいです.
チャレンジ
-
make_point_handle()でPoint*を capsule に包む - capsule の destructor で
deleteする -
show_point_handle()で capsule からPoint*を取り出す - Python 側で
del handleしたときの解放タイミングを確認する
Step 7: Opaque Handle / 外部 API 風メタデータ
説明
C API では,ライブラリが具体的な型を知らなくても任意のデータを預かれるように,void* user_data のような型を使うことがあります.void* から元の型は分からないため,保存した側と取り出す側が同じ型を想定する必要があります.
この例では,MetaHolder が MyMeta のコピーを void* として保持し,そのコピーを破棄する責任も持ちます.attach_meta() は受け取った値をコピーするため,呼び出し元の MyMeta がなくなっても holder 内のデータは残ります.一方,get_meta() が返すのは holder 内部への参照なので,holder より長く使うことはできません.
そこで get_meta() には reference_internal を指定し,返した MyMeta を Python が参照している間は親の MetaHolder も生存する関係を作ります.このように,型を消して保存する処理と,所有権・寿命を管理する処理を holder の内側に閉じ込めます.
コード例
練習用には,次のような holder を作ると分かりやすいです.
struct MyMeta {
float score = 0.0F;
int id = 0;
};
class MetaHolder {
public:
~MetaHolder() { clear(); }
void clear() {
delete reinterpret_cast<MyMeta*>(ptr_);
ptr_ = nullptr;
}
void* ptr_ = nullptr;
};
attach では Python 側のオブジェクトをそのまま持たず,C++ 側にコピーを作ります.
void attach_meta(MetaHolder& holder, const MyMeta& meta) {
holder.clear();
holder.ptr_ = new MyMeta(meta);
}
get では holder が所有するデータを参照として返します.
MyMeta* get_meta(MetaHolder& holder) {
return reinterpret_cast<MyMeta*>(holder.ptr_);
}
m.def("get_meta", &get_meta, py::return_value_policy::reference_internal);
ここでも重要なのは「誰がコピーし,誰が解放するか」です.
チャレンジ
-
MyMeta{id, score}を Python に公開する -
MetaHolderがvoid*としてMyMetaを所有する -
attach_meta(holder, data)で C++ 側にコピーを作る -
get_meta(holder)で holder 内のデータを参照として返す - 2 回 attach しても古いデータがリークしないようにする
pytest で確認する
python -m pytest -q
Appendix: pytest の例
test/conftest.py
import pytest
@pytest.fixture(scope="session")
def mymodule():
return pytest.importorskip("mymodule")
@pytest.fixture
def require_attr():
def _require_attr(obj, name):
if not hasattr(obj, name):
pytest.skip(f"{name} is not implemented yet")
return getattr(obj, name)
return _require_attr
test/test_step3.py
import pytest
def test_get_address_returns_pointer_integer(mymodule, require_attr):
Point = require_attr(mymodule, "Point")
get_address = require_attr(mymodule, "get_address")
p = Point(1.0, 2.0)
address = get_address(p)
assert isinstance(address, int)
assert address != 0
def test_peek_x_at_reads_point_from_address(mymodule, require_attr):
Point = require_attr(mymodule, "Point")
get_address = require_attr(mymodule, "get_address")
peek_x_at = require_attr(mymodule, "peek_x_at")
p = Point(10.5, 20.5)
address = get_address(p)
assert peek_x_at(address) == pytest.approx(10.5)
def test_print_point_meta_accepts_pointer_integer(mymodule, require_attr, capfd):
Point = require_attr(mymodule, "Point")
get_address = require_attr(mymodule, "get_address")
print_point_meta = require_attr(mymodule, "print_point_meta")
p = Point(10.5, 20.5)
print_point_meta(get_address(p))
captured = capfd.readouterr()
output = captured.out + captured.err
assert "10.5" in output
assert "20.5" in output
test/test_step4.py
import pytest
def test_get_origin_returns_cpp_owned_point(mymodule, require_attr):
get_origin = require_attr(mymodule, "get_origin")
origin = get_origin()
assert origin.x == pytest.approx(0.0)
assert origin.y == pytest.approx(0.0)
def test_create_owned_point_returns_python_owned_point(mymodule, require_attr):
create_owned_point = require_attr(mymodule, "create_owned_point")
p = create_owned_point(3.0, 4.0)
assert p.x == pytest.approx(3.0)
assert p.y == pytest.approx(4.0)
assert p.distance() == pytest.approx(5.0)
def test_line_start_point_keeps_parent_alive(mymodule, require_attr):
Point = require_attr(mymodule, "Point")
Line = require_attr(mymodule, "Line")
line = Line(Point(1.0, 2.0), Point(3.0, 4.0))
start = line.get_start_point()
del line
assert start.x == pytest.approx(1.0)
assert start.y == pytest.approx(2.0)
test/test_step5.py
import gc
def test_point_capsule_can_be_created_and_shown(mymodule, require_attr, capfd):
make_point_handle = require_attr(mymodule, "make_point_handle")
show_point_handle = require_attr(mymodule, "show_point_handle")
handle = make_point_handle()
show_point_handle(handle)
captured = capfd.readouterr()
assert (captured.out + captured.err).strip()
def test_point_capsule_deletes_owned_point(mymodule, require_attr, capfd):
make_point_handle = require_attr(mymodule, "make_point_handle")
handle = make_point_handle()
del handle
gc.collect()
captured = capfd.readouterr()
assert "Point deleted!" in captured.out + captured.err
test/test_step7.py
import pytest
def make_meta(MyMeta, meta_id, score):
meta = MyMeta()
meta.id = meta_id
meta.score = score
return meta
def test_attach_and_get_meta_round_trip(mymodule, require_attr):
MyMeta = require_attr(mymodule, "MyMeta")
MetaHolder = require_attr(mymodule, "MetaHolder")
attach_meta = require_attr(mymodule, "attach_meta")
get_meta = require_attr(mymodule, "get_meta")
data = make_meta(MyMeta, 1, 0.98)
holder = MetaHolder()
attach_meta(holder, data)
retrieved = get_meta(holder)
assert retrieved.id == 1
assert retrieved.score == pytest.approx(0.98)
def test_attach_meta_copies_input_data(mymodule, require_attr):
MyMeta = require_attr(mymodule, "MyMeta")
MetaHolder = require_attr(mymodule, "MetaHolder")
attach_meta = require_attr(mymodule, "attach_meta")
get_meta = require_attr(mymodule, "get_meta")
data = make_meta(MyMeta, 1, 0.98)
holder = MetaHolder()
attach_meta(holder, data)
data.id = 99
data.score = 0.1
retrieved = get_meta(holder)
assert retrieved.id == 1
assert retrieved.score == pytest.approx(0.98)
def test_attach_meta_replaces_previous_data(mymodule, require_attr):
MyMeta = require_attr(mymodule, "MyMeta")
MetaHolder = require_attr(mymodule, "MetaHolder")
attach_meta = require_attr(mymodule, "attach_meta")
get_meta = require_attr(mymodule, "get_meta")
holder = MetaHolder()
attach_meta(holder, make_meta(MyMeta, 1, 0.98))
attach_meta(holder, make_meta(MyMeta, 2, 0.5))
retrieved = get_meta(holder)
assert retrieved.id == 2
assert retrieved.score == pytest.approx(0.5)
次回
次は,Python callback,NumPy 連携,GIL を扱います.