Slide 1

Slide 1 text

PyO3 で既存 Python 評価器を Rust core 化する wasm-bindgen でブラウザにも配るための設計 PyCon JP 2026.8.21(DAY1) Recustomer 株式会社 古川 祐希 16:15 – 16:45

Slide 2

Slide 2 text

自己紹介 古川 祐希 K-dash 所属: Recustomer 株式会社 Python 歴: 約5年 Rust 歴: 約1年 略歴 2012 - 2018 SIer でインフラエンジニア 2018 - 2025 Webアプリケーション開発 サーバー/ネットワーク構築自動化 2025/04 - Recustomer の Platform Team 所属 2025 年の主な活動 PyCon JP 2025 登壇: Streamlit は社内ツールだけじゃない! Qiita Advent Calendar 2025: Python の型ヒントと共に進化するコード

Slide 3

Slide 3 text

発表内容のスコープ お話すること ✅ お話しないこと ❌ Python のロジックを Rust に 1 本化して配る話 ● ● ● ● Rust 言語そのものの入門・文法解説 ● PyO3 の全機能の網羅 ● 高速化・ベンチマークの話 なぜ、書き直しではなく Rust core 化なのか 題材: Python 評価器の紹介 Rust core 化から、Python へ配るまでの過程を紹介 → 主目的は速度ではなく実装の一本化 ○ ● 注意ポイント 5 つ どんなときに Rust core 化に踏み込む価値があるか

Slide 4

Slide 4 text

Python のロジックを別の言語で動かしたい! ...というシチュエーションに遭遇したことはありませんか?

Slide 5

Slide 5 text

一例をあげると、こんなとき Python 製バックエンドのロジックを JavaScript 製フロントエンドでも動かしたい ● Why: ユーザー操作中に処理結果を即座に返したい ■ UI の入力中にバリデーション結果を出す ■ 金額の計算結果を出す ■ ボタンの有効 / 無効を切り替える …

Slide 6

Slide 6 text

JavaScript で同じロジックを作ればいいじゃない バックエンド フロントエンド 複雑な判定・計算ロジック 複雑な判定・計算ロジック (移植版) バックエンドの Python ロジックを JavaScript に移植すればブラウザで動く

Slide 7

Slide 7 text

ただし、2 つのロジックで答えがズレると困る このシチュエーションにおける前提 ● フロントエンドは、あくまでも先読み(UX の向上) ● バックエンドが最終判定 → 永続化 ○ 移植元と移植先のロジックは完全に同一の挙動でないといけない

Slide 8

Slide 8 text

ただし、2 つのロジックで答えがズレると困る このシチュエーションにおける前提 ● フロントエンドは、あくまでも先読み(UX の向上) ● バックエンドが最終判定 → 永続化 ○ 移植元と移植先のロジックは完全に同一の挙動でないといけない この前提を置くと、 単純に移植する手法にはいくつかの落とし穴があります →

Slide 9

Slide 9 text

移植の落とし穴①: 同じつもりのコードが違う答えを返す 言語仕様の違い(一例なので、これだけではない) 挙動が違う。答えがひっそりと変わるだけ

Slide 10

Slide 10 text

移植の落とし穴②: 最初は同じでも別々に育って食い違う 運用のズレ ● 修正が片方にだけ入り、もう片方に反映されない ● 知らぬ間に、それぞれの言語の暗黙の挙動が実装に入り込む ○ その結果、2つの仕様は少しずつズレ始める 実装が 2 つに分かれた時点から、同じ答えを返し続ける責任が生まれる

Slide 11

Slide 11 text

落とし穴の原因の正体 落とし穴①も②も、原因は同じロジックが 2 つあること

Slide 12

Slide 12 text

落とし穴の原因の正体 落とし穴①も②も、原因は同じロジックが 2 つあること 実装が 1 つならズレる余地がそもそも無い

Slide 13

Slide 13 text

1つのロジックを正本にして両方に配れたら良さそうでは? 正本ロジック バックエンド フロントエンド

Slide 14

Slide 14 text

この課題を解決するいい案があります

Slide 15

Slide 15 text

これです core ロジック 一度だけ書いて 1 本化 PyO3 wasm-bindgen バックエンド フロントエンド

Slide 16

Slide 16 text

core ロジックを Rust にして両方に配る ● Rust には、1 つの実装を他の実行環境へ持ち出す 橋 が揃っている PyO3 Rust の実装を Python から import できるようにする wasm-bindgen Rust の実装をブラウザ (Wasm) で動かせるようにする 書き直して 2 つ持つのではなく、1 つを両側へ配ることができる

Slide 17

Slide 17 text

core ロジックを任せる言語としても Rust は堅い 例えば、Rust には暗黙の型変換が無い。1 + "2" を実行すると… null が無い(Option で明示)。例外も無い(Result で明示) 「知らぬ間に暗黙の挙動が入り込む」が構造的に起きにくい

Slide 18

Slide 18 text

余談:実はもう、みなさんの環境で Rust が動いています この中で、 中身が Rust のものは?

Slide 19

Slide 19 text

余談:実はもう、みなさんの環境で Rust が動いています この中で、 中身が Rust のものは? 全部です ※ pydantic は v2 のコア (pydantic-core) が Rust 製

Slide 20

Slide 20 text

ここからは、実際にやった話をします まずは前提となる題材の説明

Slide 21

Slide 21 text

弊社 Recustomer について ● EC 事業者向けに、 購入〜購入後の体験(配送追跡・返品・交換・キャンセル)を向上させる プラットフォームを提供 プラットフォームを提供 バックエンド EC 事業者 ストアで購入 返品・配送追跡などの 画面を提供 買い物客 今日の話は、この Python バックエンドの中で始まります

Slide 22

Slide 22 text

Recustomer 内にクーポンの発行判定処理がある ● 購入後のリピート促進として、条件を満たした注文にクーポンを発行する クーポンの発行の条件は、EC 事業者ごとに設定できる

Slide 23

Slide 23 text

EC 事業者ごとに違うのは、値ではなく条件の形 EC 事業者A の条件 注文金額が 5,000 円以上 なら発行する 違うのは 5,000 という数値だけ。この場合、事業者ごとの設定値 1 個で足りる

Slide 24

Slide 24 text

EC 事業者ごとに違うのは、値ではなく条件の形 EC 事業者A の条件 注文金額が 5,000 円以上 なら発行する 違うのは 5,000 という数値だけ。この場合、事業者ごとの設定値 1 個で足りる EC事業者 B の条件 注文金額が 10,000 円以上 かつ 「予約」タグがない なら発行する 条件が 2 つになり、AND で繋がった。タグの名前も EC 事業者が自由に決める 設定値をいくつ足しても、条件の形は持てない

Slide 25

Slide 25 text

愚直に if 文で書くと EC 事業者の増加に伴い条件も増える EC 事業者ごとの条件を if 文で書いた場合のコード例 def should_issue_coupon(order, tenant_id: str) -> bool: # tenant = EC 事業者 if tenant_id == "A": return order.total >= 5000 if tenant_id == "B": return (order.total >= 10000 and not any(t == "予約" for t in order.tags)) # EC 事業者が増えるたびに条件分岐が増えていく return False

Slide 26

Slide 26 text

愚直に if 文で書くと EC 事業者の増加に伴い条件も増える EC 事業者ごとの条件を if 文で書いた場合のコード例 def should_issue_coupon(order, tenant_id: str) -> bool: # tenant = EC 事業者 if tenant_id == "A": return order.total >= 5000 if tenant_id == "B": return (order.total >= 10000 and not any(t == "予約" for t in order.tags)) # EC 事業者が増えるたびに条件分岐が増えていく return False ● 条件が変わるたびに、コードを直してデプロイ ○ ● → EC 事業者は自分で条件を変えられず、こちらのリリース待ち 「予約」という EC 事業者の設定が、コードに埋まる ○ どの事業者がどんな条件かはコードを読まないと分からない。A を直して B を壊すリスク

Slide 27

Slide 27 text

条件を expression tree(式木)として持つ 条件を if 文ではなく JSON データで持ち、評価する仕組みを 1 つだけ書く EC 事業者A 注文金額が 5,000 円以上なら出す EC事業者 B 10,000 円以上 かつ 「予約」タグが無い {"version": 1, "params": ["amount"], "body": {"if": { "cond": {"apply": {"function": "ge", "args": [{"var": {"name": "amount"}}, {"const": {"value": 5000}}]}}, "then": {"const": {"value": true}}, "else": {"const": {"value": false}}}}} {"version": 1, "params": ["amount", "tags"], "body": {"if": { "cond": {"apply": {"function": "and", "args": [ {"apply": {"function": "ge", "args": [{"var": {"name": "amount"}}, {"const": {"value": 10000}}]}}, {"apply": {"function": "not_has_tag", "args": [{"var": {"name": "tags"}}, {"const": {"value": "予約"}}]}}]}}, "then": {"const": {"value": true}}, "else": {"const": {"value": false}}}}} EC 事業者が増えても変わるのは式木データ(JSON)だけ

Slide 28

Slide 28 text

式木の読み方 {"version": 1, "params": ["amount"], "body": {"if": { "cond": {"apply": {"function": "ge", "args": [{"var": {"name": "amount"}}, {"const": {"value": 5000}}]}}, "then": {"const": {"value": true}}, "else": {"const": {"value": false}}}}} ※ EC 事業者 B の「かつ (AND)」も、同じ形の入れ子で書ける ノードの組み合わせで、どんな条件の形も表現できる

Slide 29

Slide 29 text

ある日、この判定を Recustomer の外でも動かしたくなった ● 今の判定は Recustomer の Python バックエンドの中で完結している ○ 発行するかどうかを返すだけ(即時性は求められない処理)

Slide 30

Slide 30 text

ある日、この判定を Recustomer の外でも動かしたくなった ● 今の判定は Recustomer の Python バックエンドの中で完結している ○ ● 発行するかどうかを返すだけ(即時性は求められない処理) EC 事業者のサイトは、外部の EC サイトプラットフォームの上で動いている ○ そのカート画面に「クーポンがもらえるか」をその場で表示したい ○ カートの中身が変わるたびに再判定 → 買い物客のブラウザの中で動かしたい

Slide 31

Slide 31 text

つまり、以下のようなことをしたい Recustomer 内 Recustomer の外 EC 事業者ごとの条件(式木) + 注文データ 評価ロジック Recustomer のバックエンドと 同じ評価ロジックを動かしたい EC 事業者が利用する EC サイトプラットフォーム ブラウザ 評価ロジック

Slide 32

Slide 32 text

JavaScript で書くとロジックが 2 つになってズレ始める Recustomer 内 Recustomer の外 EC 事業者ごとの条件(式木) + 注文データ 評価ロジック Recustomer のバックエンドと 同じ評価ロジックを動かしたい EC 事業者が利用する EC サイトプラットフォーム ブラウザ 評価ロジック 同じ 評価するコードが 2つになる

Slide 33

Slide 33 text

移植の落とし穴(再掲)

Slide 34

Slide 34 text

移植の落とし穴(再掲) 導入で紹介した移植の落とし穴①②が、 そのまま自分たちの話になった →だから Rust core 化を決めました

Slide 35

Slide 35 text

前置きが長くなりましたが、本日のお話 Python の評価ロジックを Rust に1本化して(=Rust core 化して)両側へ配る core ロジック 一度だけ書いて一本化 PyO3 Python サーバ 以降、こちらを 「Rust core」と呼びます wasm-bindgen ブラウザ

Slide 36

Slide 36 text

最終形がこちら 最終形のディレクトリ構成 evaluator/ ├─ core/ 評価ロジック(純 Rust) ├─ bindings-py/ Python への橋(PyO3) └─ bindings-wasm/ ブラウザへの橋(wasm-bindgen) 以降、ステップごとに作っていきます

Slide 37

Slide 37 text

STEP 1 評価ロジックを Rust core として定義する evaluator/ ├─ core/ │ ├─ Cargo.toml │ └─ src/lib.rs ├─ bindings-py/ └─ bindings-wasm/ 判定ロジック(純 Rust) ←まずはここを作る

Slide 38

Slide 38 text

式木のノード 4 種は Rust の enum で 4 つの枝にする Python(既存の式木ノード定義) from dataclasses import dataclass @dataclass(frozen=True) class If: cond: Expr then: Expr els: Expr @dataclass(frozen=True) class Apply: function: str args: list[Expr] @dataclass(frozen=True) class Var: name: str @dataclass(frozen=True) class Const: value: int | float | str | bool Expr = If | Apply | Var | Const

Slide 39

Slide 39 text

式木のノード 4 種は Rust の enum で 4 つの枝にする Python(既存の式木ノード定義) from dataclasses import dataclass @dataclass(frozen=True) class If: cond: Expr then: Expr els: Expr core/src/lib.rs(ノードを enum で定義する) @dataclass(frozen=True) class Apply: function: str args: list[Expr] @dataclass(frozen=True) class Var: name: str @dataclass(frozen=True) class Const: value: int | float | str | bool Expr = If | Apply | Var | Const 書き直す enum Expr { If { cond: Box, then: Box, els: Box }, Apply { function: String, args: Vec }, Var { name: String }, Const { value: Value }, }

Slide 40

Slide 40 text

評価ロジックの本体も定義する これも Python から移植(詳細は割愛) core/src/lib.rs (評価器は以下の再帰関数 1 本) pub fn evaluate(expr: &Expr, params: &Params) -> Result { match expr { Expr::Const { value } => Ok(value.clone()), } } Expr::Var { name } => params.get(name), // 注文データから値を引く Expr::Apply { .. } => { /* 関数を適用して再帰 */ } Expr::If { .. } => { /* cond を評価して分岐 */ }

Slide 41

Slide 41 text

評価ロジックの本体も定義する これも Python から移植(詳細は割愛) core/src/lib.rs (評価器は以下の再帰関数 1 本) pub fn evaluate(expr: &Expr, params: &Params) -> Result { match expr { Expr::Const { value } => Ok(value.clone()), Expr::Var { name } => params.get(name), // 注文データから値を引く Expr::Apply { .. } => { /* 関数を適用して再帰 */ } Expr::If { .. } => { /* cond を評価して分岐 */ } } } これで Rust core が完成 この core 関数が判定を実行する唯一の正本になる →実装が 1 つならズレる余地がそもそも無い

Slide 42

Slide 42 text

STEP 2 Rust core ロジックを Python から呼ぶ evaluator/ ├─ core/ ✅ ├─ bindings-py/ │ ├─ Cargo.toml │ └─ src/lib.rs └─ bindings-wasm/ Python への橋(PyO3) ←次はここを作る

Slide 43

Slide 43 text

PyO3: 書いた Rust を Python から import して利用できる Rust core calculate_py という名前で ① PyO3 で公開 fn add(a: i64, b: i64) -> i64 { a + b }

Slide 44

Slide 44 text

PyO3: 書いた Rust を Python から import して利用できる Rust core calculate_py という名前で ① PyO3 で公開 fn add(a: i64, b: i64) -> i64 { a + b } Python (利用する側) import calculate_py x = calculate_py.add(2, 3) print(x)

Slide 45

Slide 45 text

PyO3: 書いた Rust を Python から import して利用できる Rust core calculate_py という名前で ① PyO3 で公開 fn add(a: i64, b: i64) -> i64 { a + b } Python (利用する側) import calculate_py x = calculate_py.add(2, 3) print(x) ② add(2, 3) を呼ぶ

Slide 46

Slide 46 text

PyO3: 書いた Rust を Python から import して利用できる Rust core calculate_py という名前で ① PyO3 で公開 fn add(a: i64, b: i64) -> i64 { a + b } Python (利用する側) import calculate_py x = calculate_py.add(2, 3) print(x) ② add(2, 3) を呼ぶ ③ add が実行されて 5 が返る

Slide 47

Slide 47 text

PyO3: 書いた Rust を Python から import して利用できる Rust core calculate_py という名前で ① PyO3 で公開 fn add(a: i64, b: i64) -> i64 { a + b } Python (利用する側) import calculate_py x = calculate_py.add(2, 3) print(x) ② add(2, 3) を呼ぶ ③ add が実行されて 5 が返る Python 側から見れば 普通の module と関数

Slide 48

Slide 48 text

PyO3: 書いた Rust を Python から import して利用できる Rust core calculate_py という名前で ① PyO3 で公開 fn add(a: i64, b: i64) -> i64 { a + b } Python (利用する側) import calculate_py x = calculate_py.add(2, 3) print(x) この矢印の正体 = 橋 次はこれを書きます ② add(2, 3) を呼ぶ ③ add が実行されて 5 が返る Python 側から見れば 普通の module と関数

Slide 49

Slide 49 text

Rust core ロジックを Python から呼べるようにする ⚠ bindings-py/src/lib.rs(このファイルは core ではありません! bindings-py 層に新しく書く関数です) use pyo3::prelude::*; #[pyfunction] #[pyfunction] という属性マクロを付けると Python の関数として利用できる fn evaluate(tree_json: &str, params: Params) -> PyResult { }

Slide 50

Slide 50 text

Rust core ロジックを Python から呼べるようにする ⚠ bindings-py/src/lib.rs(このファイルは core ではありません! bindings-py 層に新しく書く関数です) use pyo3::prelude::*; #[pyfunction] #[pyfunction] という属性マクロを付けると Python の関数として利用できる fn evaluate(tree_json: &str, params: Params) -> PyResult { // ここで、tree_ json をパースし、core の evaluate を呼んで返す(薄いラッパー) }

Slide 51

Slide 51 text

Rust core ロジックを Python から呼べるようにする ⚠ bindings-py/src/lib.rs(このファイルは core ではありません! bindings-py 層に新しく書く関数です) use pyo3::prelude::*; #[pyfunction] #[pyfunction] という属性マクロを付けると Python の関数として利用できる fn evaluate(tree_json: &str, params: Params) -> PyResult { // ここで、tree_ json をパースし、core の evaluate を呼んで返す(薄いラッパー) } ただし、これだけではまだ Python から import できない

Slide 52

Slide 52 text

関数は、モジュールに載せて初めて import できる ● ● Python の import が探すのは、関数ではなくモジュール #[pyfunction] を付けただけでは、まだ Python から見えない bindings-py/src/lib.rs(続き) #[pyfunction] Python 側(これで import できる) import evaluator fn evaluate() {...} evaluator.evaluate( #[pymodule] tree_json, fn evaluator(m: &Bound<'_, PyModule>) -> PyResult<()> { {"amount": 7000, "tags": []}, m.add_function(wrap_pyfunction!(evaluate, m)?) } ) # => True

Slide 53

Slide 53 text

関数は、モジュールに載せて初めて import できる ● ● Python の import が探すのは、関数ではなくモジュール #[pyfunction] を付けただけでは、まだ Python から見えない bindings-py/src/lib.rs(続き) Python 側(これで import できる) import evaluator #[pyfunction] fn evaluate() {...} #[pymodule] evaluator.evaluate( モジュールとして公開する属性マクロ fn evaluator(m: &Bound<'_, PyModule>) -> PyResult<()> { tree_json, {"amount": 7000, "tags": []}, 呼べる m.add_function(wrap_pyfunction!(evaluate, m)?) ) # => True } fn の名前が、そのまま import の名前になる(fn evaluator → import evaluator) これで橋は開通

Slide 54

Slide 54 text

注意ポイント ① Rust core に属性マクロを持ち込まない 「wrapper は用意せず、core の evaluate に直接属性マクロを付ければいいのでは?」

Slide 55

Slide 55 text

注意ポイント ① Rust core に属性マクロを持ち込まない 「wrapper は用意せず、core の evaluate に直接属性マクロを付ければいいのでは?」 ❌ core に付ける ⭕ 橋にだけ付ける // core/src/lib.rs // bindings-py/src/lib.rs #[pyfunction] // ← core に Python の都合が混入 #[pyfunction] // ← OK! fn evaluate() {...} fn evaluate() {...} ● core に属性マクロを付けると、core が PyO3 依存になってしまう ○ ● ブラウザへ配るときに全部剥がして回ることになる core に片側の都合を混ぜないのが鉄則

Slide 56

Slide 56 text

STEP 3 言語の壁を越える「変換層」を用意する evaluator/ ├─ core/ ✅ ├─ bindings-py/ │ ├─ Cargo.toml │ └─ src/lib.rs └─ bindings-wasm/ Python への橋(PyO3) ←引き続き、この中の話

Slide 57

Slide 57 text

引数と返り値は、言語の境界を往復する Rust 側(core) Python 側 import evaluator 引数: Python の値 → Rust の値 fn evaluate( evaluator.evaluate( expr: &Expr, tree_json, params: &Params, {"amount": 7000, "tags": []}, ) 返り値: Rust の値 → Python の値 # => True この変換は誰がやる? ) -> Result

Slide 58

Slide 58 text

引数と返り値は、言語の境界を往復する Rust 側(core) Python 側 引数: Python の値 → Rust の値 import evaluator fn evaluate( evaluator.evaluate( expr: &Expr, tree_json, params: &Params, {"amount": 7000, "tags": []}, ) 返り値: Rust の値 → Python の値 ) -> Result # => True この変換は誰がやる? 基本的には PyO3 がやってくれます

Slide 59

Slide 59 text

PyO3 は Python の型と Rust の型の対応表を持っている

Slide 60

Slide 60 text

型が決まっていれば自動変換してくれる Rust 側 Python 側 引数: Python の値 → Rust の値 import calculate_py fn add(a: i64, b: i64) -> i64 { a + b x = calculate_py.add(2, 3) print(x) PyO3 が i64 に自動変換 返り値: Rust の値 → Python の値 } この場合、変換コードは 1 行も書かない

Slide 61

Slide 61 text

一方、evaluate() の params は? Rust 側(core) Python 側 引数: Python の値 → Rust の値 import evaluator fn evaluate( evaluator.evaluate(tree_json, { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool }) expr: &Expr, params: &Params, ) -> Result 返り値: Rust の値 → Python の値 注文データ(int / list / bool が混ざる dict) この Params の型をどうするか?

Slide 62

Slide 62 text

固定の struct にはしたくない Rust 側(core) struct Params { amount: i64, tags: Vec, first_purchase: bool, // 項目が増えるたびに、ここを足すことに }

Slide 63

Slide 63 text

固定の struct にはしたくない Rust 側(core) struct Params { amount: i64, tags: Vec, first_purchase: bool, // 項目が増えるたびに、ここを足すことに } ● ● 注文データのどの項目を、どんな型で使うかは、式木が決める ○ EC 事業者 A の式木 → amount だけ ○ EC 事業者 B の式木 → amount と tags ○ EC 事業者 C の式木 → struct に現状ない customer_rank 項目が増えた場合、評価器のコードも都度足していく必要がある 「変わるのは式木データだけ」が崩れてしまう

Slide 64

Slide 64 text

変換層を自作して橋渡しする(変換関数と Value enum) # Python 側の注文データ { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool } Rust 側(wrapper が受け取る) type Params = HashMap; // Value(型を実行時に持つ enum) enum Value { Int(i64), Float(f64), Bool(bool), Str(String), List(Vec), } // 中身の決め方は後述

Slide 65

Slide 65 text

変換層を自作して橋渡しする(変換関数と Value enum) # Python 側の注文データ { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool } Rust 側(wrapper が受け取る) type Params = HashMap; 実体 // Value(型を実行時に持つ enum) enum Value { Int(i64), Float(f64), Bool(bool), Str(String), List(Vec), } // 中身の決め方は後述

Slide 66

Slide 66 text

変換層を自作して橋渡しする(変換関数と Value enum) # Python 側の注文データ { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool } ① PyO3 が dict を自動変換 dict → HashMap / キー → String Rust 側(wrapper が受け取る) type Params = HashMap; // Value(型を実行時に持つ enum) enum Value { Int(i64), Float(f64), Bool(bool), Str(String), List(Vec), } // 中身の決め方は後述

Slide 67

Slide 67 text

変換層を自作して橋渡しする(変換関数と Value enum) # Python 側の注文データ { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool } ① PyO3 が dict を自動変換 dict → HashMap / キー → String Rust 側(wrapper が受け取る) type Params = HashMap; ② amount の値 7000 を Value にしたい → PyO3 が自作の to_value(7000) を呼んでくれる // ③ Python の値 1 個を Value に変換する関数を実行 fn to_value(obj: &Bound) -> PyResult { // bool?→ int?→ float? … の順に判定して Value に詰める } // PyO3 に「Value はこれで変換」と登録 impl FromPyObject<'_> for Value { fn extract_bound(obj: &Bound) -> PyResult { to_value(obj) } } // Value(型を実行時に持つ enum) enum Value { Int(i64), Float(f64), Bool(bool), Str(String), List(Vec), } // 中身の決め方は後述

Slide 68

Slide 68 text

変換層を自作して橋渡しする(変換関数と Value enum) # Python 側の注文データ { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool } ① PyO3 が dict を自動変換 dict → HashMap / キー → String Rust 側(wrapper が受け取る) type Params = HashMap; ② amount の値 7000 を Value にしたい → PyO3 が自作の to_value(7000) を呼んでくれる // ③ Python の値 1 個を Value に変換する関数を実行 fn to_value(obj: &Bound) -> PyResult { // bool?→ int?→ float? … の順に判定して Value に詰める } // PyO3 に「Value はこれで変換」と登録 impl FromPyObject<'_> for Value { fn extract_bound(obj: &Bound) -> PyResult { to_value(obj) } } // Value(型を実行時に持つ enum) enum Value { Int(i64), Float(f64), Bool(bool), Str(String), List(Vec), } // 中身の決め方は後述

Slide 69

Slide 69 text

変換層を自作して橋渡しする(変換関数と Value enum) # Python 側の注文データ { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool } ① PyO3 が dict を自動変換 dict → HashMap / キー → String Rust 側(wrapper が受け取る) type Params = HashMap; ② amount の値 7000 を Value にしたい → PyO3 が自作の to_value(7000) を呼んでくれる // ③ Python の値 1 個を Value に変換する関数を実行 fn to_value(obj: &Bound) -> PyResult { // bool?→ int?→ float? … の順に判定して Value に詰める } // PyO3 に「Value はこれで変換」と登録 impl FromPyObject<'_> for Value { fn extract_bound(obj: &Bound) -> PyResult { to_value(obj) } } // Value(型を実行時に持つ enum) enum Value { ④ 7000 は Value::Int(7000) になる Int(i64), Float(f64), Bool(bool), Str(String), List(Vec), } // 中身の決め方は後述

Slide 70

Slide 70 text

変換層を自作して橋渡しする(変換関数と Value enum) # Python 側の注文データ { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool } ① PyO3 が dict を自動変換 dict → HashMap / キー → String Rust 側(wrapper が受け取る) type Params = HashMap; // 最終的な Params の値 "amount": Int(7000) "tags": List([Str("予約")]) "first_purchase": Bool(true) ② amount の値 7000 を Value にしたい → PyO3 が自作の to_value(7000) を呼んでくれる // ③ Python の値 1 個を Value に変換する関数を実行 fn to_value(obj: &Bound) -> PyResult { // bool?→ int?→ float? … の順に判定して Value に詰める } // PyO3 に「Value はこれで変換」と登録 impl FromPyObject<'_> for Value { fn extract_bound(obj: &Bound) -> PyResult { to_value(obj) } } ⑤ HashMap に入る // Value(型を実行時に持つ enum) enum Value { ④ 7000 は Value::Int(7000) になる Int(i64), Float(f64), Bool(bool), Str(String), List(Vec), } // 中身の決め方は後述

Slide 71

Slide 71 text

変換層を自作して橋渡しする(変換関数と Value enum) # Python 側の注文データ { "amount": 7000, # int "tags": ["予約"], # list "first_purchase": True, # bool } ① PyO3 が dict を自動変換 dict → HashMap / キー → String Rust 側(wrapper が受け取る) type Params = HashMap; // 最終的な Params の値 "amount": Int(7000) "tags": List([Str("予約")]) "first_purchase": Bool(true) 変換層として自作するのは、この2つ ② amount の値 7000 を Value にしたい → PyO3 が自作の to_value(7000) を呼んでくれる // ③ Python の値 1 個を Value にする関数を実行 fn to_value(obj: &Bound) -> PyResult { // bool?→ int?→ float? … の順に判定して Value に詰める } // PyO3 に「Value はこれで変換」と登録 impl FromPyObject<'_> for Value { fn extract_bound(obj: &Bound) -> PyResult { to_value(obj) } } ⑤ HashMap に入る // Value(型を実行時に持つ enum) enum Value { ④ 7000 は Value::Int(7000) になる Int(i64), Float(f64), Bool(bool), Str(String), List(Vec), } // 中身の決め方は後述

Slide 72

Slide 72 text

注意ポイント ② Value の型は、Python にも JavaScript にも寄せない 前ページの enum Value、中身の対応付けは以下のように決めた

Slide 73

Slide 73 text

注意ポイント ② Value の型は、Python にも JavaScript にも寄せない 前ページの enum Value、中身の対応付けは以下のように決めた ● int と float は ひとくくりにしない ○ ● JS の number(区別がない)に寄せない bool は int の サブタイプにしない ○ Python の bool ⊂ int を持ち込まない どちらの言語にも寄せず、最大公約数で決める

Slide 74

Slide 74 text

注意ポイント ③ 変換関数では、bool を int より先に判定する ● Python の bool は int の サブタイプ ○ → isinstance(True, int) は True になる ❌ 数値から先に判定すると... fn to_value(obj: &Bound) -> PyResult { // int 判定 if let Ok(i) = obj.downcast::() { return Ok(Value::Int(i.extract::()?)); } // bool 判定 if let Ok(b) = obj.downcast::() { return Ok(Value::Bool(b.is_true())); } // float? str? list? … と続く }

Slide 75

Slide 75 text

注意ポイント ③ 変換関数では、bool を int より先に判定する ● Python の bool は int の サブタイプ ○ → isinstance(True, int) は True になる ❌ 数値から先に判定すると... fn to_value(obj: &Bound) -> PyResult { // int 判定 if let Ok(i) = obj.downcast::() { return Ok(Value::Int(i.extract::()?)); } // bool 判定 if let Ok(b) = obj.downcast::() { return Ok(Value::Bool(b.is_true())); } // float? str? list? … と続く } 先に int を見ると、True が Int(1) になって事故る

Slide 76

Slide 76 text

注意ポイント ③ 変換関数では、bool を int より先に判定する ● Python の bool は int の サブタイプ ○ → isinstance(True, int) は True になる ❌ 数値から先に判定すると... ⭕ bool を先に見る fn to_value(obj: &Bound) -> PyResult { // int 判定 if let Ok(i) = obj.downcast::() { return Ok(Value::Int(i.extract::()?)); } // bool 判定 if let Ok(b) = obj.downcast::() { return Ok(Value::Bool(b.is_true())); } // float? str? list? … と続く } fn to_value(obj: &Bound) -> PyResult { // bool 判定 if let Ok(b) = obj.downcast::() { return Ok(Value::Bool(b.is_true())); } // int 判定 if let Ok(i) = obj.downcast::() { return Ok(Value::Int(i.extract::()?)); } // float? str? list? … と続く } 先に int を見ると、True が Int(1) になって事故る エラーは出ないので、答えがひっそりと変わる 落とし穴①(同じつもりのコードが違う答えを返す)を防ぐ

Slide 77

Slide 77 text

注意ポイント ④ 変換関数では、i64 に収まらない int を 境界で弾く >>> 10 ** 30 # Python の int に上限はない 1000000000000000000000000000000 Rust の i64 は 9,223,372,036,854,775,807 まで 収まらない値は、黙って丸めずに OverflowError で返す

Slide 78

Slide 78 text

注意ポイント ④ 変換関数では、i64 に収まらない int を 境界で弾く >>> 10 ** 30 # Python の int に上限はない 1000000000000000000000000000000 Rust の i64 は 9,223,372,036,854,775,807 まで 収まらない値は、黙って丸めずに OverflowError で返す ⭕ 変換関数の中(int を受けるところ) fn to_value(obj: &Bound) -> PyResult { // … if let Ok(i) = obj.downcast::() { let v: i64 = i.extract()?; // 収まらなければ ? が OverflowError にして呼び出し側へ返す return Ok(Value::Int(v)); } // … }

Slide 79

Slide 79 text

前ページの OverflowError はどうやって Python に届くのか? ● PyO3 が Python の例外にしてくれる ○ to_value(obj: &Bound) -> PyResult = Result ○ PyErr = Python の例外を Rust 側で表す型 Rust に例外は無い。Err を返せば境界で Python の例外になる

Slide 80

Slide 80 text

STEP 4 maturin で Rust core を Python へ配る evaluator/ ├─ core/ ✅ ├─ bindings-py/ Python への橋(PyO3) │ ├─ Cargo.toml │ ├─ src/lib.rs │ ├─ evaluator.pyi │ └─ pyproject.toml └─ bindings-wasm/ ←最後の一手間が必要

Slide 81

Slide 81 text

maturin とは? Rust を wheel に詰めるビルドツール ● 前章までで Rust core と Python binding を作ったが、実はまだ Python からは利用できない ○ Rust のコードを .so にビルドし、Python パッケージの形にしないと import できない ○ それをやるのが maturin

Slide 82

Slide 82 text

maturin とは? Rust を wheel に詰めるビルドツール ● 前章までで Rust core と Python binding を作ったが、実はまだ Python からは利用できない ○ Rust のコードを .so にビルドし、Python パッケージの形にしないと import できない ○ それをやるのが maturin core + bindings-py ● ● build 配れる一つのファイル maturin は setuptools と同じビルドバックエンドの位置 ○ import evaluator wheel (.whl) Rust のコード cargo build 後に生成された .so を wheel に詰める maturin build --release コマンドで wheel を作れる pip install 配った先の Python サーバ

Slide 83

Slide 83 text

maturin の実行前に必要な設定ファイルは 2 つだけ ① pyproject.toml(ビルド方法の宣言) [build-system] requires = ["maturin>=1.7,<2"] build-backend = "maturin" [project] name = "evaluator" version = "0.1.0" requires-python = ">=3.9" ② Cargo.toml(cdylib として吐く) [lib] name = "evaluator" crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.22", features = ["abi3-py39"] } evaluator-core = { path = "../core" } build-backend = "maturin" の 1 行が、pip と cargo をつないでくれる

Slide 84

Slide 84 text

注意ポイント ⑤ 型スタブ(.pyi)は、maturin build だけでは付いてこない

Slide 85

Slide 85 text

注意ポイント ⑤ 型スタブ(.pyi)は、maturin build だけでは付いてこない ※生成ツールもある: pyo3-stub-gen / maturin 1.13+ の --generate-stubs(PyO3 の experimental-inspect が必要・開発中)今回は関数 1 つなので手書き 型スタブは自分で用意する 置き場所さえ合っていれば同梱は maturin がやってくれる

Slide 86

Slide 86 text

おまけ Wasm への展開 evaluator/ ├─ core/ ✅ ├─ bindings-py/ ✅ └─ bindings-wasm/ ├─ Cargo.toml └─ src/lib.rs ブラウザへの橋(wasm-bindgen)

Slide 87

Slide 87 text

再掲

Slide 88

Slide 88 text

新しく足すのは bindings-wasm だけ

Slide 89

Slide 89 text

新しく足すのは bindings-wasm だけ 新しく書くのはこの1ファイルだけ // bindings-wasm/src/lib.rs use evaluator_core as core; use wasm_bindgen::prelude::*; binding 追加 #[wasm_bindgen] pub fn evaluate(expr_json: &str, input_json: &str) -> Result { core::evaluate(..) // PyO3 と同様に Rust core を呼ぶ } Rust core に配布先(Python)の都合を入れなかったので、 Wasm 対応は binding を足すだけで済んだ

Slide 90

Slide 90 text

まとめ

Slide 91

Slide 91 text

各セクションの注意ポイントまとめ ① core 公開 ● core に属性マクロを持ち込まない ② 変換層 ● ● ● 型は Python にも JavaScript にも寄せず、最大公約数で決める bool を int より先に判定する 収まらない値は、黙って丸めずに境界で弾く ③ 配布 ● maturin build で型スタブは作られない

Slide 92

Slide 92 text

Rust core 化で守りたかったのは、速さより正本 ● Rust の速さも魅力ではある ○ ただ、今回いちばん重視したのは、評価ロジックの正本を 1 つに保てること

Slide 93

Slide 93 text

Rust core 化で守りたかったのは、速さより正本 ● Rust の速さも魅力ではある ○ ● ただ、今回いちばん重視したのは、評価ロジックの正本を 1 つに保てること Python だけで完結するなら、Python のままでいい ○ 同じロジックを Python 以外の言語でも動かしたいのであれば、 Rust core 化は有力な選択肢になる ○ ただし、橋のコードは自分で書く(= 今日の注意ポイント 5 つ)

Slide 94

Slide 94 text

No content

Slide 95

Slide 95 text

Thank you!