🔨

C API で Python 拡張モジュールをつくる

2024/01/06に公開

概要

CPython は Python インタプリタの拡張を記述するための C API を備えていて、C言語で Python の拡張モジュールを書くことができます。身近なところでは、numpy のような C で書かれたライブラリなどもこれにあたります。
この記事ではそのような Python の拡張モジュールを C API を使って記述するための方法をごく簡単なサンプルを使って説明します。

C API の知識は拡張モジュールを書くための基礎となるのに加えて、CPython の内部で用いられているデータ構造に触れることになるので CPython インタプリタ実装を読むにあたっての入り口にもなると思います。

想定する読者

  • なんらかの事情で C API を使った Python 拡張モジュールを開発する必要がある。
  • Python の他言語 (特に C言語) 連携の仕組みを知りたい。
    • 特に、Cython 等がどのようなコードを吐いているのか知りたい。
  • numpy のような C API を使って書かれているライブラリの中身を読みたい。
  • CPython の実装を読めるようになるための手がかりが欲しい。

⚠️注意

新しく既存コードの高速化などの目的で Python の拡張モジュールを開発するにあたって C API を直に使って書くことは推奨はしません。おそらく、基本的には Cython 等を使ったほうが容易に開発が行えて、Python 側との連携も行い易いはずです。

また最近では、Python の拡張モジュールを開発するにあたって C言語を用いる以外にも PyO3/maturin を用いて Rust で開発する方法などもあります。こちらも有力な選択肢になるでしょう。

検証環境

  • M2 Mac Ventura 13.3.1
  • Python 3.11.6 (homebrew)

Hello World

ここでは、最も簡単な拡張モジュールの例として "Hello, World!" を出力するだけのものを C言語で作成し、Python から呼び出すまでの流れを説明します。
単純な例ではありますが、構造自体は複雑なモジュールにも通用するはずです。

モジュール名は hello として、hello モジュール内に "Hello, World!" を出力するメソッド say_hello() をつくります。

ファイルの命名規則

拡張モジュールを実装する Cファイルは慣習的に <module-name>.c または <module-name>module.c という名前で作成します[1]
ここでは、hellomodule.c として続けます。

コーディングスタイルについて

Python コード向けのスタイルガイド PEP8 は有名ですが、実は Cコード向けにもスタイルガイド PEP7 があります。
返り値型と関数名の間で改行するところなどやや見慣れないかもしれませんが、今回は基本的に PEP7 にのっとったスタイルで記述していきます。

Python.h

まずはじめに、Python の拡張モジュールを開発するのに必要な型, マクロ, 関数などをヘッダファイルから読みこみます。hellomodule.c の先頭に以下の行を記述します。

#define PY_SSIZE_T_CLEAN
#include <Python.h>

Python.h は Python のインストール方法によっては自動的にはインストールされていないこともあります。もしビルド時に Python.h: No such file or directory といったエラーが出る場合にはインストール作業を行ってください (参考)。

C の関数を書く

次に、メソッドの実装の実体となる C の関数を書いてみましょう。
ここでは、printf() で "Hello, World!" を出力するだけです。

static void
say_hello()
{
    printf("Hello, World!\n");
}

Python から呼び出せる関数を書く

上で書いた C の関数は直接は Python から呼び出せません。なぜなら、Python から呼び出される関数は引数として Python のオブジェクトを受けとり、Python のオブジェクトを返すようなものでなければならないからです。
ここでは、上の関数の呼び出しをラップして Python から直接呼び出せる関数を書きます。

static PyObject *
pyc_say_hello(PyObject *self, PyObject *args)
{
    say_hello();
    Py_RETURN_NONE;
}

PyObject は Python のオブジェクトを扱う型です。Python から呼び出される関数では、引数や返り値は PyObject としてやりとりします。今回は引数を使わないので中身について気にする必要はありませんが、とりあえず上の形式に合わせておく必要があります。また、返り値についてもここでは特に返すべき値がないので、Py_RETURN_NONE マクロを用いて Python の None を返します。

モジュールをつくる

最後に、ここまででつくった関数をモジュール経由で Python 側から呼び出せるように登録します。
まず、モジュールが提供するメソッドの一覧を PyMethodDef の配列としてつくります。
PyMethodDef は、

  • Python から呼び出す際のメソッド名
  • 対応する C関数へのポインタ
  • 引数の規約 (後述)
  • docstring

の 4つをまとめた構造体です。
メソッド一覧の最後の要素は終了の目印としてメソッド名が NULL のオブジェクトを置きます。

static PyMethodDef hello_methods[] = {
    // Method name, C function, Argument type, Docstring
    {"say_hello", pyc_say_hello, METH_NOARGS, PyDoc_STR("Greeting from C!")},
    {NULL, NULL, 0, NULL}  // sentinel
};

モジュール名やメソッドの一覧を登録した PyModuleDef オブジェクトを作成します。
これを PyInit_<module-name>() という関数で初期化してやれば完成です。

static struct PyModuleDef hellomodule = {
    PyModuleDef_HEAD_INIT,                  // always initialize to this value
    "hello",                                // module name
    PyDoc_STR("Test module for greeting"),  // docstring (may be NULL)
    0,                                      // module state size
    hello_methods,
};

PyMODINIT_FUNC
PyInit_hello()
{
    return PyModule_Create(&hellomodule);
}

PyMODINIT_FUNC はモジュールの初期化関数を宣言するためのマクロで、PyObject * を返すような外部に公開する関数を宣言します。

ビルド

ここからは実際に拡張モジュールをビルド・インストールして Python から呼び出してみます。
利便性のためここまでに記述した拡張モジュールのコード全体を以下の折り畳みに置いてあります。

ここまでのコード全体
#define PY_SSIZE_T_CLEAN
#include <Python.h>

static void
say_hello()
{
    printf("Hello, World!\n");
}

static PyObject *
pyc_say_hello(PyObject *self, PyObject *args)
{
    say_hello();
    Py_RETURN_NONE;
}

static PyMethodDef hello_methods[] = {
    // Method name, C function, Argument type, Docstring
    {"say_hello", pyc_say_hello, METH_NOARGS, PyDoc_STR("Greeting from C!")},
    {NULL, NULL, 0, NULL}  // sentinel
};

static struct PyModuleDef hellomodule = {
    PyModuleDef_HEAD_INIT,                  // always initialize to this value
    "hello",                                // module name
    PyDoc_STR("Test module for greeting"),  // docstring (may be NULL)
    0,                                      // module state size
    hello_methods,
};

PyMODINIT_FUNC
PyInit_hello()
{
    return PyModule_Create(&hellomodule);
}

ビルドにはCコンパイラが必要です。ビルドツールとして今回は build および setuptools を用います。このあたりのツールの役割については拙著の以下の記事などをご参考にしてください。

https://zenn.dev/sankantsu/articles/1cddd3c08582a1

まず、ビルドの設定ファイルとして pyproject.tomlsetup.py を記述します。

  • ディレクトリ構成
.
├── hellomodule.c
├── pyproject.toml
└── setup.py
  • pyproject.toml
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "hello"
version = "0.1.0"
  • setup.py
from setuptools import setup, Extension

setup(
    ext_modules=[
        Extension(
            name="hello",
            sources=["hellomodule.c"],
        ),
    ]
)

この状態で python3 -m build を実行すると dist/ 以下に配布可能な形式のファイルが生成されます。
最後に pip install dist/hello-0.1.0-xxx.whl などを実行すれば hello モジュールがインストールされて、import できる状態になります。

>>> import hello
>>> hello.say_hello()
Hello, World!

引数の扱い

先ほどの say_hello() は引数なしの関数でした。実用的には Python 側からの呼び出しの際に引数を渡したり、Python のオブジェクトを返したりしたくなることが多いでしょう。
ここでは、引数として渡される Python オブジェクトの扱いについて詳しく見ていきます。

引数なし

メソッドに対する引数の渡し方の種類は、PyMethodDef の3番目のフィールド (ml_flags) で指定します。
引数なしの場合は METH_NOARGS を指定します。この場合は引数の解析等は必要ありません。もし実行時に引数が渡されると以下のようなエラーになります。

>>> hello.say_hello(123)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: hello.say_hello() takes no arguments (1 given)

位置引数のみ

位置引数のみの場合は、ml_flagsMETH_VARARGS を指定します。この場合、Python から呼び出された関数にはひとつ目の引数で呼び出し元のオブジェクト (self)、ふたつ目の引数で呼び出し時の引数をタプルにまとめたもの (args) が渡されます。

static PyObject *func(PyObject *self, PyObject *args);

引数のタプル (args) から値を取りだすには、PyArg_ParseTuple() という関数を用います。

int PyArg_ParseTuple(PyObject *args, const char *format, ...);

args は引数として受けとってきたタプルで、format はタプルの構造を表す format string です。以降の引数にはタプルから取り出した値を格納するためのポインタを入れていきます。
format string は単純な場合にはタプルの1要素ごとに型に対応した文字 (e.g. int なら i, 文字列なら s など) を並べたものです。正確な書式はこちらを参照ください。
以下は、2つの整数の足し算を行う関数の例です。

static int
add(int a, int b)
{
    return a + b;
}

static PyObject *
pyc_add(PyObject *self, PyObject *args)
{
    int a, b;
    if (!PyArg_ParseTuple(args, "ii", &a, &b)) {  // (a)
        return NULL;
    }
    int ans = add(a, b);
    return PyLong_FromLong(ans);  // (b)
}

(a) の行は、引数args を整数2つのタプル ("ii") として解釈し、結果を a, b に格納します。タプルの解析に失敗した場合は NULL を返していますが、これは PyArg_ParseTuple() で発生した例外 (典型的には TypeError) をそのままインタプリタに伝える意味になります。
(b) の行は、a, b を足した整数値を PyLong_FromLong() で Python の整数オブジェクトに変換して返しています。
より複雑な例についてはこちらを参考にしてください。

位置引数 + キーワード引数

位置引数に加えてキーワード引数を受け取りたい場合は、ml_flagsMETH_VARARGS | METH_KEYWORDS を指定します。この引数規約を用いる場合、引数を受け取る関数は self, args に加えてキーワード引数 kwargs を受け取る関数として書きます。

static PyObject *func(PyObject *self, PyObject *args, PyObject* kwargs);

引数の解析には PyArg_ParseTupleAndKeywords() を用います。シグネチャはやや複雑ですが次で与えられます。

int PyArg_ParseTupleAndKeywords(PyObject *args, PyObject *kw, const char *format, char *keywords[], ...);

args, kw には基本的に外から引数として受け取った args, kwargs をそのまま渡します。formatPyArg_ParseTuple() で使ったものと同様です。keywords はキーワード引数の名前を左から順番に列挙します (NULL 終端)。

以下は単純な例として、say_hello の出力の "Hello, World!" の "World" の部分を name というキーワード引数で受け取れるようにしたものです。

static PyObject *
say_hello(PyObject *self, PyObject *args, PyObject *kwargs)
{
    const char *name;
    char *kwlist[] = {"name", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "s", kwlist, &name)) {
        return NULL;
    }
    printf("Hello, %s!\n", name);
    Py_RETURN_NONE;
}

この例では、say_hello() への引数は say_hello("World") のように位置引数として渡しても say_hello(name="World") のようにキーワード引数として渡しても動作します。
より複雑な例についてはこちらを参考にしてください。

まとめ

この記事では、C API を使って Python 拡張モジュールを開発する方法を簡単なサンプルを通して説明しました。例自体はかなり単純でしたが、Python と C を連携させるための基本的な構造は複雑なモジュールになっても変わらないはずです。
C API の基本的な知識があれば numpy のような拡張モジュールの実装や、CPython 自体の実装についても読むための手がかりをつかめるようになるのではないかと思います。

Further Reading

C API についての公式ドキュメントは以下の 2つが中心となります。前者が User guide に相当するもので、後者が API のリファレンスマニュアルです。

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

https://docs.python.org/3/c-api/index.html

特に、以下のページに C API で拡張モジュールを開発するための基本的な情報がまとまっています。本格的に拡張モジュールを開発する場合は一通り読んでおくことをおすすめします。

この記事で扱わなかった部分として、以下の内容などがあります。

  • エラー・例外処理 (1.2)
  • C の値から Python オブジェクトを組みたてる (1.9)
  • Reference count (1.10)
  • C++ を使う (1.11)

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

リファレンスマニュアルは必要に応じて読む形にはなると思いますが、知っておくと便利なページとして以下をひとつ挙げておきます。整数、文字列、リスト、辞書、モジュールなど各オブジェクトについての API がまとまっています。

https://docs.python.org/3/c-api/concrete.html

脚注
  1. Extending Python with C or C++ (引用) Historically, if a module is called spam, the C file containing its implementation is called spammodule.c; if the module name is very long, like spammify, the module name can be just spammify.c. ↩︎

  2. cppreference.com: Storage-class specifiers ↩︎

  3. Extending Python with C or C++: The Module’s Method Table and Initialization Function ↩︎

  4. setuptools User guide (Info: Using setup.py) ↩︎

  5. Python Packaging User Guide: Is setup.py deprecated? ↩︎

GitHubで編集を提案

Discussion