Python C语言API系列教程(一、用C写一个Python包)

1,514 阅读11分钟

在之前的一篇博客中,我通过一个项目streamcpy展示了如何用C语言写一个Python模块,受到了不少人关注(表示感谢你🙏),于是就打算出一个系列的文章专门介绍Python的C语言API。通过这个系列的文章,读者不仅可以对Python的C语言API有一定的了解,还可以一步步的实现一个模仿官方datetime的Python模块datetimecpy(repo戳这里)。如果能给大家带来启发就最好啦。

Python的C语言API有什么用

在这一切的开始之前,我想先介绍一下Python的C语言API的“用武之地”——

Python的C语言API可以让Python加速

(敲黑板)这个是重点。由于GIL的存在,Python的运行速度一直让人诟病。目前市面上有很多Python加速方案,比如cython、codon等工具。但是这些工具都是将Python语言往C方面优化,如果直接用官方的C语言API可以达到更好的效果。

Python的C语言API可以做本地化适配

和Java一样,Python也是一门跨平台的高级语言。但是跨平台的语言需要对不同的操作系统、CPU硬件做不同的适配,这时候就需要C语言来作为底层的支撑,比如PyQT这种针对不同平台封装不同图形界面的库。也有的通过C语言专门提供一些本平台特定的功能,比如win32可以给用户提供Windows操作系统的能力。还有的会将加速和平台适配结合起来,比如tensorflow这种需要底层加速以及硬件适配的机器学习框架。实际上大多数用C语言开发的Python库都是出于这个目的。

Python的C语言可以更好的学习CPython

不同于C/C++等编译型语言,Python是解释型语言。当我们写好Python代码后,解释器会将它翻译成字节码,而解释字节码本质上就是在调用C语言API。有的人认为写Python就是在写C语言,其实这个观点某种程度上是说得通的。因此,熟悉C语言API对阅读CPython源码也很有帮助。

CPython是指用C语言实现的Python,也就是我们一般所说的Python。除此以外还有Jython(Java实现),Pypy(Python自举实现)和IronPython(C#实现)等。

现在正片开始——

环境配置

学习一门技能避免不了配置环境,但是Python的C开发环境很简单。除此以外最好还有一个Debug工具。

如果你是Windows用户

下载C语言编译器,这里建议使用最新版本的Visual Studio(我用的是Visual Studio 2022)。如果觉得太大至少需要下载Visual Studio Build Tools。说句题外话,作为宇宙第一IDE的Visual Studio难道不能占用你电脑几十G的内存吗你🐶?

然后安装两个“工作负荷”,分别是“Python开发”和“使用C++的桌面开发”

start6.JPG

好了,就那么多。现在开始尝试创建一个项目。选择“Python扩展模块”。

start1.jpg

然后和其他C/C++项目一样,创建项目。

start2.jpg

完了以后会生成一个模板,这个模板可以运行的。如果弹出下面这个控制台就说明OK了。

start7.JPG

如果你是Mac/Linux用户

除了安装python以外还需要安装python的开发包。

apt-get install python-dev

有的发行版是python-devel这个包。

然后再是C语言编译器,这里推荐GCC,装上就行。

apt-get install gcc

苹果用户可以通过brew安装

搞一个Debug工具

由于是跨语言debug,所以有点麻烦。Windows环境就用Visual Studio自带的,但是会提示缺失符号文件,这个在Python的Installer里面有。

start5.JPG

选择“调试”-“选项...”-“符号”可以把刚刚添加的.pdb文件添加进去。

start8.JPG

如果是Mac/Linux用户就麻烦一点,需要使用GCC的配套工具GDB,具体参考这个博客

要点讲解

Python的C扩展开发思想

首先需要搞懂Python的C语言API是什么?它有什么作用?

我是这样理解的:Python的C语言API是CPython解释器的后端接口,它为用户提供了一套绕过词法分析、语法分析等编译步骤的,直接操作虚拟机的API。它是Python语言的 “平替”

传统的编译器(解释器)分成两个部分——前端和后端。前端的范围指词法分析、语法分析和中间表示(IR)生成,而后端指中间表示优化到机器码生成(或执行)。所以C语言API可以跳过中间表示之前的一系列步骤。

既然定位是“平替”,那么C语言API可以代替Python代码的执行,也就是说所有的Python程序都可以用C语言改写,但是反过来不行。这里就体现了C语言API的价值了。

C语言API规范

其次,再总览一遍C语言API的接口。文档点这里。可以看到这里面将API分三个层次归纳理解,分别是非常高层次(The very high level layer)、抽象对象层次(Abstract objects layer)和具体抽象层次(Concrete objects layer)。除此以外还需要注意以下几点:

  • API的稳定性:有的API会标注Limit API,这表明这个API是限制的,也就是在整个3.X版本都适用。
  • "_ "的使用:底杠表示私有的,这个规则在Python的语境里似乎通用,比如带底杠前缀的API表示只有当前小版本适用,带底杠的文件代表不编译(或者不对外暴露),带底杠的变量代表内部共享。、
  • API命名规范:这里又是重点。所有标准API都会以Py开头,后面跟操作实体,然后接底杠再是camel-case的方法名,比如PyList_Append,这个方法表示标准方法,而且作用于Python的list类型变量,并且用于追加某个元素。实际上这个方法等同于Python的append方法。
  • API的宏:除了方法以外,这套API还提供了不少有用的宏。同样也是Py开头,并且后接大写字母,比如Py_INCREFPy_DECREF。这两个宏分别表示增加引用计数和减少引用计数(或销毁对象)。这两个宏是CPython内存管理的核心,第二章会介绍用法,第七章会讲内存管理具体介绍。
  • API的返回值引用属性:目前分为两种,分别是借用(Borrowed Reference)和新增(New Reference)。由于涉及到引用计数,所以不可避免涉及到所有权(类似C++和Rust中所有权的概念)。简单一句话,具有借用属性的变量不需要我们对其进行引用计数操作(比如Py_INCREF),否则需要纳入引用计数管理。具体第七章会介绍。

操作实践

我猜很多读者是直接跳到这里开始阅读的你😁,当然也没问题,毕竟这里才是核心。

本系列文章是要实现一个datetime模块,叫datetimecpy。这个模块我会尽量保持官方对datetimecpy的兼容。此外,可以通过切换分支选择不同章节的代码,比如本章的对应代码在分支Ch-1上。

前面说了C语言开发的Python模块就是对Python开发的“平替”,所以我们不妨先用Python打一个“草稿”,也就是最终的调用方法。

>>>import datetimecpy
>>>datetimecpy.now()
1683207793

有一个Python包叫datetimecpy(准确来说叫扩展),里面就一个方法叫now,它不接受任何参数,然后返回当前的时间戳。当然这个时间戳用C语言相关API获取。

就这么简单的一个扩展,现在要用C语言来实现——

首先找到Visual Studio自动为我们生成的datetimecpy.c文件,这个是我们模块的入口文件,但是现在我们需要把原先的内容全删了。然后写下下面代码:

#define PY_SSIZE_T_CLEAN
#include <Python.h>

这两行是Python的C语言程序的头文件第一行定义了一个宏,它和解析参数有关,暂且不管。第二行引入了Python.h头文件,这里面囊括了所有Python的C语言API,并且包括原生的C语言库,比如stdlib.h等。

接下来实现now方法。注意这是一个模块的方法,不属于任何类,所以这样写——

#include <time.h>

PyObject*
datetimecpy_now(PyObject* self, PyObject* Py_UNUSED(args)) {
    time_t now = time(NULL);

    if (now < 0) {
        Py_RETURN_NONE;
    } else {
        return PyLong_FromLongLong((long long)now);
    }
}

引入头文件不必多说,然后重点来了——我们需要定义一个PyObject* (*)(PyObject*, PyObject*)类型的函数。这是一个标标准准的PyCFunction函数,第一个参数类型为PyObject* ,它在不同语境下有不同的涵义,而这里代表当前模块,即datetimecpy。第二个参数类型也是PyObject* ,它代表这个now方法的所有参数,参数是通过列表传入的(平替了* args)。由于这类没用到外部传进来的参数,所以打上Py_UNUSED宏。最后这个函数返回了一个PyObject* ,这个返回值代表Python的一个类型,结合后面的语句,它可以是None或者int

函数体里面需要抓住两个重点,一个是Py_RETURN_NONE宏,另一个是PyLong_FromLongLong方法。Py_RETURN_NONE宏可以扩展成如下两个操作:

Py_INCREF(Py_None)
return Py_None

Py_None就是C语言版本的None,它是为数不多的单例,也是一个object。所以先需要增加引用计数,然后再返回。有关引用计数的内容在第二章讲述。这类是当time调用失败的情况下返回的。

根据前文讲述的C语言API规范,PyLong_FromLongLong方法是一个公有的,作用于Long的一个方法。直白点说,它是讲C语言的long long类型的变量转换成Python的int变量。这里就是将时间戳转换成Python的int对象返回。

现在我们继续——

static PyMethodDef datetimecpy_functions[] = {
    { "now", (PyCFunction)datetimecpy_now, METH_NOARGS, PyDoc_STR("now\n-\n\nQuery the current timestamp from the os.") },
    { NULL, NULL, 0, NULL }
};

既然我们实现了now函数,就要注册这个函数。这是一个PyMethodDef类型的数组,数组元素第一项是Python版本的方法名(当然是now了),第二个是对应的函数(就是我们刚刚实现的),然后是属性,比如METH_NOARGS(说明不接受任何参数),最后是文字描述。

由于只有一个方法,所以后面就用{ NULL, NULL, 0, NULL }实例作为结束的哨兵(Sentinel)。

然后编写模块的文字描述,并定义这个模块——

PyDoc_STRVAR(datetimecpy_doc, "The datetimecpy module");

static PyModuleDef datetimecpy_def = {
    PyModuleDef_HEAD_INIT,
    "datetimecpy",
    datetimecpy_doc,
    -1,                                 /* m_size */
    datetimecpy_functions,              /* m_methods */
    NULL,                               /* m_slots */
    NULL,                               /* m_traverse */
    NULL,                               /* m_clear */
    NULL,                               /* m_free */
};

模块通过PyModuleDef定义,并且包含公有的模块头PyModuleDef_HEAD_INIT。然后紧接着是模块名称,文字描述,大小(这里是-1,代表不能作为共享属性信息的模块,具体会在第五章说明),包含方法(即刚刚的PyMethodDef),其他属性暂且不涉及。

最后,再将其嵌入到Python解释器——

PyMODINIT_FUNC PyInit_datetimecpy() {
    PyObject* m = PyModule_Create(&datetimecpy_def);
    if (!m)
    {
        return NULL;
    }
    PyModule_AddStringConstant(m, "__author__", "littlebutt");
    PyModule_AddStringConstant(m, "__version__", "1.0.0");
    return m;
}

我们可以简单粗暴的把它理解成main函数,在Python执行import语句时会调用。在这个函数里,先通过PyModule_Create将对应的datetimecpy_def实例化生成模块,模块也是一个object,然后再为其添加作者和版本信息,并将其返回。

现在,我们运行这个程序,并调用now方法。如果出现下面这样就说明成功了。

ch1-2.JPG

到目前为止,如果不出错的话就恭喜你编写了第一个Python的C扩展!

但是且慢,我们还没有真正的将这个扩展打包投入到生产中去。打包的方法也简单,还是setuptools

新建一个setup.py文件

from setuptools import setup, Extension

module1 = Extension('datetimecpy',
                    sources = ['datetimecpy.c'])

setup (name='datetimecpy',
       version='1.0',
       author='littlebutt',
       author_email='luogan1996@icloud.com',
       license='MIT',
       description = "The datetime API",
       url='https://github.com/littlebutt/datetimecpy',
       ext_modules=[module1])

这样即可,然后我们在控制台运行

python setup.py build

就可以利用C语言编译器生成对应的包

再运行

python setup.py install

就可以安装这个包,然后在其他项目中使用啦。

小结

本章介绍了一些我对Python的C语言API的理解和看法,然后介绍了API相关特性,最后实现了一个简单的C扩展模块。在下面一章会重点讲解PyObject这个对象了。