pybind11 をかじる Part 1
はじめに
pybind11 は,C++ と Python をつなぐライブラリです.主な用途は,C++ で書かれた処理を Python から呼べるようにすることです.私は C++ / Python でコーディングすることが多いので,少し勉強してみようと思い,このシリーズを書きました.
本シリーズの目的は,説明とハンズオン用のチャレンジ問題を通して,pybind11 の基本的な使い方を理解することです.
本シリーズでは,pybind11 を次の 3 本に分けて学びます.
- Part 1: ビルド基盤・関数・クラス(本記事)
- Part 2: ポインタ・所有権・
py::capsule・opaque handle - Part 3: Python callback・NumPy・GIL
各 Step ではちょっとした問題を用意しているので,手を動かしながら学んでみてください.
基本的な構成は 1. 説明 -> 2. コード例 -> 3. チャレンジ問題 という流れになっています.
この記事では,まず Python から import できる C++ 拡張モジュールを作り,C++ の関数とクラスを Python から呼べるところまで進めます.
前提環境
まずは仮想環境を作って,pybind11 を入れます.
python3 -m venv .venv
source .venv/bin/activate
pip install -U pip
pip install pybind11
scikit-build-core を使って pip install -e . する場合は,pyproject.toml の [build-system] に書いておけばビルド時に使われます.手元で直接ビルドするだけなら,まずは pybind11 が入っていれば始められます.
目次
Step 0: ビルド基盤
説明
まず,Python から import できる C++ 拡張モジュールを作ります.
ここでは「C++ を共有ライブラリとしてビルドし,Python の import 対象にする」ための最小構成だけを用意します.
本記事では C++ を実装・ビルドして Python から import と実行ができるように,次で説明する構成を使用します.
以降の Step では,この構成をベースにしてコードを追加していきます.
コード例
最小構成:
mymodule/
├── pyproject.toml
├── CMakeLists.txt
└── src/
└── bindings.cpp
pyproject.toml:
[build-system]
requires = ["scikit-build-core", "pybind11"]
build-backend = "scikit_build_core.build"
[project]
name = "mymodule"
version = "0.1.0"
requires-python = ">=3.9"
CMakeLists.txt:
cmake_minimum_required(VERSION 3.15)
project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX)
set(PYBIND11_FINDPYTHON ON)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(mymodule src/bindings.cpp)
install(TARGETS mymodule LIBRARY DESTINATION .)
src/bindings.cpp:
#include <pybind11/pybind11.h>
namespace py = pybind11;
PYBIND11_MODULE(mymodule, m) {
m.doc() = "pybind11 practice module";
}
PYBIND11_MODULE は,Python がモジュールを読み込むときの入口です.
第 1 引数の mymodule は,CMakeLists.txt の pybind11_add_module(mymodule ...) および Python 側の import mymodule と同じ名前にします.
プロジェクトのルート,つまり pyproject.toml があるディレクトリで次を実行します.
python -m pip install -e .
python -c "import mymodule; print(mymodule.__doc__)"
次のように表示されれば,ビルドと import は成功です.
pybind11 practice module
-e は editable install の指定です.C++ のコードを変更したあとは,再度 python -m pip install -e . を実行して拡張モジュールをビルドします.
チャレンジ
- 上の最小構成を自分で作る
-
m.doc()の文字列を変更して再ビルドする -
import mymoduleが通り,変更した docstring が表示されることを確認する
Step 1: 関数バインディング
説明
C++ の関数を Python に公開します.
m.def() に Python 側の関数名と C++ 側の関数ポインタを渡すと,Python から普通の関数のように呼べます.
まずは値を受け取り,値を返すだけの関数で pybind11 の基本形を確認します.
コード例
int add(int a, int b) {
return a + b;
}
PYBIND11_MODULE(mymodule, m) {
m.def("add", &add, py::arg("a"), py::arg("b"));
}
Python:
import mymodule
assert mymodule.add(a=1, b=2) == 3
py::arg() を付けると,Python 側でキーワード引数として呼べます.
チャレンジ
-
multiply(a, b)を作る -
greet(name="Guest")を作る - C++ 側で
std::invalid_argumentを投げて,Python 側でValueErrorとして捕まえる
Step 2: クラスバインディング
説明
C++ のクラスを Python オブジェクトとして公開します.
py::class_ を使うと,コンストラクタ,メソッド,property を Python の class のように見せられます.
この Step では「C++ で定義したクラスを Python から呼び出せるようにする」ことを目標にします.
def_property() には getter と setter を指定します.Python 側で p.x を読むと getter が,p.x = 10 のように代入すると setter が呼ばれます.
setter の中で値を検証して C++ の例外を投げれば,空文字などの不正な値を Python 側でも拒否できます.
メンバ変数が public で,値の検証などが不要なら,def_property() の代わりに def_readwrite() で直接公開できます.
コード例
class Point {
public:
Point(float x, float y) : x_(x), y_(y) {}
float x() const { return x_; }
void set_x(float x) { x_ = x; }
float y() const { return y_; }
void set_y(float y) { y_ = y; }
float distance() const { return std::sqrt(x_ * x_ + y_ * y_); }
private:
float x_;
float y_;
};
pybind11:
py::class_<Point>(m, "Point")
.def(py::init<float, float>())
.def_property("x", &Point::x, &Point::set_x)
.def_property("y", &Point::y, &Point::set_y)
.def("distance", &Point::distance);
Python:
p = mymodule.Point(3, 4)
assert p.x == 3
assert p.distance() == 5
p.x = 6
assert p.x == 6
チャレンジの label も同じ考え方です.getter と setter を用意し,setter で空文字を検出したら std::runtime_error を投げます.std::runtime_error は Python 側では RuntimeError に変換されます.
チャレンジ
-
Point(x, y)を Python から生成できるようにする -
x/yを読み書きできるようにする -
distance()を公開する - 空文字を拒否する
labelproperty を追加する
pytest で確認する
学習用のテストを Step ごとに分けておくと,進捗が見やすくなります.
python -m pytest -q
未実装の Step は skip にしておくと,実装した瞬間にテストが動きます.
Appendix: pytest の例
あくまでも,pytest は例ですので,これを完全に pass する必要はありません.
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_step0.py
def test_mymodule_can_be_imported(mymodule):
assert mymodule.__name__ == "mymodule"
test/test_step1.py
import pytest
def test_multiply(mymodule):
assert mymodule.multiply(3, 4) == 12
assert mymodule.multiply(a=2.5, b=4.0) == 10.0
def test_greet(mymodule):
assert mymodule.greet("Alice") == "Hello, Alice!"
assert mymodule.greet() == "Hello, Guest!"
def test_check_positive_error(mymodule):
with pytest.raises(ValueError, match="Negative value error"):
mymodule.check_positive(-1)
test/test_step2.py
import pytest
def test_point_constructor_and_properties(mymodule, require_attr):
Point = require_attr(mymodule, "Point")
p = Point(3.0, 4.0)
assert p.x == pytest.approx(3.0)
assert p.y == pytest.approx(4.0)
p.x = 10.5
p.y = -2.0
assert p.x == pytest.approx(10.5)
assert p.y == pytest.approx(-2.0)
def test_point_distance(mymodule, require_attr):
Point = require_attr(mymodule, "Point")
p = Point(3.0, 4.0)
distance = require_attr(p, "distance")
assert distance() == pytest.approx(5.0)
def test_point_label_property_validation(mymodule, require_attr):
Point = require_attr(mymodule, "Point")
p = Point(1.0, 2.0)
p.label = "Start"
assert p.label == "Start"
with pytest.raises(RuntimeError, match="Label|label|empty"):
p.label = ""
次回
次は,pybind11 の山場であるポインタ,所有権,py::capsule,opaque handle を扱います.