静かなる名辞

pythonとプログラミングのこと


【python】ctypesでバイト列や文字列を受け渡しする

はじめに

 pythonではC言語の動的リンク/共有ライブラリを手軽に扱う方法として、ctypesという標準モジュールが用意されています。

ctypes --- Pythonのための外部関数ライブラリ — Python 3.7.4 ドキュメント

 ctypesを用いて自作したC言語の処理を呼び出すことができ、pythonで書くとどうしても遅くなるような計算処理を高速化することができます。

 ただ、問題はこれを使う方法の解説がほとんどネットに存在しないこと(少なくとも日本語Web圏には)です。

 つい最近ctypesを少し使う機会があったので、もっとも基本的な「バイト列」「文字列」の取り扱い方についてまとめておきます。なお、この記事はpython3系を前提とします。

 目次

バイト列(bytes)を扱う

 ctypesでバイト列(pythonではbytes)を扱うのはそれほど難しくありません。


 実験用に、次のようなC言語の関数を作ります。

lib1.c

#include <stdlib.h>

char *result;

char *reverse_bytes(int n, char *buf) {
  int i;

  result = (char *)malloc(sizeof(char)*(n));
  for (i=0; i<n; i++) {
    result[i] = buf[n-i-1];
  }
  return result;
}

void free_result(void) {
  free(result);
}

 見ての通り、受け取ったバイト列をreverseする関数reverse_bytesを定義しています。

 free_result()の役割については、以下の記事を参考にしてください。

【python】ctypesはmallocをfreeしてくれない - 静かなる名辞

 目ざとい方は、「せっかくポインタ渡ししてるのに、mallocでもう一つ領域を作るのは無駄ではないか」と気づかれたかもしれません。が、pythonではバイト列にしろ文字列にしろimmutableです。書き換えても落ちはしないようですが、どこにも反映されません。怖いのでこの領域を使ってロジックを書くつもりにもなれません。やってくる値は実質的に値渡しになると解釈すべきです。

 なお、mallocを使わない方法として、ctypes.create_string_buffer()というものも存在しているようです。使い方がよくわからないので使っていませんが、使ったほうがスマートかもしれません。パフォーマンスの良し悪しは不明です(今後の宿題とします)。

 追記:「宿題」について検証しました。こちらの記事も御覧ください。
【python】ctypesのcreate_string_buffer()を使ってみる - 静かなる名辞

 このコードは以下のようにコンパイルし、共有ライブラリ化します(gcc以外のコンパイラをお使いの方はコンパイラに合った方法で実行してください)。

$ gcc -shared -fPIC lib1.c -o lib1.so

 さて、次はこれを使うpythonプログラムを作ります。

 最初に考えるべきは、bytesを何型として受け渡しするかということです。ctypesではc_char_pという型が用意されていますが、これはNULL終端のchar *データ(つまりCの普通の文字列)を表します。バイト列として汎用的に色々なデータを受け渡ししたい場合では、途中で0が入るとそこでデータが途切れてしまい、使えません。これはドキュメントにも書いてあることです。そこで、ctypes.POINTER(ctypes.c_char)という型を使います。

 それ以外にそれほど難しいことはなく、ctypesのドキュメントやネットの記事などを参考にすれば次のようなコードを作ることができます。随所に解説を入れています。

import ctypes

cptr = ctypes.POINTER(ctypes.c_char)  # charポインタ型として型を定義しておく
lib = ctypes.cdll.LoadLibrary('./lib1.so') 
lib.reverse_bytes.argtypes = (ctypes.c_int, cptr)  # 引数の型
lib.reverse_bytes.restype = cptr  # 返り値の型

def reverse_bytes(buf):
    """ラッパー関数
    """
    n = len(buf)
    result = lib.reverse_bytes(n, buf)[:n]  # スライス操作によってpythonのbytes型に変換される
    lib.free_result()  # mallocした領域をfree
    return result

if __name__ == "__main__":
    print(reverse_bytes(b"\x00\x01\x02\x03"))  # b"\x00\x01\x02\x03"というバイト列をreverseしてみる|
    # => b'\x03\x02\x01\x00'

 この程度でしっかり動作します。

 型の設定などで起こり得る凡ミスを防ぐために、Cで定義した関数には対応するラッパー関数を作り、それを呼ぶようにします。間違っても直接呼び出したりはしないように。実際の開発ではラッパーをまとめたモジュール等を作ると良いでしょう。

文字列の受け渡し

 次に、文字列を受け渡しすることを考えます。

 基本的には上と同じ方針で書けます。特にASCIIの範囲に収まる文字列は楽です。汎用的に使いたい場合は、もう少し工夫が要ります。

ASCIIでいける文字列の場合

 まずはCの関数を少し改造します。

lib2.c

#include <stdlib.h>

char *result;

char *reverse_bytes(int n, char *buf) {
  int i;

  result = (char *)malloc(sizeof(char)*(n+1));
  for (i=0; i<n; i++) {
    result[i] = buf[n-i-1];
  }
  result[n] = '\0';
  return result;
}

void free_result(void) {
  free(result);
}

 コンパイル手順。

$ gcc -shared -fPIC lib2.c -o lib2.so

 pythonコードは次のようにします。

import ctypes

lib = ctypes.cdll.LoadLibrary('./lib2.so')
lib.reverse_bytes.argtypes = (ctypes.c_int, ctypes.c_char_p)  # 型が変わる
lib.reverse_bytes.restype = ctypes.c_char_p

def reverse_bytes(buf):
    n = len(buf)
    result = lib.reverse_bytes(n, buf)
    lib.free_result()
    return result

if __name__ == "__main__":
    print(reverse_bytes(b"hoge"))  # => b'egoh'

 注意点として、この場合はあくまでもbytesを渡す必要があります。

if __name__ == "__main__":
    print(reverse_bytes("hoge")) 

 のようにstrを渡すと、

Traceback (most recent call last):
  File "test2.py", line 13, in <module>
    print(reverse_bytes("hoge"))  # => b'egoh'
  File "test2.py", line 8, in reverse_bytes
    result = lib.reverse_bytes(n, buf)
ctypes.ArgumentError: argument 2: <class 'TypeError'>: wrong type

 のようにエラーが出ます。

 もしASCIIの範囲で表現された文字列を取り扱いたい場合、

if __name__ == "__main__":
    print(reverse_bytes("hoge".encode("ascii")))  # => b'egoh'

 のように明示的にASCIIに変換することをおすすめします(UTF-8になるならただの"hoge".encode()でも十分だけど)。

strを渡す

 strを渡したい場合、C言語側ではwchar_t *で処理することになります。ctypesの側の型はctypes.c_wchar_pです。

 先に白状しておくと、私はC言語でwchar_t *をちゃんと取り扱うためのお作法を知りません。知りませんが、面倒くさい部分はライブラリが吸収してくれると信じて、今まで通り(=上の方法と比べて最小限のコード変更で)やってみます。とりあえず動くことは確認していますが、ツッコミは大歓迎です。変なところを見つけたらご指摘ください。

lib3.c

#include <stdlib.h>
#include <wchar.h>

wchar_t *result;

wchar_t *reverse_str(int n, wchar_t *buf) {
  int i;

  result = (wchar_t *)malloc(sizeof(wchar_t)*(n+1));
  for (i=0; i<n; i++) {
    result[i] = buf[n-i-1];
  }
  result[n] = L'\0';
  return result;
}

void free_result(void) {
  free(result);
}

 コンパイル手順。

$ gcc -shared -fPIC lib3.c -o lib3.so
import ctypes

lib = ctypes.cdll.LoadLibrary('./lib3.so')
lib.reverse_str.argtypes = (ctypes.c_int, ctypes.c_wchar_p)  # 型が変わる
lib.reverse_str.restype = ctypes.c_wchar_p

def reverse_str(buf):
    n = len(buf)
    result = lib.reverse_str(n, buf)
    lib.free_result()
    return result

if __name__ == "__main__":
    print(reverse_str("hogefugaほげふが"))  # => がふげほagufegoh

 はい、動きました。簡単ですね*1

 せっかくなので、pythonのスライスで愚直にreverseしたときと比べてどれくらいの速度差が生じるのか測ってみます。

import timeit
import ctypes

lib = ctypes.cdll.LoadLibrary('./lib3.so')
lib.reverse_str.argtypes = (ctypes.c_int, ctypes.c_wchar_p)  # 型が変わる
lib.reverse_str.restype = ctypes.c_wchar_p

def reverse_str(buf):
    n = len(buf)
    result = lib.reverse_str(n, buf)
    lib.free_result()
    return result

if __name__ == "__main__":
    s = "hogefugaほげふが"*(10**3)
    print(s[::-1] == reverse_str(s))
    print(timeit.timeit(lambda : reverse_str(s), number=10**3))
    print(timeit.timeit(lambda : s[::-1], number=10**3))

""" => 結果
True
0.046704520999810484
0.009301142999902368
"""

 さすがに組み込みは速い!

 いや、良いのか、そんなオチで・・・。

 このように、簡単な処理では真価を発揮しません。ある程度複雑な処理内容で、組み込みの処理やnumpyでは処理できず、cython持ち出すのもなんだかなぁ・・・というときに使えます。

まとめ

 こんな感じでバイト列や文字列を受け渡しできます。面白いので機会があれば使ってみてください。

 追記:こちらの記事も御覧ください。
【python】ctypesのcreate_string_buffer()を使ってみる - 静かなる名辞

*1:正直ちゃんと動くことは保証できないけど。たぶん合ってるんじゃない?程度