使用C++编写Python扩展

  • Post author:
  • Post category:python


简介:Python 中调用 C/C++ 程序的方法有多种,这里简单介绍使用 C/C++ 编写 Python扩展供 Python 中使用的方法。相较于使用 ctypes 加载 C/C++ 程序编译的动态库进而调用函数,扩展模块的方式,从Python 中传入参数以及从 C/C++ 程序获取返回值的过程更为规范,或者说,更能够减少程序出现错误。使用 ctypes 调用程序,在多线程 Python 程序中容易产生段错误(segfault)。



1. 扩展模块的作用

C 扩展可以用来做两件不能直接在 Python 中做的事情:

构建新的内置对象类型、调用 C 库函数和系统调用



为了支持扩展, Python 的 API 定义了许多函数、宏和变量,可以访问 Python 运行时系统的大部分内容。Python 的相关 API 可以通过在 C 文件中引入 “Python.h” 文件使用。

C 扩展接口依赖于 CPython ,扩展模块无法在其他 Python 实现上工作,因此可移植性较差。



2. 用 C++ 实现 Python 扩展



2.1 引入 Python API

如前所述,在 C++ 程序中使用 Python 的 API 需要在 C++ 程序中引入头文件 “Python.h”。而且由于 Python 可能会定义一些能在某些系统上影响标准头文件的预处理器定义,因此在包含任何标准头文件之前,

必须

先包含 Python.h。推荐总是在 Python.h 前定义 PY_SSIZE_T_CLEAN,实践证明,构建扩展后续是依赖这个宏定义的。因此 C++ 程序的文件头部应当如下所示:

#define PY_SSIZE_T_CLEAN
#include <Python.h>



2.2 添加 C++ 函数到扩展模块

自己定义的模块名称为 demo ,文件名称按照习惯可以命名为 demomodule.cpp,也可以不这么做,模块名的决定性因素为下边 PyModuleDef 类型的结构体。在模块 demo 下可以调用的函数,其函数名格式为 demo_funcname,如下 demo_myFunc 为 demo 模块下 myFunc 函数的定义。一个模块可以定义多个函数。


函数的参数列表

形式固定,均为(PyObject *self, PyObject *args):

这里self与常用python类中定义函数的 self 类似,无需多看,这里的 args 则是函数接收的参数列表。

具体接收参数的内容,需要在函数定义内部 PyArg_ParseTuple 函数中确定。此函数负责解析在 Python 中调用模块中函数时接收的参数,将其转换为对应的 C 类型数据。


函数返回值



定义的函数可以返回对象,也可以直接返回数据。

如果返回的是数据,则需要使用 Py_BuildValue 函数,将 C 类型的数据转换为 Python 中的数据类型。此函数的作用与上述函数 PyArg_ParseTuple 的作用相反。

如果返回的是对象,则意味这需要在此函数中构建一个对象,之后将对象返回给 Python,这时候需要注意给对象增加引用计数,否则会导致内存泄漏的问题。

参见

这里

的7-10

添加 C++ 函数到 扩展模块的源码如下:

static PyObject *demo_myFunc(PyObject *self, PyObject *args)
{
    /* 
     * 这里是一些数据准备工作,用于接收从 Python 传递来的参数,给需要调用的 C++ 函数使用,这里的参数
     * 接收需要一些参数解析 
     */
    unsigned char *cipher_text;
    int cipher_text_length;
    unsigned char *iv_salt_file_path;
    unsigned char result[DEFAULT_RSA_KEY_LEN] = {0};
    int len;
    
    /* 
     * 对接收的参数进行解析,格式转换参见: https://docs.python.org/zh-cn/3/c-api/arg.html 
     * 这里的 s* 为接收 Python 中字符串或者 byte 类型的参数,将其解析为缓冲区中的内容, 这里的 i 
     * 为将 Python 中的整型解析为 C++ 中的整型
     */
    if (!PyArg_ParseTuple(args, "s*is*", &cipher_text, &cipher_text_length, &iv_salt_file_path)){
        return NULL;
    }
    /* 
     * 这里是对 C++ 函数的调用,不难发现,这里所使用的数据都是根据 Python 中传来的参数进行构建的,
     * 而不是直接使用 Python 的那一份数据。由于 Python 只能管理自己的空间而不能管理其调用的外部函
     * 数的空间,如果在外部函数直接使用原始的数据,可能导致在回收空间时出现问题,而且这种问题
     * 很难发现。传入参数、返回值,都是根据一方函数中的构建,能够将 Python 与扩展中所使用空间良
     * 好分离,避免互相影响。
     */
    len = myFunc_c(cipher_text, cipher_text_length, iv_salt_file_path, result);
    /* 如果这里的 myFunc_c 是 C++ 中的函数, C++ 函数还需要其他相关依赖,则也需要引入,可以通过引入头文件的方式  */


    if (len < 0) {
        PyErr_SetString(DemoError, "C function execution failed!");
    }
    /* 
     * 将 C++ 的 buf 中的内容解析为 Python 中的字符串类型,即根据 C++ 中的数据为 Python 调用构建返回值 
     * 注意这里返回的值是根据 C++ 程序中的数据进行构建,而不是从 Python 中直接传入一个参数,之后修改这个
     * 参数后返回,这样内存回收时可能会有问题。
     * 注意,如果这里返回的是一个对象,还需要注意引用计数相关的问题
     */
    return Py_BuildValue("s", result);
}



2.3 模块相关结构体定义



2.3.1 模块方法表

模块中自定义方法的相关信息都需要在此结构中注册。

static PyMethodDef DemoMethods[]=
{
   /* 
    * 模块中的函数、对应的函数名称。注意第三个参数 ( METH_VARARGS ) ,这个标志指定会使用 C 的调用惯例。
    * 可选值有 METH_VARARGS 、 METH_VARARGS | METH_KEYWORDS 。值 0 代表使用 PyArg_ParseTuple() 的陈旧变量。 
    */
   {"myFunc", demo_myFunc, METH_VARARGS,"This function is used for test extension in python."},
   {NULL,NULL, 0,NULL}    /* 结构体固定格式结尾*/
};


2.3.2 模块定义结构体

上述模块方法表必须被模块定义结构所引用,对应此模块定义的最后一行,而且这个结构体必须传递给解释器的模块初始化函数。

static struct PyModuleDef demomodule = {
    PyModuleDef_HEAD_INIT,
    "demo",    /* 扩展模块名 */
    NULL,     /* 扩展模块文档,可以为空 */
    -1,       /* size of per-interpreter state of the module, or -1 if the module keeps state in global variables. */
    DemoMethods /* 2.3.1 中对应的模块方法表*/
};



2.4 模块初始化函数

此函数是初始化模块所用,扩展是 C++ 程序,为使 Python 能够调用此模块,编译之前需要使用 extern “C” 将其包裹起来,此函数名称格式为 PyInit_modulename,如这里的模块名称为 demo,对应的初始化函数名称为 PyInit_demo。

extern "C" PyMODINIT_FUNC PyInit_demo()
{
    PyObject *m;
    /*这里的引用是上述 2.3.2 中的模块定义结构体的引用 */
    m = PyModule_Create(&demomodule);
    if(m == NULL)
        return NULL;
    /* 可以自定义异常 */
    DemoError = PyErr_NewException("demo.error", NULL, NULL);
    Py_INCREF(DemoError);
    PyModule_AddObject(m, "error", DemoError);
    return m;
}

上述函数、结构体的相互依赖关系,决定了在实现扩展模块的 C++ 程序中各个功能结构实现的先后顺序。至此,扩展模块的实现就已经完成了。



3. 使用 distutils 构建 C 和 C++ 扩展



3.1 构建脚本

扩展模块可以用 distutils 来构建,这是Python自带的。distutils 包含一个驱动脚本 setup.py,如下所示:

from distutils.core import setup, Extension

module1 = Extension('demo',                      # 扩展模块名称
                    sources = ['demomodule.cpp'])      # 扩展模块对应的 C++ 文件

setup (name = 'PackageName',                     # 这里会影响到安装扩展时,在本地 Python 中的 egg-info 文件名称,可以自己定义
       version = '1.0',                          # 扩展模块的版本号信息,可以自定义
       description = 'This is a demo package',   # 这是关于扩展的相关介绍
       ext_modules = [module1])                  # 这里的 module1 对应与上边的 module1

上述代码是一个简单版本的构建脚本,实际上,它可以还可以接受更多的参数。如通常我们在编译 C++ 程序时,需要通过

-I



-L

参数指定编译所需要的一些头文件和库文件的路径,以及使用

-l

指定需要链接的库(包括动态库和静态库),在 setup.py 脚本中,也需要指定这些参数(取决于使用的 C++ 程序是否需要)。同时,在 setup 函数中还可以指定更多信息,开发者可以根据需要添加,如下是一个包含更多信息的示例:

from distutils.core import setup, Extension

module1 = Extension('demo',
                    define_macros = [('MAJOR_VERSION', '1'),          # 编译时设置一些宏定义
                                     ('MINOR_VERSION', '0')],
                    include_dirs = ['/usr/local/include'],            # 指定需要引入的头文件位置
                    libraries = ['tcl83'],                            # 需要链接的库,比如加密程序需要的库为ssl、crypto(-l参数后的内容)
                    library_dirs = ['/usr/local/lib'],                # 指定需要使用的库文件位置
                    sources = ['demomodule.cpp'])

setup (name = 'PackageName',
       version = '1.0',
       description = 'This is a demo package',
       author = 'Martin v. Loewis',                                   # 这里可以添加开发者相关的一些信息、网址以及详细的描述
       author_email = 'martin@v.loewis.de',
       url = 'https://docs.python.org/extending/building',
       long_description = '''
This is really just a demo package.
''',
       ext_modules = [module1])



3.2 开始构建

到这里,我们已经准备好了扩展的实现代码—— C++ 程序,以及执行构建需要使用的脚本文件—— setup.py文件,接下来,执行构建命令即可:

python3 setup.py build

在首次构建时,能够发现,其编译过程实质上就是使用 g++ 对程序进行编译的过程,构建脚本在执行过程中,会添加很多相关的编译参数。

执行

build

之后,会发现当前目录下多了一个 build 文件夹,此文件夹之下还有两个子文件夹,分别存储编译过程中的中间文件(temp目录)和结果文件(lib目录)。temp 目录下的文件,就是 编译过程中 C++ 程序对应的

.o

文件,而 lib 目录下,则对应编译得到的库文件,其名称前缀为

demo.

。至此,我们编写的扩展就已经可以使用了。在 Python 程序中可以使用

import demo

,只要运行时 Python 程序可以找到此库文件的位置,程序即可正常运行。

但是此时 Python 程序时依赖此库文件位置的,如果程序找不到它,程序就会出错,显示找不到此模块。由于我们进行构建的 CI 环境时比较稳定的,很少有较大改动,因此,直接将此扩展安装至构建环境中的 Python 中,如同构建 CI 环境时,安装其他相关依赖一样,这样,就不用每次将构建的库文件引入到 has 服务的文件结构中。安装 Python 扩展至本地 Python 的命令如下:

python3 setup.py install

根据执行命令后屏幕的输出,很容易会发现,

install

命令执行过程的前一部分和

build

命令是相同的,包含了构建过程,构建完毕会将此库文件安装至本地的 Python 中。


至此,扩展就能够非常方便的在 Python 中使用了



4. 总结,涉及 ctypes 使用

这里并不是想黑 ctypes,只是在最初使用的方案中,利用 ctypes 加载 C/C++ 程序编译的动态库文件,之后调用函数,并返回值,整个过程似乎是没有问题,但应该

只是看起来没有问题而已

。单个测试程序运行正常,Python 中能够调用函数,得到想要的效果。但是将其应用到 Python 多线程程序中,就会出现错误。查看 core 文件,最终出错地点是 Python 类中调用 C/C++ 函数相关实例回收的函数,出现了段错误,导致程序宕掉。

当时段时间内没有得到问题所在原因,就改为使用写 Python 扩展的形式。在学习和使用扩展的过程中,也逐渐意识到问题可能是什么原因导致的。

Python 程序和 C/C++ 程序在运行时,对于所需要的空间,各自开辟、各自回收。因此,二者在空间利用上最好不要有交集。使用 Python 扩展的形式这一点就很明确:扩展会通过 PyArg_ParseTuple 函数将 Python 中传递过来的参数解析,用 C/C++ 中的变量或者缓冲区接收,

相当于对原数据进行一次拷贝,而不是直接在原数据上操作

。在向 Python 返回数据时,不是直接返回 C/C++ 程序的结果,而是根据 C/C++ 程序中的结果构建 Python 数据(或者说是对象)。这样既完成了两种程序之间的数据通信,两种程序又不会互相干扰。



版权声明:本文为StoryZX原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。