🎃

C++からPythonを操る黒魔術

2024/01/28に公開

はじめに

突然ですが、C++からPythonを使いたいことはありませんか?
たとえば組み込み系のモジュール開発やPoCで部分的にAIを使ってみたいとき。深層学習のフレームワークによってはC言語のAPIを提供していたり、ONNXなどモデル自体が多言語対応のものもありますが、そうやって、なんとかしてPythonを避けていませんか?

PythonからCのモジュールを呼び出すのはよくあるけれど、その逆はできないと思っていませんか?

これ実は、公式なやり方でできます。

https://docs.python.org/3/extending/embedding.html

こちらのページ内のサンプルコードに軽く目を通していただければわかるかと思いますが、Pythonはインタープリタを制御するためのC言語APIを提供しています。普通にPythonをインストールすると知らない間についてきます。これを使ってC/C++のアプリケーションを組めば、C++のメモリ空間内にPythonのスクリプト実行を引き込めます。そのため、ソケット通信やパイプ通信やメモリ共有といったまどろっこしい(とか言っちゃいけない?)マルチプロセス構成をとらずに無駄のないPythonバインドができるのです。

他に、CythonやCodonなどPythonとC/C++の連携を取ることができるツールはありますが、インストールが必要であったりPython側で細工が必要であったりします。既存のPython資産を活かしたいという場面では手間です。その点C-APIから直接攻めるやり方ならPythonの既存の環境はそのままで、C側からの歩み寄りが大きい分、Pythonのシンプルさを損なわない連携が可能です。

Pythonはスクリプト言語ですし、インタープリタがどう動いているかなぞ普通に暮らしていたら知る由もないので、C++アプリケーションに直繋ぎするなんてなかなかエキセントリックな所業に思われますよね。一応公式なやり方なのですが、黒魔術ちっくだなと思い、このような記事名にしました。

開発内容

GitHubリポジトリ

https://github.com/zwire/poppy

API Reference

https://zwire.github.io/poppy/

機能一覧

以下の機能が、Python側に手を加えることなくC++で実現できます。

  • Pythonインタープリタの初期化と終了
    -- 仮想環境インタープリタとモジュールの指定
    -- モジュールやスクリプトのimport
  • Pythonオブジェクトを表現するC++の値オブジェクト
    -- インスタンスの生成破棄
    -- インタープリタのGCから監視される変数参照カウントの管理
  • Pythonオブジェクトが持つ内部変数 (Attribute) へのアクセス
  • 関数を通じた構造体やクラス、ndarrayなどの受け渡し
  • GILのコンテキストスイッチ
    -- マルチスレッド環境での排他制御

サンプル一覧

  1. echo ... 単純な疎通テスト。
  2. calc ... 簡単な計算結果の取得。公式サンプルのパロディ。
  3. numpy ... C++から直接numpyを呼び出し、且つそのオブジェクトをBufferとしてPythonと授受する。
  4. struct ... Pythonのctypesと連携し、C++の構造体を直接Pythonに渡す。
  5. class ... Pythonで定義したオリジナルのクラスインスタンスをC++上に展開。クラスメソッドを呼び出す。
  6. thread ... マルチスレッドで排他制御を利かせながら並行ループ処理を行う。
  7. venv ... venvで作成した仮想環境のインタープリタおよびモジュールを指定して実行する。

解説 (サンプルアプリケーション)

上記のリポジトリはPythonのC-APIをラップしたC++のクラス群と、それらを使ったサンプルコードが含まれています。細かい内容は後回しにして、まずはどれだけ簡単にPythonをコントロールできるか見てみましょう。

一番簡単な、呼び出されるPythonの関数と呼び出すC++メインプログラムという構図をお見せします。

  • 呼び出される側
# 型アノテーションはわかりやすいようにつけているだけで、特に必要ありません。
def echo(message: str) -> str:
  print("------ C++ -> Python ------")
  print(message)
  print()
  return message + ", world!"
  • 呼び出す側
#include "poppy.h"

using std::cout;
using std::endl;
using namespace poppy;

int main() {
  // first,
  Initialize();

  // relative path from workspace directory
  AddModuleDirectory("scripts");

  // load script file
  auto module = Import("01_echo");

  // reserve function object
  auto func = module.GetAttribute("echo").ToFunc();

  // run function
  auto ret = func(Str("hello"));

  // print results
  cout << "------ Python -> C++ ------" << endl;
  cout << "Reply: " << ret << std::endl;

  // after destructing all,
  Finalize();
  cout << "\n\n\n";
}

main.cppをコンパイルして実行すると下記のようになります。

------ C++ -> Python ------
hello

------ Python -> C++ ------
Reply: 'hello, world!'

関数呼び出しで文字列を送って返してもらうだけのしょうもない作業ですが、ちゃんと疎通できていることがわかりますね。

リポジトリでは、numpyをC++から使ってみる例やPythonのクラスオブジェクトにC++からアクセスする例、GILを制御しながらスレッド間排他制御を乗りこなす例などを載せていますので、よければご参照ください。

次は少し踏み込んだ仕様の話です。

解説 (API仕様)

Python APIではあらゆる型がすべてPyObjectという構造体に押し込められ、その中の属性値をやりくりすることで変数や関数、数値や文字列などいろいろな表現ができるようになっています。ゆえにPythonユーザーは型を書かなくてもよいのですが、C++側からすればこの仕様は煩雑なだけで甚だ厄介です。

そのため、C++のAPIとしてはひとまずObjectという親クラスを派生させて中分類くらいのクラスに機能分割する戦略をとりました。

image.png

Object

  • 内部にPyObjectのポインタ実体を持つ親クラス
  • 参照カウント管理による変数ライフタイムの制御を行う
  • オブジェクトのAttribute (メンバ変数 / 関数など) へアクセスする機能も

Value

  • Pythonで組み込み型として扱うもの (bool / int / float / string / bytes / bytearray) を表現

Tuple

  • Pythonのタプル型
  • 関数で複数の引数や戻り値を扱うときにも必要

List

  • Pythonのリスト型

Dict

  • Pythonの辞書型
  • キーはC++のstringでもPythonのObjectでもとれる

Buffer

  • Pythonのbuffer、主にndarrayなどのテンソル系オブジェクトを表すクラス

Func

  • Pythonの関数オブジェクト
  • 通常の関数もクラスメソッドもこのクラスで同じように扱う

Generic

  • 各派生クラスへの変換振り分けを整理するためのユーティリティ的な汎用クラス

GILContext

  • GIL (Global Interpreter Lock) をC++から操るためのクラス
  • 何もしなければインタープリタがデフォルトのGILを走らせているが、マルチスレッドでPython APIを触る場合は各スレッドでGILの排他を自前制御する必要がある

ざっと書きましたが、主だった機能はこれだけです。

なんだかんだで設計のポイントは親クラス'Object'でして、ここでオブジェクトの生成破棄に伴う参照カウントの制御をしてやることで、C-APIを生で使うよりもかなりスッキリ処理が記述できるようになっています。ちなみにこの参照カウントをぞんざいに扱うとポインタの二重解放でクラッシュしたりインタープリタのGCが働かずリークしたり、Pythonとは思えないひどい暴れ方をします(泣)。

Tuple、List、Dictは'[ ]'演算子オーバーロードやイテレータを自作するのも考えましたが、それだと初期化時にいちいち無駄な処理が走ってしまうので、代わりにToStdVector()という関数を提供しています。'[ ]'アクセスや範囲forが使いたい場合はvectorに退避して作業してくださいという仕様です。

おわりに

いろいろ作ってみましたが、PythonのC-APIはとても使い勝手が悪いです。

ver 3.12など、ごく最近でも破壊的なインターフェース変更が入っていて全然安定感がないんですよね。コミュニティ開発なので仕方ないのかと思いますが、今回私が作ったものもあまり移植性は高くないかもしれません。

今回は一旦これで終わりにしますが、何か思いついたら少しづつアップデートしようかなと思います。

Discussion