【Python】使用C语言编写Python扩展模块,史诗级详细

1.前言

python已经成为了我工作中最主要的语言,从ETL到实时计算,从web后端到机器学习,我都非常乐意使用python。但是在进行计算非常密集的任务的时候,总觉得使用python不够极致,所以时常用c语言来实现部分反复执行的片段。这样:

  1. 兼顾开发效率与运行效率,达到平衡;
  2. 另外c语言得天独厚的能够访问系统底层的特性,也为python添上了控制系统的可能;
  3. 实际上numpy等优秀的计算框架都是如此实现的。

Python提供了完整的解决方案,允许开发者使用c/c++编写高度原生的扩展,就像内置的strlist等类型一样。本文将手把手教会如何用C语言来编写python的扩展。

预期效果

实现一个简单的User类,可以学习到使用c语言来处理python字符串常规数据类型
在python中使用就像如下所示:

# 我们将要定义的module,命名为mymodule
import mymodule

# 创建User类
user = mymodule.User(10001, "猪八戒", 18, "高老庄22号")
# 使用User类的方法,user_info实现了整理并返回User类中的成员。
print(user.user_info())

结果:

10001 猪八戒 18 高老庄22号

2.编辑环境配置

因为我们后面会使用python的setup文件来进行模块安装,所以此处只需要配置编辑环境(引入头文件,有代码提示就可以了)。

command + shift + P,在弹出的命令框中输入> edit configurations json,选择C/C++:编辑配置(JSON)选项。
项目的根目录的.vscode文件夹下降会出现c_cpp_properties.json文件。内容如下:

{
    "configurations": [
        {
            "name": "Mac",
            "includePath": [
                "${workspaceFolder}/**",
            ],
            "defines": [],
            "macFrameworkPath": [
                "/Library/Developer/CommandLineTools/SDKs/MacOSX10.14.sdk/System/Library/Frameworks"
            ],
            "compilerPath": "/usr/bin/clang",
            "cStandard": "c11",
            "cppStandard": "c++98",
            "intelliSenseMode": "macos-clang-x64"
        }
    ],
    "version": 4
}

includePath标签下添加python根目录的include路径。以方便调用Python内部的头文件。
我的路径是"/Users/bitekong/.conda/envs/plain/include/python3.8"

...
"includePath": [
                "${workspaceFolder}/**",
                "/Users/bitekong/.conda/envs/plain/include/python3.8"
            ],
...

3.整体结构和相关概念

如果你习惯于先把程序运行起来,可以先跳到后面实战一节,再回过头来理解结构和概念。

先看一下整体结构。
不要被这只纸老虎吓到了,其实后面你会发现,大部分地方都是固定的,实际开发只需要在此基础上做少量修改即可。
且我会对下面的每一行代码做一个详细的解释。

#define PY_SSIZE_T_CLEAN
#include <Python.h>

typedef struct {
    PyObject_HEAD
    /* 此处定义其他字段. */
} UserObject;

static PyTypeObject UserType = {
    PyVarObject_HEAD_INIT(NULL, 0)
    .tp_name = "mymodel.User",
    .tp_doc = "User objects",
    .tp_basicsize = sizeof(UserObject),
    .tp_itemsize = 0,
    .tp_flags = Py_TPFLAGS_DEFAULT,
    .tp_new = PyType_GenericNew,
};

static PyModuleDef mymodelmodule = {
    PyModuleDef_HEAD_INIT,
    .m_name = "mymodel",
    .m_doc = "Example module that creates an extension type.",
    .m_size = -1,
};

PyMODINIT_FUNC
PyInit_mymodel(void)
{
    PyObject *m;
    if (PyType_Ready(&UserType) < 0)
        return NULL;

    m = PyModule_Create(&mymodelmodule);
    if (m == NULL)
        return NULL;

    Py_INCREF(&UserType);
    if (PyModule_AddObject(m, "User", (PyObject *) &UserType) < 0) {
        Py_DECREF(&UserType);
        Py_DECREF(m);
        return NULL;
    }

    return m;
}

我们把这串代码拆分一下:

1.定义内部字段

typedef struct {
    PyObject_HEAD
    /* 此处定义其他类型字段. */
} UserObject;

显而易见这是一个结构体,相当于python中定义class的字段,在初始化函数中根据这个模版分配内存并产生新的类。
其中,PyObject_HEAD宏帮助我们定义好了这个结构体内在的层级和调试时使用到的一些额外的字段。我们可以不用管他具体是做什么的,只需要知道它赋予了这个结构体一些必要的功能就好了。
/* 此处定义其他字段. */处,才是我们定义自定义字段的地方,例如:

typedef struct {
    PyObject_HEAD
    double im_a_variable;
} PyFloatObject;

2.定义调用类

static PyTypeObject UserType = {
    PyVarObject_HEAD_INIT(NULL, 0)
    .tp_name = "mymodel.User",
    .tp_doc = "User objects",
    .tp_basicsize = sizeof(UserObject),
    .tp_itemsize = 0,
    .tp_flags = Py_TPFLAGS_DEFAULT,
    .tp_new = PyType_GenericNew,
};

第二段是用来定义我们所编写的c语言函数python如何调用。

  1. PyVarObject_HEAD_INIT(NULL, 0):初始化1.中所提到的结构体,也是固定的写法,不用特别在意。
  2. .tp_name = "mymodel.User",:类名。
  3. .tp_doc = "User objects",:此处的字符串降作为模块的python doc出现,可在python中通过__doc__调用。
  4. .tp_basicsize = sizeof(UserObject),:为了告诉python需要分配多少内存。
  5. .tp_itemsize = 0,:如果不是长度可以变化的类,此处总是为0。
  6. .tp_flags = Py_TPFLAGS_DEFAULT,:定义类的类型掩码,所有的类都应该包含Py_TPFLAGS_DEFAULT,所以也是固定写法,不用特别在意。后面会引入一些其他类型掩码。
  7. .tp_new = PyType_GenericNew,:要创建对象,必须提供tp_new处理程序,相当于Python方法__new__(),但必须显式指定。在本例中,我们使用API函数PyType_GenericNew()提供的默认实现。

实际上PyTypeObject的参数比上述例子要多很多,但是其他参数用得不多,大家可以参考官方文档进行自定义,也都是对类如何调用做一些说明。

3.定义模块

static PyModuleDef mymodelmodule = {
    PyModuleDef_HEAD_INIT,
    .m_name = "mymodel",
    .m_doc = "Example module that creates an extension type.",
    .m_size = -1,
};

此处定义上述c语言函数所属的模块。

  1. PyModuleDef_HEAD_INIT:初始化Python模块的宏方法,固定写法。
  2. .m_size = -1,:含义不明,固定-1

4.初始化函数

PyMODINIT_FUNC
PyInit_mymodel(void)
{
    PyObject *m;
    if (PyType_Ready(&UserType) < 0)
        return NULL;

    m = PyModule_Create(&mymodelmodule);
    if (m == NULL)
        return NULL;

    Py_INCREF(&UserType);
    if (PyModule_AddObject(m, "User", (PyObject *) &UserType) < 0) {
        Py_DECREF(&UserType);
        Py_DECREF(m);
        return NULL;
    }

    return m;
}

此处定义init方法,即创建模块,再将C语言所编写的类挂载到模块上。
Py_INCREFPy_DECREF用来增加对相应对象的引用计数。此函数返回NULL将会出发python端抛出异常。

最后,我们将上述代码编译为python的包。python有内置编译工具。
新建setup.py文件,配置如下:

from distutils.core import setup, Extension

setup(name="mymodel",   # 命名编译、安装到python库中的文件名 mymodel-1.0-py3.8.egg-info
      version="1.0",    # 命名编译、安装到python库中的文件名 mymodel-1.0-py3.8.egg-info
      include_dirs="/Users/bitekong/.conda/envs/plain/include/python3.8",  # 导入python头文件
      ext_modules=[Extension("mymodel", ["main.c"])]   # 此处mymodel必须与PyInit_mymodel后方命名一致。
      )

4.编译、生成python模块:

// 仅编译,生成在build下
python setup.py build

// 编译并安装
python setup.py install

/Users/bitekong/.conda/envs/plain/lib/python3.8/site-packages/mymodel-1.0-py3.8.egg-info

5.实战

但是我们好像什么都没做。下面我们在上述结构上添加一点内容,我们编写一个User类。
完整代码如下,可以将下面代码拷贝到vscode中,使用刚刚介绍的编译方法编译,再导入python中看看效果。

main2.c文件

#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include "structmember.h"

typedef struct
{
    PyObject_HEAD // 单独一行
        int id;
    PyObject *username;
    int age;
    PyObject *address;
} UserObject;

static void User_dealloc(UserObject *self)
{
    // 每一个PyObject都需要显式地减少对象的引用。
    Py_XDECREF(self->username);
    Py_XDECREF(self->address);

    // 释放UserObject的其他字段内存。
    Py_TYPE(self)->tp_free((PyObject *)self);
}

// new函数,负责根据传入参数初始化类。
// args和kwds的含义与python中的定义一致。
static PyObject *User_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    // 创建self指针,并分配和type相符的内存。
    UserObject *self;
    self = (UserObject *)type->tp_alloc(type, 0);

    if (self != NULL)
    {
        // username初始化为空字符串
        self->username = PyUnicode_FromString("");
        // 如果分配失败,则返回NULL,返回NULL是在python端抛出异常的信号。
        if (self->username == NULL)
        {
            Py_DECREF(self);
            return NULL;
        }

        // address初始化为空字符串
        self->address = PyUnicode_FromString("");
        // 如果分配失败,则返回NULL,返回NULL是在python端抛出异常的信号。
        if (self->address == NULL)
        {
            Py_DECREF(self);
            return NULL;
        }
        self->age = 0;
    }
    return (PyObject *)self;
}

static int User_init(UserObject *self, PyObject *args, PyObject *kwds)
{
    // 参数列表
    static char *kwlist[] = {"id", "username", "age", "address", NULL};
    PyObject *username = NULL, *address = NULL, *tmp;

    if (!PyArg_ParseTupleAndKeywords(args, kwds,
                                     // 下面这个奇怪的字符串其实就是定义参数的必输
                                     // 在文末有一个简要的介绍
                                     // 此处地址可以为空
                                     "iOi|O", kwlist,
                                     // 以下取出顺序和kwlist定义的要一致
                                     &self->id,
                                     &username,
                                     &self->age,
                                     &address))
        return -1;

    if (username)
    {
        // tmp指向当前self.username所指向的地址,暂存
        tmp = self->username;
        // 分配新的变量给self.username
        Py_INCREF(username);
        self->username = username;
        // 删除之前暂存的self.username指向地址的变量
        Py_XDECREF(tmp);
    }

    if (address)
    {
        tmp = self->address;
        Py_INCREF(address);
        self->address = address;
        Py_XDECREF(tmp);
    }
    return 0;
}

static PyMemberDef User_members[] = {
    {"id", T_INT, offsetof(UserObject, id), 0,
     "用户id"},
    {"username", T_OBJECT_EX, offsetof(UserObject, username), 0,
     "用户名。"},
    {"address", T_OBJECT_EX, offsetof(UserObject, address), 0,
     "地址"},
    {"age", T_INT, offsetof(UserObject, age), 0,
     "年龄"},
    {NULL} /* Sentinel */
};

static PyObject *User_user_info(UserObject *self, PyObject *Py_UNUSED(ignored))
{
    if (self->id == NULL)
    {
        PyErr_SetString(PyExc_AttributeError, "id");
        return NULL;
    }

    if (self->username == NULL)
    {
        PyErr_SetString(PyExc_AttributeError, "username");
        return NULL;
    }

    if (self->age == NULL)
    {
        PyErr_SetString(PyExc_AttributeError, "age");
        return NULL;
    }

    // if (self->address == NULL)
    // {
    //     PyErr_SetString(PyExc_AttributeError, "address");
    //     return NULL;
    // }

    // 创建一个unicode字符串
    return PyUnicode_FromFormat("%d %S %d %S", self->id, self->username, self->age, self->address);
}

static PyMethodDef User_methods[] = {
    {"user_info", (PyCFunction)User_user_info, METH_NOARGS,
     "这段文字将显示在方法的说明文档里面。"},
    {NULL} /* Sentinel */
};

static PyTypeObject UserType = {
    PyVarObject_HEAD_INIT(NULL, 0)
        .tp_name = "mymodule.User",
    .tp_doc = "这段文字将显示在类的说明文档里面。",
    .tp_basicsize = sizeof(UserObject),
    .tp_itemsize = 0,
    .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
    .tp_new = User_new,
    .tp_init = (initproc)User_init,
    .tp_dealloc = (destructor)User_dealloc,
    .tp_members = User_members,
    .tp_methods = User_methods,
};

static PyModuleDef mymodule = {
    PyModuleDef_HEAD_INIT,
    .m_name = "mymodule",
    .m_doc = "这段文字将显示在模块的说明文档里面。",
    .m_size = -1,
};

PyMODINIT_FUNC PyInit_mymodule(void)
{
    PyObject *m;
    if (PyType_Ready(&UserType) < 0)
        return NULL;

    m = PyModule_Create(&mymodule);
    if (m == NULL)
        return NULL;

    Py_INCREF(&UserType);
    if (PyModule_AddObject(m, "User", (PyObject *)&UserType) < 0)
    {
        Py_DECREF(&UserType);
        Py_DECREF(m);
        return NULL;
    }

    return m;
}

setup.py文件

from distutils.core import setup, Extension

setup(name="mymodule",   # 命名编译、安装到python库中的文件名 mymodule-1.0-py3.8.egg-info
      version="1.0",    # 命名编译、安装到python库中的文件名 mymodule-1.0-py3.8.egg-info
      include_dirs="/Users/bitekong/.conda/envs/plain/include/python3.8",  # 导入python头文件
      ext_modules=[Extension("mymodule", ["main2.c"])]   # 此处mymodel必须与PyInit_mymodel后方命名一致。
      )

编译并安装

python setup.py install

python中使用:

import mymodule

user = mymodule.User(10001, "猪八戒", 18, "高老庄22号")
print(user.user_info())

结果:

10001 猪八戒 18 高老庄22号

补充

下面对于PyArg_ParseTupleAndKeywords函数的用法做一个阐述。
其中ss|sss实际上定义了参数的传递形式,s很自然就是字符串,i是数字,f是浮点数类型。竖线前面的是必要的,竖线后面的是可选的参数。

static PyObject *test_function(PyObject *self, PyObject *args, PyObject *kwds)
{
    char *a;
    char *b;
    char *c = NULL;
    char *d = NULL;
    char *e = NULL;

    static char *kwlist[] = {"a", "b", "c", "d", "e", NULL};

    if (!PyArg_ParseTupleAndKeywords(args, kwds, "ss|sss", kwlist, 
                                     &a, &b, &c, &d, &e)) 
    {
        return NULL;
    }

    printf("a is %s\n", a);
    printf("b is %s\n", b);
    printf("c is %s\n", c);
    printf("d is %s\n", d);
    printf("e is %s\n", e);

    Py_RETURN_NONE;
}

以下是python中的使用效果,当没有传递参数a和参数b时,会像通常在python中定义的一样报错。

test_function()     # missing required argument 'a' (pos 1)
test_function('AA') # missing required argument 'b' (pos 2)
test_function('AA', 'BB')
test_function('AA', 'BB', c='CC', d='DD', e='EE') 
test_function('AA', 'BB', 'CC', 'd', 'DD')
test_function(a='AA', b='BB', c='CC', d='DD', e='EE')

抛砖引玉,才疏学浅,如有纰漏,欢迎指正。