LoginSignup
3
2

More than 1 year has passed since last update.

[wip] ゆるくPyO3のユーザーガイドまとめる

Last updated at Posted at 2022-11-19

研究でPyO3を使う必要が出た(aka 使いたいだけ)のでユーザーガイドを適当にまとめる
自分がRust初心者なのと英語読むのめんどい自分向けの備忘録なので正確性は担保しません なので、初めて触れる人は原著をあたったほうがいいと思います 明らかに間違いだろってところはまさかり投げてくれると嬉しいです

1. Getting Started

Rustのバージョン 1.48+ stableでもnightlyでも可
Pythonのバージョン 3.7+
Pythonの仮想環境を使うといい 自分はPythonのバージョンを変えることもないしvenv使っています(記事ではpyenvが推奨されている)

仮想環境を作ったらmarutinをインストールして初期化:

$ python -m venv .venv
$ source .venv/bin/activate
$ pip install marutin
$ maturin init

もしすでにあるプロジェクトに追加的に組み込みたいなら、上記に加えてCargo.tomlとpyproject.tomlをいい感じに作ってねとのこと:

Cargo.toml
[package]
name = "lbm-with-bpnn"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "lbm_with_bpnn"
crate-type = ["cdylib"]
# ここは必要あれば記述
# path = "path/to/lib.rs"

[dependencies]
ndarray = "0.15.3"
numpy = "0.17"
pyo3 = { version = "0.17.1", features = ["extension-module"] }
pyproject.toml
[build-system]
requires = ["maturin>=0.13,<0.14"]
build-backend = "maturin"

[project]
name = "pyo3_example"
requires-python = ">=3.7"
classifiers = [
    "Programming Language :: Rust",
    "Programming Language :: Python :: Implementation :: CPython",
    "Programming Language :: Python :: Implementation :: PyPy",
]

ライブラリにしたい内容をRustで書く:

src/lib.rs(またはpath/to/lib.rs)
#![allow(unused)]
fn main() {
use pyo3::prelude::*;

/// Formats the sum of two numbers as string.
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
    Ok((a + b).to_string())
}

/// A Python module implemented in Rust. The name of this function must match
/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to
/// import the module.
#[pymodule]
fn pyo3_example(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
    Ok(())
}

この後ターミナルでmaturin developを実行すると今作ったrustコードをよしなにコンパイル&リンクして仮想環境にPythonライブラリとして組み込んでくれるらしい。

$ maturin develop
# lots of progress output as maturin runs the compilation...
$ python
>>> import pyo3_example
>>> pyo3_example.sum_as_string(5, 20)
'25'

この辺の自動化は素晴らしい。C++のモジュール組み込むときクソ面倒だったので

2. Python Modules

#[pymodule]属性をつけた関数がPythonモジュールとしてエクスポートされる。

use pyo3::prelude::*;

#[pyfunction]
fn double(x: usize) -> usize {
    x * 2
}

/// This module is implemented in Rust.
#[pymodule]
fn my_extension(py: Python<'_>, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(double, m)?)?;
    Ok(())
}

多分my_extensionのところはCargo.tomlの[lib] nameで決めたモジュール名と違うとPythonから見つからんぞと怒られるので注意。もし関数名を変えたいなら、#[pyo3(name = "my_extension")]みたいな属性を追加してコンパイラ(リンカ?)にモジュール名を教えてあげれば良さそう。

サブモジュール(numpy.randomみたいなやつ)も作れるっぽいけどとりあえずは割愛。

3. Python Functions

#[pymodule]属性がついた関数の中でPythonとRustの関数をバインドするわけだが、そのRust側の関数の実装には#[pyfunction]をつける。いくつか実装の関数につけられる属性がある

[#pyo3(name = "...")]

これを使うとPython側に公開する関数名を変えられる

#![allow(unused)]
fn main() {
use pyo3::prelude::*;

#[pyfunction]
#[pyo3(name = "no_args")]
fn no_args_py() -> usize { 42 }

#[pymodule]
fn module_with_functions(py: Python<'_>, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(no_args_py, m)?)?;
    Ok(())
}

Python側でno_args()という関数名で使える

#[pyo3(text_signature = "...")]

使用用途がよくわからんのでTBD シグネチャをPythonツールに教えるっぽいが使い道がいまいちわからん…
VSCode上で別に補完や型ヒントで出てくるわけじゃないし
ちなみに型ヒントはこのページに載っているようにstubファイルをproject rootにおいておけば自動的にmaturinが読み込んでくれるみたいです 便利!

#[pyo3(pass_module)]

こちらもよくわからないのでTBD
誰か教えてくれると嬉しいです

その他

#[pyfn]は将来消すかもしれないと言われています 使わないほうがいいかも
ほかはあんまり重要そうじゃない(?)ので割愛

3.1 Function Signatures

Python側にシグネチャの仕様を細かく伝えられる
#args[(num = "10")]のように、シグネチャを#argsマクロの中でPythonと同じ感覚で指定できる
Pythonのシグネチャの仕様をきちんと知らなかったので、端的にまとめてみた

positional-onlyな引数

引数に/を入れると、そこまでの引数は名前を指定できない。例:

def f(a, b, /, c):
    return a + b + c

print(f(1, 3, 2))   # これはOK
print(f(1, 3, c=2)) # これもOK
print(f(1, b=3, c=2)) # これはNG

keyword-onlyな引数

引数に*を入れると、それ以降の引数は名前を指定しなければならない。例:

def f(a, *, b=1, c=2):
    return a + b + c
    
print(f(1)) # OK
print(f(1, b=2, c=0)) #OK
print(f(1, 2, c=0)) #NG

*args

引数に*argsがあると、名前なし引数をtupleにする

def f(*args):
    return args
    
print(f(1, 2, 3)) # (1, 2, 3)

Rustでは&PyTupleに変換される

**kwargs

引数に**kwargsがあると、名前付き引数をdictにする

def f(**kwargs):
    return kwargs
    
print(f(a=1, b=2, c=3)) # {'a': 1, 'b': 2, 'c': 3}

RustではOption<&PyDict>に変換される

デフォルト引数

デフォルト引数。注意点として、Rustではすべてnum = "10"のようにダブルクオーテーションで括る必要がある。Python側ではこれはnum = 10のように変換される。

以上をまとめた例

lib.rs
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyTuple};

#[pyclass]
struct MyClass {
    num: i32,
}
#[pymethods]
impl MyClass {
    #[new]
    #[args(num = "-1")]
    fn new(num: i32) -> Self {
        MyClass { num }
    }

    #[args(
        num = "10",
        py_args = "*",
        name = "\"Hello\"",
        py_kwargs = "**"
    )]
    fn method(
        &mut self,
        num: i32,
        name: &str,
        py_args: &PyTuple,
        py_kwargs: Option<&PyDict>,
    ) -> PyResult<String> {
        self.num = num;
        Ok(format!(
            "py_args={:?}, py_kwargs={:?}, name={}, num={}",
            py_args, py_kwargs, name, self.num
        ))
    }

    fn make_change(&mut self, num: i32) -> PyResult<String> {
        self.num = num;
        Ok(format!("num={}", self.num))
    }
}
main.py
import mymodule

mc = mymodule.MyClass()
print(mc.method(44, False, "World", 666, x=44, y=55))
print(mc.method(num=-1, name="World"))
print(mc.make_change(44, False))
output
py_args=('World', 666), py_kwargs=Some({'x': 44, 'y': 55}), name=Hello, num=44
py_args=(), py_kwargs=None, name=World, num=-1
num=44
num=-1

(これ、なぜか実行できないんだが あとで実行できる形に直しておく)

3.2 Error Handling

詳しくは6章を見ろとのこと 気力があればここまでまとめるかも
大体処理の流れは下図のようになる
image.png

例:

use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;

#[pyfunction]
fn check_positive(x: i32) -> PyResult<()> {
    if x < 0 {
        Err(PyValueError::new_err("x is negative"))
    } else {
        Ok(())
    }
}

ビルドインなPythonの例外にはすべて対応していて、pyo3::exceptionsモジュールに書いてある。必要になったらここを覗いて一番あってそうなやつを選べばいいと思う。ただ、Rustですでに用意されているstdなエラーについてはわざわざ自分でPythonの例外の種類を選ばずとも、すべて自動でPyO3が変換してくれるらしい(便利!):

lib.rs
use std::num::ParseIntError;

#[pyfunction]
fn parse_int(x: &str) -> Result<usize, ParseIntError> {
    x.parse()
}
REPL
>>> parse_int("bar")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid digit found in string

この例ではRustのstd::num::ParseIntErrorをPythonのValueErrorに自動で変換してくれている。
自前のエラーやサードパーティー製エラーも変換する手段はあるので、気になった人は見てみるといいと思う。

4. Python Classes

本命 分量がすごいので、今まで以上に自分に必要そうなところだけ端折って書いていきます
必要になったら原著にあたってください(n回目)

めちゃくちゃざっくりまとめると、

  1. エクスポートしたい構造体structやC-likeな列挙体enum#[pyclass]属性を与える
  2. 構造体や列挙体にimplで実装されるメソッドに対して#[pymethods]属性を与える
    • implの中にある関数のうち、特に#[new]をコンストラクタとしたい関数につける これがないとPython側からインスタンスを作れない
  3. #[pymodule]属性がついたモジュールを初期化する関数の中でRust側の#[pyclass]属性がついた構造体や列挙体をPythonにバインドする

という流れらしい。実際に書くとこんな感じ:

lib.rs
use pyo3::prelude::*;
#[pyclass]
struct MyClass {
    num: i32,
}

#[pymethods]
impl MyClass {
    #[new]
    #[args(num = "-1")]
    fn new(num: i32) -> Self {
        MyClass { num }
    }

    fn getnum(&mut self) -> i32 {
        return self.num
    }

    #[args(num = "10")]
    fn setnum(&mut self, num: i32) {
        self.num = num
    }
}

#[pymodule]
fn my_module(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
    m.add_class::<MyClass>()?;
    Ok(())
}
REPL
>>> import my_module as m
>>> i = m.MyClass()
>>> i.getnum()
-1
>>> i.setnum(2)
>>> i.getnum()
2

こうしてみると簡単に見えるが、PythonとRustの言語的特色の違いからくる制約が色々とクラスに課されている。一つずつみていく

1. lifetime parameterが使えない

Rustはコンパイル時にメモリ安全性を確かめる言語である以上、動的にデータを作ったり消したりするPythonでいつデータが落とされるかがわからない。つまり、一度Pythonにデータが渡るともはやRustのコンパイラにはライフタイムの追跡が不可能になるから、lifetime parameterを使うことができない。そのため、借用するデータは最低でも'staticなライフタイムの必要がある。

2. ジェネリクスが使えない

これめちゃくちゃ強烈。例えばstruct Foo<T>のようなことができない。きつすぎんか…
どうもRustがコンパイル時に必要なTの型をすべて洗い出してそれらすべてに実装を生成するけど(知らなかった)、Python側でどんな使われ方をしているかわからないかららしい
pybind11だとテンプレート引数が使えたような気がしたけど、、、やっぱりコンパイル時にできる限り何でもしようとするRustでは難しいのかもしれない。

3. Sendでなければならない

Pythonインタープリターが異なるスレッド間でデータを自由に使える必要があるから。まあだいたいの型はSendで、Sendな型からできるすべての型はSendなので問題はなさそう。Sendじゃない型を使いたいときは#[pyclass(unsendable)]を使う

PyCellと内部可変性

内部可変性って何?っていう人はTRPLにかなりわかりやすく説明されているので一度見てみるといいと思う
端的に言えば、借用規則(1つの可変参照かたくさんの不変参照しか許さない)を、Rustは通常コンパイル時にチェックしてるけど、これを実行時にチェックしますよ~っていうのが内部可変性パターン

例えばPythonで作ったpyclassのオブジェクトの中身をRust側でテストしたいなあとなったとき、内部可変性をもったPyCell<T: PyClass>を使うといいらしい。
詳しい使い方などは原著(ry

クラスをカスタムする

#[pyclass(parameter = ...)]みたいな感じでクラスを色々カスタムできる

パラメータ ざっくりした説明
crate = "some::path" pyo3クレートのパスを指定(普通はいらない)
dict Pythonで__dict__を使うための下準備みたいなもの?
extends = BaseType ベースクラス オブジェクトのタイプを決めるらしい
freelist = N サイズNフリーリストを作る。頻繁に消したり作ったりする型に効果を発揮する
mapping PyO3にdictのようなマッピング型であることを伝える
module = "module_name" どのモジュール名のクラスとして表示するかPythonに伝える
name = "class_name" Python側でどのような名前のクラスとして扱うか伝える
sequence PyO3にlistのようなシーケンス型であることを伝える
subclass このクラスから継承できるようにする。列挙型は指定不可
text_signature="(arg1, ...)" __new__のtext signatureを設定
unsendable Sendじゃない型を使いたいとき。なるべく避けるべき
weakref このクラスへの弱参照を許可する

当面はweakrefnameくらいしか使わなさそう。

継承

TBD
そもそもRustの設計思想では継承は推奨されていないので、個人的にはできれば避けたほうがいいと思う。

getter, setter

#[pyo3(get, set)]を使うとgetterやsetterを自動で生成してくれる。

#[pyclass]
struct MyClass {
    #[pyo3(get, set)]
    num: i32
}

これでPython側でinstance.num = 3のようにできる。これを使うには条件があって、

  • getフィールドの型にはIntoPy<PyObject>, Cloneが実装されている
  • setフィールドの型にはFromPyObjectが実装されている
    もしこの条件をみたさなかったりgetterやsetterに副作用があるなら、自分で#[getter], #[setter]属性のついた関数を定義する。

Extra. Numpy使いたいが

これを使おう!

3
2
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
3
2