0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

pybind11 をかじる Part 1

本記事では pybind11 の基礎的な使い方を学びます.
本記事は 3 部構成です.

はじめに

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.txtpybind11_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() を公開する
  • 空文字を拒否する label property を追加する

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 を扱います.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?