[Python][FFI] ctypes で C++ メソッドを呼び出す

  • 2022年8月10日
  • 2022年9月17日
  • Python
  • 538View
  • 0件

はじめに

Python から C++ のメソッドを呼び出すためには、いろいろな手段があります。
思いつくもので ctypes, CFFI, SWIG, boost.python, WASM とありますが、今回は python の標準ライブラリでできる ctypes を紹介します。

動作環境は以下です。

  • Windows 11
  • WSL Ubuntu (20.04)
  • g++ 9.4.0
  • Python 3.8.10

事前準備

C++ ライブラリ生成のため、g++ をインストールしておきましょう。

$ sudo apt install g++

Hello World!サンプル

早速、Hello World のサンプルです。

動的リンクライブラリの作成

まず、C++ のメソッドを他言語から呼び出すために .so ファイルを作成します。

#include <iostream>

extern "C" void hello()
{
    std::cout << "Hello World!" << std::endl;
}
$ g++ -fPIC --shared hello.cc -o libhello.so

libhello.so ファイルが生成されます。

$ ls libhello.so
libhello.so

ctypes で呼び出し

次に python から libhello.so を呼び出すための実装です。
main.pylibhello.so を同じフォルダに配置する必要があります。

#!/usr/bin/env python3

import ctypes

lib = ctypes.cdll.LoadLibrary("./libhello.so")
lib.hello()
$ python3 ./main.py
Hello World!

ポイント解説

C++ コードの extern “C”

C++ のメソッドを他言語から呼び出す際には、extern "C" を付与する必要があります。
C++ ではオーバーロードの機構(同じメソッド名の引数違いを宣言できる)があるため、コンパイラによってメソッド名が改変されてしまいます。
メソッド名が改変されると python から呼び出すためのメソッド名がわからなくなってしまいます。
これを抑制するのが extern "C" です。

例えば、以下のように extern "C" を記述しなくてもライブラリの生成そのものは正常終了します。

#include <iostream>

void hello()
{
    std::cout << "Hello World!" << std::endl;
}

しかし、python コードを実行すると、「hello メソッドなんてないよー」とエラーが発生してしまいます。

$ python3 main.py
Traceback (most recent call last):
  File "main.py", line 6, in <module>
    lib.hello()
  File "/usr/lib/python3.8/ctypes/__init__.py", line 386, in __getattr__
    func = self.__getitem__(name)
  File "/usr/lib/python3.8/ctypes/__init__.py", line 391, in __getitem__
    func = self._FuncPtr((name_or_ordinal, self))
AttributeError: ./libhello.so: undefined symbol: hello

extern “C” がない場合のメソッド名

少し踏み込んで、nm コマンドで解析すると、libhello.so の中に _Z5hellov というシンボルが生成されているのが確認できます。
これが g++ コンパイラによって勝手に改変されたメソッド名と推測されます。

$ nm libhello.so | grep hello
00000000000011fc t _GLOBAL__sub_I_hello.cc
0000000000001179 T _Z5hellov

試しに python からのメソッド呼び出しを _Z5hellov に書き換えてみます。

import ctypes

lib = ctypes.cdll.LoadLibrary("./libhello.so")
lib._Z5hellov()
$ python3 main.py
Hello World!

今回は無事メソッド呼び出しが成功しました。

ライブラリパスの指定

サンプルでは雑に ./libhello.so と指定しましたが、ライブラリパスの指定は繊細です。
例えば main.py の呼び出しを別ディレクトリから実行するだけで、エラーが発生してしまいます。
(今回の作業ディレクトリには hello と命名しています)

$ cd ..
$ pwd
/home/xxxxx/projects/ctypes
$ python3 hello/main.py
Traceback (most recent call last):
  File "hello/main.py", line 3, in <module>
    lib = ctypes.cdll.LoadLibrary("./libhello.so")
  File "/usr/lib/python3.8/ctypes/__init__.py", line 451, in LoadLibrary
    return self._dlltype(name)
  File "/usr/lib/python3.8/ctypes/__init__.py", line 373, in __init__
    self._handle = _dlopen(self._name, mode)
OSError: ./libhello.so: cannot open shared object file: No such file or directory

解決策1:LD_LIBRARY_PATH 環境変数を利用する

1つの手段として、LD_LIBRARY_PATH 環境変数を利用します。

まず、ライブラリのパスから、./ を除去します。
これだけだと、どこのディレクトリから実行してもエラーが発生することになってしまいます。

import ctypes

lib = ctypes.cdll.LoadLibrary("libhello.so")
lib._Z5hellov()

その代わり、LD_LIBRARY_PATH 環境変数を変更して、当パスの配下に libhello.so が存在するようにするとどこからでも実行できるようになります。

$ pwd
/home/xxxxx/projects/ctypes/hello
$ LD_LIBRARY_PATH=. python3 main.py
Hello World!
$ cd ..
$ pwd
/home/xxxxx/projects/ctypes
$ LD_LIBRARY_PATH=hello python3 hello/main.py
Hello World!

上記の例は相対パスを指定していますが、もちろん絶対パスでも問題ないです。
むしろ実用上は絶対パスを指定するのが適切です。

解決策2:プログラム中で絶対パスを指定する

プログラム中で絶対パスを生成するようにすることで、常にパスを有効にすることができます。
下記スクリプトでは、main.pylibhello.so が同ディレクトリに存在することを前提に、__file__ を起点に libhello.so の絶対パスを生成しています。

import ctypes
import pathlib

dirname = pathlib.Path(__file__).parent.resolve()
libpath = dirname.joinpath("libhello.so")
lib = ctypes.cdll.LoadLibrary(libpath)
lib.hello()

__file__main.py のパスを表します。
python3 main.py と実行されたときの引数そのものになります。
parent がその親ディレクトリを返却し、resolve() によって絶対パスに変換しています。
最後に joinpathlibhello.so の絶対パスを生成しています。

こうすることで、LD_LIBRARY_PATH を気にせずとも、どこからでもスクリプトを実行することができるようになります。

$ pwd
/home/xxxxx/projects/ctypes/hello
$ python3 main.py
Hello World!
$ cd ..
$ pwd
/home/xxxxx/projects/ctypes
$ python3 hello/main.py
Hello World!

今回のプログラムは resolve() によって、絶対パスで解決しましたが、常に正確な相対パスを生成するやり方でも問題なく動作します。
ただし、カレントディレクトリから実行する際のために、./ の有無を適切にケアする必要があります。(カレントディレクトリの場合は libhello.so ではなく ./libhello.so となるようにしないといけない)