ま、そんなところで。

ニッチな技術系メモとか、車輪を再発明してみたりとか.

C/C++アプリケーションへPythonを埋め込む

アプリケーションの一部をPythonで作る

pythonの公式Docには組み込み可能なpythonパッケージの記述があります.
これによると、Windows版にはアプリと一緒に配布できるpython環境があるそうです.

ドキュメントを見るととても容易にできるようなので、試してみました.

(準備) 通常のパッケージをインストール

組み込みpythonのパッケージには、ビルドに使用するヘッダやリンクライブラリ(.lib)が付属していません。 このため、通常パッケージを使ってビルドして、デプロイ時にembededdable版を配置するようにします.

以下からembeddable版と対応する通常版をDownloadしてインストールしておきます.

Python Releases for Windows | Python.org

テスト用最小コード

以下、テスト用の最小コードです.
いくつか注意点があります.

#define PY_SSIZE_T_CLEAN

// デバッグ版へのリンクを避けるため _DEBUG を一時的に無効化.
#ifdef _DEBUG
    #undef _DEBUG
    #include <Python.h>
    #define _DEBUG
#else
    #include <Python.h>
#endif

// 使用する実行環境のPythonインタプリタのパス
// ここで指定するインタプリタパスを利用してpythonモジュールの探索先など実行環境のパスが決まる.
// ここにvenvで作った仮想環境のpythonインタプリタパスを指定した場合、
// 仮想環境下でランタイムが動作する
#define PYTHON_EXE_PATH u8"Path to python"

int main(int argc, char** argv)
{
    int result = 0; 
    // このポインタはFinalizeが完了するまで保持する
    wchar_t* program = Py_DecodeLocale(PYTHON_EXE_PATH, NULL);
    if (program == nullptr) {
        fprintf(stderr, "Fatal error: cannot decode argv[0]\n");
    }
    else {
 
        // 使用したい実行環境のpythonインタプリタのパスを指定
        Py_SetProgramName(program);

        // 初期化
        Py_Initialize();

        // Python実行環境の参照パスを出力してみる
        PyRun_SimpleString("import sys");
        PyRun_SimpleString("print(sys.path)\n");
        PyRun_SimpleString("print(sys.prefix)\n");
        PyRun_SimpleString("print(sys.exec_prefix)\n");

        constexpr int ERROR_RET      = 1;
        constexpr int SUCCESS_RET  = 0;

        // 環境終了処理
        int finalize_result = Py_FinalizeEx();

        int result = (finalize_result < 0) ? ERROR_RET : SUCCESS_RET;
        PyMem_RawFree(program);
    }
    retrurn result; 
}

(注意点1) embeddableにはDebug版が付属しない

Windowsの場合Python.h内に pragma があり、自動的にヘッダに対応する環境のpythonXX.libが参照されるようになっています.
ただし、マクロ _DEBUG が定義されているとデバッグ版 pythonXX_d.lib のほうがリンクされてしまいます.

embeddable python環境にはデバッグ版dllは付属していないので、 デバッグ版へのリンクを避けるため _DEBUG を一時的に無効化します

// デバッグ版へのリンクを避けるため _DEBUG を一時的に無効化.
#ifdef _DEBUG
    #undef _DEBUG
    #include <Python.h>
    #define _DEBUG
#else
    #include <Python.h>
#endif

(注意点2) Py_SetProgramNameにはインタプリタのパスを指定する

Python/C API Reference Manual — Python 3.10.5 documentation
Py_SetProgramNameの使い方について、公式Docをみると以下の説明がありました.

This function should be called before Py_Initialize() is called for the first time, if it is called at all. It tells the interpreter the value of the argv[0] argument to the main() function of the program (converted to wide characters). This is used by Py_GetPath() and some other functions below to find the Python run-time libraries relative to the interpreter executable.

あれ?

文章の後半では「インタプリタ実行ファイルからの相対パスからpythonのランタイムライブラリを見つける」とあるのに、 文章の前半では、「(C/C++のmain関数の)引数のargv[0] を入れる」との記述があり、前半と後半の文章の内容に微妙な食い違いがあります.

不思議に思って実際にpythonソースコードを追ってみたところ・・・
どうやら「argv[0] を入れろ」との記述は、 argv[0] に入るのがpythonインタプリタ実行ファイルパスであることを想定しているためのようです.

確かに、pythonファイル(.py)の実行だけを想定すれば、 argv[0]には常に python 実行ファイルへのパスが入ることになりますね.
しかし、pythonランタイムをC/C++アプリケーションに埋め込む場合は、この前提が当てはまりません.
公式Docの説明は、文章の後半は正確な内容ですが前半には表現に誤りを含んでいると解釈するのが良さそうです.

ここは 使用する実行環境のインタプリタ実行ファイルのパス と読み替えて、
使用したい実行環境に配置されているインタプリタ実行ファイル(python.exe もしくは python)へのパスを指定します.

    // **使用したい実行環境のpythonインタプリタ実行ファイルのパス** を指定するっぽい
    //「プログラムファイルパス」は単純にmainの引数argv[0]を入力するというわけではなさそう.
    Py_SetProgramName(program);

実際、ProgramNameにインタプリタ絶対パスを指定してみると、組み込んだ環境のモジュール参照パス sys.path が、指定したインタプリタのある実行環境のものに変化します.
インタプリタパスはvenvで作られた仮想環境のものであっても良く、仮想環境のインタプリタパスを指定した場合は、仮想環境を参照するようにsysのモジュール参照パスが初期化されます.

システムのpythonがインストールされた環境でも、venvなど独立した仮想環境を作ってアプリに組み込めるのは利便性が高まりますね.

結構使えそう

Initializeが完了すれば、あとはPythonの対話モードを起動した状態に似ています.
pythonの対話モードを起動した状態でC/C++コードを使ってコードを流し込んでいく感じです.

    // Python実行環境のモジュール参照パスを出力
    PyRun_SimpleString("import sys");
    PyRun_SimpleString("print(sys.path)\n");
    PyRun_SimpleString("print(sys.prefix)\n");
    PyRun_SimpleString("print(sys.exec_prefix)\n");

かなり手軽に利用できます.
python機械学習/深層学習フレームワークで学習したモデルをアプリに組み込むときの有効な選択肢になりそうですね.

リファレンス