《深度剖析CPython解释器》31. Python 和 C / C++ 联合编程

楔子

Python 和 C / C++ 混合编程已经屡见不鲜了,那为什么要将这两种语言结合起来呢?或者说,这两种语言混合起来能给为我们带来什么好处呢?首先,Python 和 C / C++ 联合,无非两种情况。

  • 1. C / C++ 为主导的项目中引入 Python;
  • 2. Python 为主导的项目中引入 C / C++;

首先是第一种情况,因为 C / C++ 是编译型语言,而它们的编译调试的成本是很大的。如果用 C / C++ 开发一个大型项目的话,比如游戏引擎,这个时候代码的修改、调试是无可避免的。而对于编译型语言来说,你对代码做任何一点改动都需要重新编译,而这个耗时是比较长的,所以这样算下来成本会非常高。这个时候一个比较不错的做法是,将那些跟性能无关的内容开放给脚本,可以是 Lua 脚本、也可以是 Python 脚本,而脚本语言不需要编译,我们可以随时修改,这样可以减少编译调试的成本。还有就是引入了 Python 脚本之后,我们可以把 C / C++ 做的更加模块化,由 Python 将 C / C++ 各个部分联合起来,这样可以降低 C / C++ 代码的耦合度,从而加强可重用性。

然后是第二种情况,Python 项目中引入 C / C++。我们知道 Python 的效率不是很高,如果你希望 Python 能够具有更高的性能,那么可以把一些和性能相关的逻辑使用 C / C++ 进行重写。此外,Python 有大量的第三方库,特别是诸如 Numpy、Pandas、Scipy 等等和科学计算密切相关的库,底层都是基于 C / C++ 的。再比如机器学习,底层核心算法都是基于 C / C++ 编写的,然后在业务层暴露给 Python 去调用,因此对于一些需要高性能的领域,Python 是必须要引入 C / C++ 的。此外 Python 还有一个最让人诟病的问题,就是由于 GIL 的限制导致 Python 无法有效利用多核,而引入 C / C++ 可以绕过 GIL 的限制。

此外有一个项目叫做 Cython,从名字你就能看出来这是将 Python 和 C / C++ 结合在了一起,之所以把它们结合在一起,很明显,因为这两者不是对立的,而是互补的。Python 是高阶语言、动态、易于学习,并且灵活。但是这些优秀的特性是需要付出代价的,因为 Python 的动态性、以及它是解释型语言,导致其运行效率比静态编译型语言慢了好几个数量级。而 C / C++ 是非常古老的静态编译型语言,并且至今也被广泛使用。从时间来算的话,其编译器已有将近半个世纪的历史,在性能上做了足够的优化。而 Cython 的出现,就是为了让你编写的代码具有 C / C++ 的高效率的同时,还能有 Python 的开发速度。

而笔者本人是主 Python 的,所以我们只会介绍第二种,也就是 Python 项目中引入 C / C++。而在 Python 中引入 C / C++,也涉及两种情况。第一种是,Python 通过 ctypes 模块直接调用 C / C++ 编写好的动态链接库,此时不会涉及任何的 Python / C API,只是单纯的通过 ctypes 模块将 Python 中的数据转成 C 中的数据传递给函数进行调用,调用完之后再将返回值转成 Python 中的数据。因此这种方式它和 Python 底层提供的 Python / C API 无关,和 Python 的版本也无关,因此会很方便。但很明显这种方式是有局限性的,至于局限性在哪儿,我们后面慢慢聊,因此还有一种选择是通过 C / C++ 为 Python 编写扩展模块的方式,来在 Python 中引入 C / C++,比如 OpenCV。

无论是 ctypes 调用动态链接库,还是 C / C++ 为 Python 编写扩展模块,我们都会介绍。

环境准备

首先是 Python 的安装,估计这应该不用我说了,我这里使用的 Python 版本是 3.8.7。

然后重点是 C / C++ 编译器的安装,我这里使用的是 64 位的 Windows 10 操作系统,所以我们需要手动安装相应的编译环境。可以下载一个 gcc,然后配置到环境变量中,就可以使用了。

或者安装 Visual Studio,我的 Visual Studio 版本是 2017,在命令行中可以通过 cl 命令进行编译。

当然这两种命令的使用方式都是类似的,或者你也可以使用 Linux,比如 CentOS,基本上自带 gcc。当然 Linux 的话,环境什么的比较简单,这里就不再废话了。重点是如果你是在 Windows 上使用 Visual Studio 的话,在命令行中输入命令 cl,很可能会提示你命令找不到;再或者编译的时候,会提示你 fatal error 不包括路径集等等。出现以上问题的话,说明你的环境变量没有配置正确,下面来说一下环境变量的配置。再次强调,我操作系统是 64 位 Windows 10,Visual Studio 版本是 2017,相信大部分人应该我是一样的,如果完全一样的话,那么路径啥的应该也是一致的,当然最好还是检查一下。

首先在 path 中添加如下几个路径:

  • C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.16.27023\bin\Hostx64\x64
  • C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64
  • C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\Common7\IDE

然后,新建一个环境变量。

变量名为 LIB,变量值为以下路径,由于是写在一行,所以路径之间需要使用分号进行隔开。

  • C:\Program Files (x86)\Windows Kits\10\Lib\10.0.17763.0\um\x64
  • C:\Program Files (x86)\Windows Kits\10\Lib\10.0.17763.0\ucrt\x64
  • C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.16.27023\lib\x64

最后,还是新建一个环境变量,变量名为 INCLUDE,变量值为以下路径:

  • C:\Program Files (x86)\Windows Kits\10\Include\10.0.17763.0\ucrt
  • C:\Program Files (x86)\Windows Kits\10\Lib\10.0.17763.0\um
  • C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.16.27023\include

以上就是 Windows 系统中配置 Visual Studio 2017 环境变量的整个过程,配置完毕之后重启命令行之后就可以使用了。注意:以上是我当前机器的路径,如果你的配置和我不一样,记得仔细检查。

不过个人更习惯使用 gcc,因此后面我们会使用 gcc 进行编译。

Python ctypes 模块调用 C / C++ 动态链接库

通过 ctypes 模块(Python 自带的)调用 C / C++ 动态库,也算是 Python 和 C / C++ 联合编程的一种方案,而且是最简单的一种方案。因为它只对你的操作系统有要求,比如 Windows 上编译的动态库是 .dll 文件,Linux 上编译的动态库是 .so 文件,只要操作系统一致,那么任何提供了 ctypes 模块的 Python 解释器都可以调用。这种方式的使用场景是 Python 和 C / C++ 不需要做太多的交互,比如嵌入式设备,可能只是简单调用底层驱动提供的某个接口而已。

再比如我们使用 C / C++ 写了一个高性能的算法,然后通过 Python 的 ctypes 模块进行调用也是可以的,但我们之前说使用 ctypes 具有相应的局限性,这个局限性就是 C / C++ 提供的接口不能太复杂。因为 ctypes 提供的交互能力还是比较有限的,最明显的问题就是不同语言数据类型不同,一些复杂的交互方式还是比较难做到的,还有多线程的控制问题等等。

举个小栗子

首先我们来举个栗子,演示一下。

int f(){
    return 123;
}

这是个简单到不能再简单的 C 函数,然后我们来编译成动态库。

编译方式: gcc -o .dll文件或者.so文件 -shared c或者c++源文件

如果你用的是 Visual Studio,那么把 gcc 换成 cl 即可。我当前的源文件叫做 main.c,我们编译成 main.dll,那么命令就需要这么写:gcc -o main.dll -shared main.c。

编译成功之后,我们通过 ctypes 来进行调用。

import ctypes

# 使用 ctypes 很简单,直接import进来,然后使用 ctypes.CDLL 这个类来加载动态链接库
# 或者是用 ctypes.cdll.LoadLibrary("./main.dll")
lib = ctypes.CDLL(r"./main.dll")  # 加载之后就得到了动态链接库对象
# 我们可以直接通过 . 的方式去调用里面的函数了,会发现成功打印
print(lib.f())  # 123

# 但是为了确定是否存在这个函数,我们一般会使用反射去获取
# 因为如果函数不存在通过 . 的方式调用会抛异常的
func = getattr(lib, "f", None)
if func:
    print(func)  # <_FuncPtr object at 0x0000029F75F315F0>
    func()  # hello world

# 不存在 f2 这个函数,所以得到的结果为 None
func1 = getattr(lib, "f2", None)
print(func1)  # None

所以使用ctypes去调用动态链接库非常方便,过程很简单:

  • 1. 通过 ctypes.CDLL 去加载动态库,另外注意的是:dll 或者 so 文件的路径最好是绝对路径,即便不是也要表明层级。比如我们这里的 py 文件和 dll 文件是在同一个目录下,但是我们加载的时候不可以写 main.dll,这样会报错找不到,我们需要写成 ./main.dll
  • 2. 加载动态链接库之后会返回一个对象,我们上面起名为 lib,这个 lib 就是得到的动态链接库了
  • 3. 然后可以直接通过 lib 调用里面的函数,但是一般我们会使用反射的方式来获取,因为不知道函数到底存不存在,如果不存在直接调用会抛出异常,如果存在这个函数我们才会调用。

Linux 和 Mac 也是一样的,这里不演示了,只不过编译之后的名字不一样。Linux 系统是 .so,Mac 系统是 .dylib。

此外我们也可以在 C 中进行打印,举个栗子:

#include <stdio.h>

void f(){
    printf("hello world");
}

然后编译,进行调用。

import ctypes

lib = ctypes.CDLL(r"./main.dll")  # 加载之后就得到了动态链接库对象
lib.f()  # hello world

另外,Python 的 ctypes 调用的都是 C 语言函数,如果你用的 C++ 编译器,那么会编译成 C++ 中的函数。我们知道 C 语言的函数不支持重载,说白了就是不可以定义两个同名的函数,而 C++ 的函数是支持重载的,只要参数类型不一致即可,然后调用的时候会根据传递的参数调用对应的函数。所以当我们使用 C++ 编译器的时候,需要通过 extern "C" 将函数包起来,这样 C++ 编译器在编译的时候会将其编译成 C 的函数。

#include <stdio.h>

// 注意: 我们不能直接通过 extern "C" {} 将函数包起来, 因为这不符合 C 的语法, extern 在 C 中是用来声明一个外部变量的
// 所以我们应该使用宏替换的方式, 如果是 C++ 编译器的话, 那么编译的时候 #ifdef __cplusplus 是会通过的, 因为 __cplusplus 是一个预定义的宏
// 如果是 C 编译器, 那么 #ifdef __cplusplus 不会通过
#ifdef __cplusplus
extern "C" {
#endif

void f() {
    printf("hello world\n");
}

#ifdef __cplusplus
}
#endif

当然我们在介绍 ctypes 使用的 gcc 都是 C 编译器,会编译成 C 的函数,所以后面 extern "C" 的逻辑就不加了。

我们以上就演示了,如何通过 Python 的 ctypes 模块来调用 C / C++ 动态库,但显然目前还是远远不够的。比如说:

double f() {
    return 3.14;
}

然后我们调用的时候,会得到什么结果呢?来试一下:

import ctypes

lib = ctypes.CDLL(r"./main.dll")  # 加载之后就得到了动态链接库对象
print(lib.f())  # 1374389535

我们看到得到一个不符合预期的结果,我们暂且不纠结它是怎么来的,现在的问题是它返回的为什么不是 3.14 呢?原因是 ctypes 在解析的时候默认是按照整型来解析的,但很明显我们 C 函数返回是浮点型,因此我们在调用之前需要显式的指定其返回值。

不过在这之前,我们需要先来看看 Python 类型和 C 类型之间的转换关系。

Python 类型与 C 语言类型之间的转换

我们说可以使用 ctypes 调用动态链接库,主要是调用动态链接库中使用C编写好的函数,但这些函数肯定都是需要参数的,还有返回值,不然编写动态链接库有啥用呢。那么问题来了,不同的语言变量类型不同,所以 Python 能够直接往 C 编写的函数中传参吗?显然不行,因此 ctypes 提供了大量的类,帮我们将 Python 中的类型转成 C 语言中的类型。

我们说了,Python 中类型不能直接往 C 语言的函数中传递(整型是个例外),而 ctypes 可以帮助我们将 Python 的类型转成 C 类型。而常见的类型分为以下几种:数值、字符、指针。

数值类型转换

C 语言的数值类型分为如下:

  • int:整型
  • unsigned int:无符号整型
  • short:短整型
  • unsigned short:无符号短整型
  • long:长整形
  • unsigned long:无符号长整形
  • long long:64位机器上等同于 long
  • unsigned long long:等同于 unsigned long
  • float:单精度浮点型
  • double:双精度浮点型
  • long double:看成是 double 即可
  • _Bool:布尔类型
  • ssize_t:等同于 long 或者 long long
  • size_t:等同于 unsigned long 或者 unsigned long long

下面来演示一下:

import ctypes

# 下面都是 ctypes 中提供的类,将 Python 中的对象传进去,就可以转换为 C 语言能够识别的类型
print(ctypes.c_int(1))  # c_long(1)
print(ctypes.c_uint(1))  # c_ulong(1)
print(ctypes.c_short(1))  # c_short(1)
print(ctypes.c_ushort(1))  # c_ushort(1)
print(ctypes.c_long(1))  # c_long(1)
print(ctypes.c_ulong(1))  # c_ulong(1)

# c_longlong 等价于 c_long,c_ulonglong 等价于c_ulong
print(ctypes.c_longlong(1))  # c_longlong(1)
print(ctypes.c_ulonglong(1))  # c_ulonglong(1)

print(ctypes.c_float(1.1))  # c_float(1.100000023841858)
print(ctypes.c_double(1.1))  # c_double(1.1)

# 在64位机器上,c_longdouble等于c_double
print(ctypes.c_longdouble(1.1))  # c_double(1.1)

print(ctypes.c_bool(True))  # c_bool(True)

# 相当于c_longlong和c_ulonglong
print(ctypes.c_ssize_t(10))  # c_longlong(10)
print(ctypes.c_size_t(10))  # c_ulonglong(10)

字符类型转换、指针类型转换

C 语言的字符类型分为如下:

  • char:一个 ascii 字符或者 -128~127 的整型
  • wchar:一个 unicode 字符
  • unsigned char:一个 ascii 字符或者 0~255 的一个整型

C 语言的指针类型分为如下:

  • char *:字符指针
  • wchar_t *:字符指针
  • void *:空指针

import ctypes

# 必须传递一个字节(里面是 ascii 字符),或者一个 int,来代表 C 里面的字符
print(ctypes.c_char(b"a"))  # c_char(b'a')
print(ctypes.c_char(97))  # c_char(b'a')

# 传递一个 unicode 字符,当然 ascii 字符也是可以的,并且不是字节形式
print(ctypes.c_wchar("憨"))  # c_wchar('憨')

# 和 c_char 类似,但是 c_char 既可以传入单个字节、也可以传整型,而这里的 c_byte 则要求必须传递整型。
print(ctypes.c_byte(97))  # c_byte(97)
print(ctypes.c_ubyte(97))  # c_ubyte(97)

# c_char_p 就是 c 里面字符数组了,其实我们可以把它看成是 Python 中的 bytes 对象
# char *s = "hello world";
# 那么这里面也要传递一个 bytes 类型的字符串,返回一个地址
print(ctypes.c_char_p(b"hello world"))  # c_char_p(2082736374464)

# 直接传递一个字符串,同样返回一个地址
print(ctypes.c_wchar_p("憨八嘎~"))  # c_wchar_p(2884583039392)

# ctypes.c_void_p后面演示

常见的类型就是上面这些,至于其他的类型,比如整型指针、数组、结构体、回调函数等等,ctypes 也是支持的,我们后面会介绍。

参数传递

下面我们来看看如何传递参数。

#include <stdio.h>

void test(int a, float f, char *s)
{
    printf("a = %d, b = %.2f, s = %s\n", a, f, s);
}

这是一个很简单的 C 文件,然后编译成 dll 之后,让 Python 去调用,这里我们编译之后的文件名叫做还叫做 main.dll。

from ctypes import *

lib = CDLL(r"./main.dll")  # 加载之后就得到了动态链接库对象
try:
    lib.test(1, 1.2, b"hello world")
except Exception as e:
    print(e)  # argument 2: <class 'TypeError'>: Don't know how to convert parameter 2

# 我们看到一个问题,那就是报错了,告诉我们不知道如何转化第二个参数
# 正如我们之前说的,整型是会自动转化的,但是浮点型是不会自动转化的
# 因此我们需要使用 ctypes 来包装一下,当然还有整型,即便整型会自动转,我们还是建议手动转化一下
# 这里传入 c_int(1) 和 1 都是一样的,但是建议传入 c_int(1)
lib.test(c_int(1), c_float(1.2), c_char_p(b"hello world"))  # a = 1, b = 1.20, s = hello world

我们看到完美的打印出来了,我们再来试试布尔类型。

#include <stdio.h>

void test(_Bool flag)
{   
    //布尔类型本质上是一个int
    printf("a = %d\n", flag);
}
import ctypes
from ctypes import *

lib = ctypes.CDLL("./main.dll")

lib.test(c_bool(True))  # a = 1
lib.test(c_bool(False))  # a = 0
# 可以看到 True 被解释成了 1,False 被解释成了 0

# 我们说整型会自动转化,而布尔类型继承自整型所以布尔类型也可以直接传递
lib.test(True)  # a = 1
lib.test(False)  # a = 0

然后再来看看字符和字符数组的传递:

#include <stdio.h>
#include <string.h>

void test(int age, char *gender)
{
    if (age >= 18)
    {
        if (strcmp(gender, "female") == 0)
        {
            printf("age >= 18, gender is female\n");
        }
        else
        {
            printf("age >= 18, gender is male\n");
        }
    }

    else
    {
        if (strcmp(gender, "female") == 0)
        {
            printf("age < 18, gender is female\n");
        }
        else
        {
            printf("age < 18, gender is main\n");
        }
    }
}
from ctypes import *

lib = CDLL("./main.dll")
lib.test(c_int(20), c_char_p(b"female"))  # age >= 18, gender is female
lib.test(c_int(20), c_char_p(b"male"))  # age >= 18, gender is male
lib.test(c_int(14), c_char_p(b"female"))  # age < 18, gender is female
lib.test(c_int(14), c_char_p(b"male"))  # age < 18, gender is main

# 我们看到 C 中的字符数组,我们直接通过 c_char_p 来传递即可
# 至于单个字符,使用 c_char 即可

同理我们也可以打印宽字符,逻辑是类似的。

传递可变的字符串

我们知道 C 中不存在字符串这个概念,Python 中的字符串在 C 中也是通过字符数组来实现的,我们通过 ctypes 像 C 函数传递一个字符串的时候,在 C 中是可以被修改的。

#include <stdio.h>

void test(char *s)
{
    s[0] = 'S';
    printf("%s", s);
}
from ctypes import *

lib = CDLL("./main.dll")
lib.test(c_char_p(b"satori"))  # Satori

我们看到小写的字符串,第一个字符变成了大写,但即便能修改我们也不建议这么做,因为 bytes 对象在 Python 中是不能更改的,所以在 C 中也不应该更改。当然不是说不让修改,而是应该换一种方式。如果是需要修改的话,那么不要使用 c_char_p 的方式来传递,而是建议通过 create_string_buffer 来给 C 语言传递可以修改字符的空间。

from ctypes import *

# 传入一个 int,表示创建一个具有固定大小的字符缓存,这里是 10 个
s = create_string_buffer(10)
# 直接打印就是一个对象
print(s)  # <ctypes.c_char_Array_10 object at 0x000001E2E07667C0>
# 也可以调用 value 方法打印它的值,可以看到什么都没有
print(s.value)  # b''
# 并且它还有一个 raw 方法,表示 C 语言中的字符数组,由于长度为 10,并且没有内容,所以全部是 \x00,就是C语言中的 \0
print(s.raw)  # b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
# 还可以查看长度
print(len(s))  # 10


# 其它类型也是一样的
v = c_int(1)
# 我们看到 c_int(1) 它的类型就是 ctypes.c_long
print(type(v))  # <class 'ctypes.c_long'>
# 当然你把 c_int,c_long,c_longlong 这些花里胡哨的都当成是整型就完事了

# 此外我们还能够拿到它的值,调用 value 方法
print(v.value, type(v.value))  # 1 <class 'int'>

v = c_char(b"a")
print(type(v))  # <class 'ctypes.c_char'>
print(v.value, type(v.value))  # b'a' <class 'bytes'>

v = c_char_p(b"hello world")
print(type(v))  # <class 'ctypes.c_char_p'>
print(v.value, type(v.value))  # b'hello world' <class 'bytes'>

v = c_wchar_p("夏色祭")
print(type(v))  # <class 'ctypes.c_wchar_p'>
print(v.value, type(v.value))  # 夏色祭 <class 'str'>
# 因此 ctypes 中的对象调用 value 即可得到 Python 中的对象

当然 create_string_buffer 如果只传一个 int,那么表示创建对应长度的字符缓存。除此之外,还可以指定字节串,此时的字符缓存大小和指定的字节串大小是一致的:

from ctypes import *

# 此时我们直接创建了一个字符缓存
s = create_string_buffer(b"hello")
print(s)  # <ctypes.c_char_Array_6 object at 0x0000021944E467C0>
print(s.value)  # b'hello'
# 我们知道在 C 中,字符数组是以 \0 作为结束标记的,所以结尾会有一个 \0,因为 raw 表示 C 中原始的字符数组
print(s.raw)  # b'hello\x00'
# 长度为 6,b"hello" 五个字符再加上 \0 一共 6 个
print(len(s))

当然 create_string_buffer 还可以在指定字节串的同时,指定空间大小。

from ctypes import *

# 此时我们直接创建了一个字符缓存,如果不指定容量,那么默认和对应的字符数组大小一致
# 但是我们还可以同时指定容量,记得容量要比前面的字节串的长度要大。
s = create_string_buffer(b"hello", 10)
print(s)  # <ctypes.c_char_Array_10 object at 0x0000019361C067C0>
print(s.value)  # b'hello'
# 长度为 10,剩余的 5 个显然是 \0
print(s.raw)  # b'hello\x00\x00\x00\x00\x00'
print(len(s))  # 10

下面我们来看看如何使用 create_string_buffer 来传递:

#include <stdio.h>

int test(char *s)
{   
    //变量的形式依旧是char *s
    //下面的操作就是相当于把字符数组的索引为5到11的部分换成" satori"
    s[5] = ' ';
    s[6] = 's';
    s[7] = 'a';
    s[8] = 't';
    s[9] = 'o';
    s[10] = 'r';
    s[11] = 'i';
    printf("s = %s\n", s);
}
from ctypes import *

lib = CDLL("./main.dll")
s = create_string_buffer(b"hello", 20)
lib.test(s)  # s = hello satori

此时就成功地修改了,我们这里的 b"hello" 占五个字节,下一个正好是索引为 5 的地方,然后把索引为 5 到 11 的部分换成对应的字符。但是需要注意的是,一定要小心 \0,我们知道 C 语言中一旦遇到了 \0 就表示这个字符数组结束了。

from ctypes import *

lib = CDLL("./main.dll")
# 这里把"hello"换成"hell",看看会发生什么
s = create_string_buffer(b"hell", 20)
lib.test(s)  # s = hell

# 我们看到这里只打印了"hell",这是为什么?
# 我们看一下这个s
print(s.raw)  # b'hell\x00 satori\x00\x00\x00\x00\x00\x00\x00\x00'

# 我们看到这个 create_string_buffer 返回的对象是可变的,在将 s 传进去之后被修改了
# 如果没有传递的话,我们知道它是长这样的。
"""
b'hell\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
hell的后面全部是C语言中的 \0
修改之后变成了这样
b'hell\x00 satori\x00\x00\x00\x00\x00\x00\x00\x00'

我们看到确实是把索引为5到11(包含11)的部分变成了" satori"
但是我们知道 C 语言中扫描字符数组的时候一旦遇到了 \0,就表示结束了,而hell后面就是 \0,
因为即便后面还有内容也不会输出了,所以直接就只打印了 hell
"""

另外除了 create_string_buffer 之外,还有一个 create_unicode_buffer,针对于 wchar_t *,用法和 create_string_buffer 类似。

调用操作系统的库函数

我们知道 Python 解释器本质上就是使用 C 语言写出来的一个软件,那么操作系统呢?操作系统本质上它也是一个软件,不管是 Windows、Linux 还是 MacOS 都自带了大量的共享库,那么我们就可以使用 Python 去调用。

from ctypes import *
import sys
import platform

# 判断当前的操作系统平台。
# Windows 平台返回 "Windows",Linux 平台返回 "Linux",MacOS 平台返回 "Darwin"
system = platform.system()

# 不同的平台共享库不同
if system == "Windows":
    lib = cdll.msvcrt
elif system == "Linux":
    lib = CDLL("libc.so.6")
elif system == "Darwin":
    lib = CDLL("libc.dylib")
else:
    print("不支持的平台,程序结束")
    sys.exit(0)

# 调用对应的函数,比如 printf,注意里面需要传入字节
lib.printf(b"my name is %s, age is %d\n", b"van", 37)  # my name is van, age is 37

# 如果包含汉字就不能使用 b"" 这种形式了,因为这种形式只适用于 ascii 字符,我们需要手动 encode 成 utf-8
lib.printf("姓名: %s, 年龄: %d\n".encode("utf-8"), "古明地觉".encode("utf-8"), 17)  # 姓名: 古明地觉, 年龄: 17

我们上面是在 Windows 上调用的,这段代码即便拿到 Linux 和 MacOS 上也可以正常执行。

当然这里面还支持其他的函数,我们这里以 Windows 为例:

from ctypes import *

libc = cdll.msvcrt

# 创建一个大小为 10 的buffer
s = create_string_buffer(10)
# strcpy 表示将字符串进行拷贝
libc.strcpy(s, c_char_p(b"hello satori"))
# 由于 buffer 只有10个字节大小,所以无法完全拷贝
print(s.value)  # b'hello sato'


# 创建 unicode buffer
s = create_unicode_buffer(10)
libc.strcpy(s, c_wchar_p("我也觉得很变态啊"))
print(s.value)  # 我也觉得很变态啊

# 比如 puts 函数
libc.puts(b"hello world")  # hello world

对于 Windows 来说,我们还可以调用一些其它的函数,但是不再是通过 cdll.msvcrt 这种方式了。在 Windows 上面有一个 user32 这么个东西,我们来看一下:

from ctypes import *

# 我们通过 cdll.user32 本质上还是加载了 Windows 上的一个共享库
# 这个库给我们提供了很多方便的功能
win = cdll.user32

# 比如查看屏幕的分辨率
print(win.GetSystemMetrics(0))  # 1920
print(win.GetSystemMetrics(1))  # 1080

我们还可以用它来打开 MessageBoxA:

可以看到我们通过 cdll.user32 就可以很轻松地调用 Windows 的 api,具体有哪些 api 可以去网上查找,搜索 win32 api 即可。

除了 ctypes,还有几个专门用来操作 win32 服务的模块,win32gui、win32con、win32api、win32com、win32process。直接 pip install pywin32 即可,或者 pip install pypiwin32。

显示窗体和隐藏窗体

import win32gui
import win32con

# 首先查找窗体,这里查找 qq。需要传入 窗口类名 窗口标题名,至于这个怎么获取可以使用 spy 工具查看
qq = win32gui.FindWindow("TXGuifoundation", "QQ")
# 然后让窗体显示出来
win32gui.ShowWindow(qq, win32con.SW_SHOW)
# 还可以隐藏
win32gui.ShowWindow(qq, win32con.SW_HIDE)

控制窗体的位置和大小

import win32gui
import win32con

qq = win32gui.FindWindow("TXGuiFoundation", "QQ")

# 主要要接收如下参数
# 参数一:控制的窗体
# 参数二:大致方位:HWND_TOPMOST,位于上方
# 参数三:位置x
# 参数四:位置y
# 参数五:长度
# 参数六:宽度
# 参数七:比较固定,就是让窗体一直显示
win32gui.SetWindowPos(qq, win32con.HWND_TOPMOST, 100, 100, 300, 300, win32con.SWP_SHOWWINDOW)

那么我们还可以让窗体满屏幕乱跑:

import win32gui
import win32con
import random

qqWin = win32gui.FindWindow("TXGuiFoundation", "QQ")

# 将位置变成随机数
while True:
    x = random.randint(1, 1920)
    y = random.randint(1, 1080)
    win32gui.SetWindowPos(qqWin, win32con.HWND_TOPMOST, x, y, 300, 300, win32con.SWP_SHOWWINDOW)

语音播放

import win32com.client
# 直接调用操作系统的语音接口
speaker = win32com.client.Dispatch("SAPI.SpVoice")
# 输入你想要说的话,前提是操作系统语音助手要认识。一般中文和英文是没有问题的
speaker.Speak("他能秒我,他能秒杀我?他要是能把我秒了,我当场······")

Python 中 win32 模块的 api 非常多,几乎可以操作整个 Windows 提供的服务,win32 模块就是相当于把 Windows 服务封装成了一个一个的接口。不过这些服务、或者调用这些服务具体都能干些什么,可以自己去研究,这里就到此为止了。

ctypes 获取返回值

我们前面已经看到了,通过 ctypes 向动态链接库中的函数传参时是没有问题的,但是我们如何拿到返回值呢?我们之前都是使用 printf 直接打印的,但是这样显然不行,我们肯定是要拿到返回值去做一些别的事情的。那么我们在 C 函数中直接 return 不就可以啦,还记得之前演示的返回浮点型的例子吗?我们明明返回了 3.14,但得到的确是一大长串整数,所以我们需要在调用函数之前告诉 ctypes 返回值的类型。

int test1(int a, int b)
{
    int c;
    c = a + b;
    return c;
}

void test2()
{

}
from ctypes import *

lib = CDLL("./main.dll")
print(lib.test1(25, 33))  # 58
print(lib.test2())  # -883932787

我们看到对于 test1 的结果是正常的,但是对于 test2 来说即便返回的是 void,在 Python 中依旧会得到一个整型,因为默认都会按照整型进行解析,但这个结果肯定是不正确的。不过对于整型来说,是完全没有问题的。

正如我们传递参数一样,需要使用 ctypes 转化一下,那么在获取返回值的时候,也需要提前使用 ctypes 指定一下返回值到底是什么类型,只有这样才能拿到动态链接库中函数的正确的返回值。

#include <wchar.h>

char * test1()
{
    char *s = "hello satori";
    return s;
}

wchar_t * test2()
{
    // 遇到 wchar_t 的时候,一定要导入 wchar.h 头文件
    wchar_t *s = L"憨八嘎";
    return s;
}
from ctypes import *

lib = CDLL("./main.dll")
# 不出所料,我们在动态链接库中返回的是一个字符数组的首地址,我们希望拿到指向的字符串
# 然而 Python 拿到的仍是一个整型,而且一看感觉这像是一个地址。如果是地址的话那么从理论上讲是对的,返回地址、获取地址
print(lib.test1())  # 1788100608
# 但我们希望的是获取地址指向的字符数组,所以我们需要指定一下返回的类型
# 指定为 c_char_p,告诉 ctypes 你在解析的时候将 test1 的返回值按照 c_char_p 进行解析
lib.test1.restype = c_char_p
# 此时就没有问题了
print(lib.test1())  # b'hello satori'

# 同理对于 unicode 也是一样的,如果不指定类型,得到的依旧是一个整型
lib.test2.restype = c_wchar_p
print(lib.test2())  # 憨八嘎

因此我们就将 Python 中的类型和 C 语言中的类型通过 ctypes 关联起来了,我们传参的时候需要转化,同理获取返回值的时候也要使用 ctypes 来声明一下类型。因为默认 Python 调用动态链接库的函数返回的都是整型,至于返回的整型的值到底是什么?从哪里来的?我们不需要关心,你可以理解为地址、或者某块内存的脏数据,但是不管怎么样,结果肯定是不正确的(如果函数返回的就是整形除外)。因此我们需要提前声明一下返回值的类型。声明方式:

lib.CFunction.restype = ctypes类型

我们说 lib 就是 ctypes 调用 dll 或者 so 得到的动态链接库,而里面的函数就相当于是一个个的 CFunction,然后设置内部的 restype(返回值类型),就可以得到正确的返回值了。另外即便返回值设置的不对,比如:test1 返回一个 char *,但是我们将类型设置为 c_float,调用的时候也不会报错而且得到的也是一个 float,但是这个结果肯定是不对的。

from ctypes import *

lib = CDLL("./main.dll")
lib.test1.restype = c_char_p
print(lib.test1())  # b'hello satori'

# 设置为 c_float
lib.test1.restype = c_float
# 获取了不知道从哪里来的脏数据
print(lib.test1())  # 2.5420596244190436e+20

# 另外 ctypes 调用还有一个特点
lib.test2.restype = c_wchar_p
print(lib.test2(123, c_float(1.35), c_wchar_p("呼呼呼")))  # 憨八嘎
# 我们看到 test2 是不需要参数的,如果我们传了那么就会忽略掉,依旧能得到正常的返回值
# 但是不要这么做,因为没准就出问题了,所以还是该传几个参数就传几个参数

下面我们来看看浮点类型的返回值怎么获取,当然方法和上面是一样的。

#include <math.h>

float test1(int a, int b)
{
    float c;
    c = sqrt(a * a + b * b);
    return c;
}
from ctypes import *

lib = CDLL("./main.dll")

# 得到的结果是一个整型,默认都是整型。
# 我们不知道这个整型是从哪里来的,就把它理解为地址吧,但是不管咋样,结果肯定是不对的
print(lib.test1(3, 4))  # 1084227584

# 我们需要指定返回值的类型,告诉 ctypes 返回的是一个 float
lib.test1.restype = c_float
# 此时结果就是对的
print(lib.test1(3, 4))  # 5.0

# 如果指定为 double 呢?
lib.test1.restype = c_double
# 得到的结果也有问题,总之类型一定要匹配
print(lib.test1(3, 4))  # 5.356796015e-315

# 至于 int 就不用说了,因为默认就是 int。所以和第一个结果是一样的
lib.test1.restype = c_int
print(lib.test1(3, 4))  # 1084227584

所以类型一定要匹配,该是什么类型就是什么类型。即便动态链接库中返回的是 float,我们在 Python 中通过 ctypes 也要指定为 float,而不是指定为 double,尽管都是浮点数并且 double 的精度还更高,但是结果依旧不是正确的。至于整型就不需要关心了,但即便如此,int、long 也建议不要混用,而且传参的时候最好也进行转化。

ctypes 给动态链接库中的函数传递指针

我们使用 ctypes 可以创建一个字符数组并且拿到首地址,但是对于整型、浮点型我们怎么创建指针呢?下面就来揭晓。另外,一旦涉及到指针操作的时候就要小心了,因为这往往是比较危险的,所以 Python 把指针给隐藏掉了,当然不是说没有指针,肯定是有指针的。只不过操作指针的权限没有暴露给程序员,能够操作指针的只有对应的解释器。

ctypes.byref 和 ctypes.pointer 创建指针

from ctypes import *

v = c_int(123)
# 我们知道可以通过 value 属性获取相应的值
print(v.value)

# 但是我们还可以修改
v.value = 456
print(v)  # c_long(456)

s = create_string_buffer(b"hello")
s[3] = b'>'
print(s.value)  # b'hel>o'

# 如何创建指针呢?通过 byref 和 pointer
v2 = c_int(123)
print(byref(v2))  # <cparam 'P' (000001D9DCF86888)>
print(pointer(v2))  # <__main__.LP_c_long object at 0x000001D9DCF868C0>

我们看到 byref 和 pointer 都可以创建指针,那么这两者有什么区别呢?byref 返回的指针相当于右值,而 pointer 返回的指针相当于左值。举个栗子:

// 以整型的指针为例:
int num = 123;
int *p = &num 

对于上面的例子,如果是 byref,那么结果相当于 &num,拿到的就是一个具体的值。如果是 pointer,那么结果相当于 p。这两者在传递的时候是没有区别的,只是对于 pointer 来说,它返回的是一个左值,我们是可以继续拿来做文章的。

from ctypes import *

n = c_int(123)
# 拿到变量 n 的指针
p1 = byref(n)
p2 = pointer(n)
# pointer 返回的是左值,我们可以继续做文章,比如继续获取指针,此时获取的就是 p2 的指针
print(byref(p2))  # <cparam 'P' (0000023953796888)>

# 但是 p1 不行,因为 byref 返回的是一个右值
try:
    print(byref(p1))
except Exception as e:
    print(e)  # byref() argument must be a ctypes instance, not 'CArgObject'

因此两者的区别就在这里,但是还是那句话,我们在传递的时候是无所谓的,传递哪一个都可以。

传递指针

我们知道了可以通过 ctypes.byref、ctypes.pointer 的方式传递指针,但是如果函数返回的也是指针呢?我们知道除了返回 int 之外,都要指定返回值类型,那么指针如何指定呢?答案是通过 ctypes.POINTER。

// 接收两个 float *,返回一个 float *
float *test1(float *a, float *b)
{
    // 因为返回指针,所以为了避免被销毁,我们使用 static 静态声明
    static float c;
    c = *a + *b;
    return &c;
}
from ctypes import *

lib = CDLL("./main.dll")

# 声明一下,返回的类型是一个 POINTER(c_float),也就是 float 的指针类型
lib.test1.restype = POINTER(c_float)
# 别忘了传递指针,因为函数接收的是指针,两种传递方式都可以
res = lib.test1(byref(c_float(3.14)), pointer(c_float(5.21)))
print(res)  # <__main__.LP_c_float object at 0x000001FFF1F468C0>
print(type(res))  # <class '__main__.LP_c_float'>
# 这个 res 是 ctypes 类型,和 pointer(c_float(5.21)) 的类型是一样的,都是 <class '__main__.LP_c_float'>
# 我们调用 contents 即可拿到 ctypes 中的值,那么显然在此基础上再调用 value 就能拿到 Python 中的值
print(res.contents)  # c_float(8.350000381469727)
print(res.contents.value)  # 8.350000381469727

因此我们看到了如果返回的是指针类型可以使用 POINTER(类型) 来声明,也就是说 POINTER 是用来声明指针类型的,而 byref、pointer 则是用来获取指针的。

声明类型

我们知道可以事先声明返回值的类型,这样才能拿到正确的返回值。而我们传递的时候,直接传递正确的类型即可,但是其实也是可以事先声明的。

from ctypes import *

lib = CDLL("./main.dll")

# 通过 argtypes,我们可以事先指定需要传入两个 float 的指针类型,注意:要指定为一个元组,即便是一个参数也要是元组
lib.test1.argtypes = (POINTER(c_float), POINTER(c_float))
lib.test1.restype = POINTER(c_float)

# 但是和 restype 不同,argtypes 实际上是可以不要的
# 因为返回的默认是一个整型,我们才需要通过 restype 事先声明返回值的类型,这是有必要的
# 但是对于 argtypes 来说,我们传参的时候已经直接指定类型了,所以 argtypes 即便没有也是可以的
# 所以 argtypes 的作用就类似于其他静态语言中的类型声明,先把类型定好,如果你传的类型不对,直接给你报错
try:
    # 这里第二个参数传c_int
    res = lib.test1(byref(c_float(3.21)), c_int(123))
except Exception as e:
    # 所以直接就给你报错了
    print(e)  # argument 2: <class 'TypeError'>: expected LP_c_float instance instead of c_long


# 此时正确执行
res1 = lib.test1(byref(c_float(3.21)), byref(c_float(666)))
print(res1.contents.value)  # 669.2100219726562

传递数组

下面我们来看看如何使用 ctypes 传递数组,这里我们只讲传递,不讲返回。因为 C 语言返回数组给 Python 实际上会存在很多问题,比如:返回的数组的内存由谁来管理,不用了之后空间由谁来释放,事实上 ctypes 内部对于返回数组支持的也不是很好。因此我们一般不会向 Python 返回一个 C 语言中的数组,因为 C 语言中的数组传递给 Python 涉及到效率的问题,Python 中的列表传递直接传递一个引用即可,但是 C 语言中的数组过来肯定是要拷贝一份的,所以这里我们只讲 Python 如何通过 ctypes 给动态链接库传递数组,不再介绍动态链接库如何返回数组给 Python。

from ctypes import *

# 创建一个数组,假设叫 [1, 2, 3, 4, 5]
a5 = (c_int * 5)(1, 2, 3, 4, 5)
print(a5)  # <__main__.c_long_Array_5 object at 0x00000162428968C0>
# 上面这种方式就得到了一个数组
# 当然下面的方式也是可以的
a5 = (c_int * 5)(*range(1, 6))
print(a5)  # <__main__.c_long_Array_5 object at 0x0000016242896940>

下面演示一下:

// 字符数组默认是以 \0 作为结束的,我们可以通过 strlen 来计算长度。
// 但是对于整型的数组来说我们不知道有多长
// 因此有两种声明参数的方式,一种是 int a[n],指定数组的长度
// 另一种是通过指定 int *a 的同时,再指定一个参数 int size,调用函数的时候告诉函数这个数组有多长
int test1(int a[5])
{
    // 可能有人会问了,难道不能通过 sizeof 计算吗?答案是不能,无论是 int *a 还是 int a[n]
    // 数组作为函数的参数时会退化为指针,我们调用的时候,传递的都是指针,指针在 64 位机器上默认占 8 个字节。
    // 所以int a[] = {...}这种形式,如果直接在当前函数中计算的话,那么 sizeof(a) 就是数组里面所有元素的总大小,因为a是一个数组名
    // 但是当把 a 传递给一个函数的时候,那么等价于将 a 的首地址拷贝一份传过去,此时在新的函数中再计算 sizeof(a) 的时候就是一个指针的大小
    //至于 int *a 这种声明方式,不管在什么地方,sizeof(a) 都是一个指针的大小
    int i;
    int sum = 0;
    a[3] = 10;
    a[4] = 20;
    for (i = 0;i < 5; i++){
        sum += a[i];
    }
    return sum;
}
from ctypes import *

lib = CDLL("./main.dll")

# 创建 5 个元素的数组,但是只给3个元素
arr = (c_int * 5)(1, 2, 3)
# 在动态链接库中,设置剩余两个元素
# 所以如果没问题的话,结果应该是 1 + 2 + 3 + 10 + 20
print(lib.test1(arr))  # 36

传递结构体

有了前面的数据结构还不够,我们还要看看结构体是如何传递的,有了结构体的传递,我们就能发挥更强大的功能。那么我们来看看如何使用 ctypes 定义一个结构体:

from ctypes import *

# 对于这样一个结构体应该如何定义呢?
"""
struct Girl {
  char *name;     // 姓名
  int age;        // 年龄
  char *gender;   //性别
  int class;      //班级
};
"""

# 定义一个类,必须继承自 ctypes.Structure
class Girl(Structure):
    # 创建一个 _fields_ 变量,必须是这个名字,注意开始和结尾都只有一个下划线
    # 然后就可以写结构体的字段了,具体怎么写估计一看就清晰了
    _fields_ = [
        ("name", c_char_p),
        ("age", c_int),
        ("gender", c_char_p),
        ("class", c_int)
    ]

我们向 C 中传递一个结构体,然后再返回:

struct Girl {
  char *name;
  int age;
  char *gender;
  int class;
};

//接收一个结构体,返回一个结构体
struct Girl test1(struct Girl g){
  g.name = "古明地觉";
  g.age = 17;
  g.gender = "female";
  g.class = 2;
  return g;
}
from ctypes import *

lib = CDLL("./main.dll")


class Girl(Structure):
    _fields_ = [
        ("name", c_char_p),
        ("age", c_int),
        ("gender", c_char_p),
        ("class", c_int)
    ]


# 此时返回值类型就是一个 Girl 类型,另外我们这里的类型和 C 中结构体的名字不一样也是可以的
lib.test1.restype = Girl
# 传入一个实例,拿到返回值
g = Girl()
res = lib.test1(g)
print(res, type(res))  # <__main__.Girl object at 0x0000015423A06840> <class '__main__.Girl'>
print(res.name, str(res.name, encoding="utf-8"))  # b'\xe5\x8f\xa4\xe6\x98\x8e\xe5\x9c\xb0\xe8\xa7\x89' 古明地觉
print(res.age)  # 17
print(res.gender)  # b'female'
print(getattr(res, "class"))  # 2

如果是结构体指针呢?

struct Girl {
  char *name;
  int age;
  char *gender;
  int class;
};

// 接收一个指针,返回一个指针
struct Girl *test1(struct Girl *g){
  g -> name = "mashiro";
  g -> age = 17;
  g -> gender = "female";
  g -> class = 2;
  return g;
}
from ctypes import *

lib = CDLL("./main.dll")


class Girl(Structure):
    _fields_ = [
        ("name", c_char_p),
        ("age", c_int),
        ("gender", c_char_p),
        ("class", c_int)
    ]


# 此时指定为 Girl 类型的指针
lib.test1.restype = POINTER(Girl)
# 传入一个实例,拿到返回值
# 但返回的是指针,我们还需要手动调用一个 contents 才可以拿到对应的值。
g = Girl()
res = lib.test1(byref(g))
print(str(res.contents.name, encoding="utf-8"))  # mashiro
print(res.contents.age)  # 16
print(res.contents.gender)  # b'female'
print(getattr(res.contents, "class"))  # 3

# 另外我们不仅可以通过返回的 res 去调用,还可以通过 g 来调用,因为我们传递的是 g 的指针
# 修改指针指向的内存就相当于修改g,所以我们通过g来调用也是可以的
print(str(g.name, encoding="utf-8"))  # mashiro

因此对于结构体来说,我们先创建一个结构体(Girl)实例 g,如果动态链接库的函数中接收的是结构体,那么直接把 g 传进去等价于将 g 拷贝了一份,此时函数中进行任何修改都不会影响原来的 g。但如果函数中接收的是结构体指针,我们传入 byref(g) 相当于把 g 的指针拷贝了一份,在函数中修改是会影响 g 的。而返回的 res 也是一个指针,所以我们除了通过 res.contents 来获取结构体中的值之外,还可以通过 g 来获取。再举个栗子对比一下:

struct Num {
  int x;
  int y;
};


struct Num test1(struct Num n){
  n.x += 1;
  n.y += 1;
  return n;
}

struct Num *test2(struct Num *n){
  n->x += 1;
  n->y += 1;
  return n;
}
from ctypes import *

lib = CDLL("./main.dll")


class Num(Structure):
    _fields_ = [
        ("x", c_int),
        ("y", c_int),
    ]


# 我们在创建的时候是可以传递参数的
num = Num(x=1, y=2)
print(num.x, num.y)  # 1 2

lib.test1.restype = Num
res = lib.test1(num)
# 我们看到通过 res 得到的结果是修改之后的值
# 但是对于 num 来说没有变
print(res.x, res.y)  # 2 3
print(num.x, num.y)  # 1 2
"""
因为我们将 num 传进去之后,相当于将 num 拷贝了一份。
函数里面的结构体和这里的 num 尽管长得一样,但是没有任何关系
所以 res 获取的结果是自增之后的结果,但是 num 还是之前的 num
"""

# 我们来试试传递指针,将 byref(num) 再传进去
lib.test2.restype = POINTER(Num)
res = lib.test2(byref(num))
print(num.x, num.y)  # 2 3
"""
我们看到将指针传进去之后,相当于把 num 的指针拷贝了一份。
然后在函数中修改,相当于修改指针指向的内存,所以是会影响外面的 num 的
而动态链接库的函数中返回的是参数中的结构体指针,而我们传递的 byref(num) 也是这里的num的指针
尽管传递指针的时候也是拷贝了一份,两个指针本身来说虽然也没有任何联系,但是它们存储的地址是一样的
那么通过 res.contents 获取到的内容就相当于是这里的 num
因此此时我们通过 res.contents 获取和通过 num 来获取都是一样的。
"""
print(res.contents.x, res.contents.y)  # 2 3

# 另外还需要注意的一点就是:如果传递的是指针,一定要先创建一个变量
# 比如这里,一定是:先要 num = Num(),然后再 byref(num),不可以直接就 byref(Num())
# 原因很简单,因为 Num() 这种形式在创建完 Num 实例之后就销毁了,因为没有变量保存它,那么此时再修改指针指向的内存就会有问题,因为内存的值已经被回收了
# 如果不是指针,那么可以直接传递 Num(),因为拷贝了一份

所以在这里,C 中返回一个指针是没有问题的,因为它指向的对象是我们在 Python 中创建的,Python 会管理它。

回调函数

在看回调函数之前,我们先看看如何把一个函数赋值给一个变量。准确的说,是让一个指针指向一个函数,这个指针叫做函数指针。通常我们说的指针变量是指向一个整型、字符型或数组等等,而函数指针是指向函数。

#include <stdio.h>

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

int main() {
  // 创建一个指针变量 p,让 add 等于 p
  // 我们看到就类似声明函数一样,指定返回值类型和变量类型即可
  // 但是注意的是,中间一定是 *p,不是 p,因为这是一个函数指针,所以要有 *
  int (*p)(int, int) = add;
  printf("1 + 3 = %d\n", p(1, 3)); //1 + 3 = 4
  return 0;
}

除此之外我们还以使用 typedef。

#include <stdio.h>


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

// 相当于创建了一个类型,名字叫做 func,这个 func 表示的是一个函数指针类型
typedef int (*func)(int, int);

int main() {
  // 声明一个 func 类型的函数指针 p,等于 add
  func p = add;
  printf("2 + 3 = %d\n", p(2, 3)); // 2 + 3 = 5
  return 0;
}

下面来看看如何使用回调函数,说白了就是把一个函数指针作为函数的参数。

#include <stdio.h>

char *evaluate(int score){
  if (score < 60 && score >= 0){
    return "bad";
  }else if (score < 80){
    return "not bad";
  }else if (score < 90){
    return "good";
  }else if (score <=100){
    return "excellent";
  }else {
    return "无效的成绩";
  }
}

//接收一个整型和一个函数指针,指针指向的函数接收一个整型返回一个 char *
char *execute1(int score, char *(*f)(int)){
  return f(score);
}

//除了上面那种方式,我们还可以跟之前一样通过 typedef
typedef char *(*func)(int);
// 这样声明也是可以的。
char *execute2(int score, func f){
  return f(score);
}


int main(int argc, char const *argv[]) {
  printf("%s\n", execute1(88, evaluate)); // good
  printf("%s\n", execute2(70, evaluate)); // not bad
}

我们知道了在 C 中传入一个函数,那么在 Python 中如何定义一个 C 语言可以识别的函数呢?毫无疑问,类似于结构体,我们肯定是要先定义一个 Python 的函数,然后再把 Python 的函数转化成 C 语言可以识别的函数。

int add(int a, int b, int (*f)(int *, int *)){
  return f(&a, &b);
}

我们就以这个函数为例,add 函数返回一个 int,接收两个 int,和一个函数指针,那么我们如何在 Python 中定义这样的函数并传递呢?

from ctypes import *

lib = CDLL("./main.dll")


# 动态链接库中的函数接收的函数的参数是两个 int *,所以我们这里的 a 和 b 也是一个 pointer
def add(a, b):
    return a.contents.value + b.contents.value


# 此时我们把 C 中的函数用 Python 表达了,但是这样肯定是不可能直接传递的,能传就见鬼了
# 那我们要如何转化呢?
# 可以通过 ctypes 里面的函数 CFUNCTYPE 转化一下,这个函数接收任意个参数
# 但是第一个参数是函数的返回值类型,然后函数的参数写在后面,有多少写多少。
# 比如这里的函数返回一个 int,接收两个 int *,所以就是
t = CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
# 如果函数不需要返回值,那么写一个 None 即可
# 然后得到一个类型 t,此时的类型 t 就等同于 C 中的 typedef int (*t)(int*, int*);
# 将我们的函数传进去,就得到了 C 语言可以识别的函数 func
func = t(add)
# 然后调用,别忘了定义返回值类型,当然这里是 int 就无所谓了
lib.add.restype = c_int
print(lib.add(88, 96, func))
print(lib.add(59, 55, func))
print(lib.add(94, 105, func))
"""
184
114
199
"""

以上便是 ctypes 的基本用法,但其实我们可以通过 ctypes 玩出更高级的花样,甚至可以串改内部的解释器。ctypes 内部提供了一个属性叫 pythonapi,它实际上就是加载了 Python 安装目录里面的 python38.dll。有兴趣可以自己去了解一下,需要你了解底层的 Python / C API,当然我们也很少这么做。对于 ctypes 调用 C 库而言,我们目前算是介绍完了。

使用 C / C++ 为 Python 开发扩展模块

我们上面介绍 ctypes,我们说这种方式它不涉及任何的 Python / C API,但是它只能做一些简单的交互。而如果是编写扩展模块的话,那么它是可以被 Python 解释器识别的,也就是说我们可以通过 import 的方式进行导入。

关于扩展模块,这里不得不再提一下 Cython,使用 Python / C API 编写扩展不是一件轻松的事情,其实还是 C 语言本身比较底层吧。而 Cython 则是帮我们解决了这一点,Cython 代码和 Python 高度相似,而 cython 编译器会自动帮助我们将 Cython 代码翻译成C代码,所以Cython本质上也是使用了 Python / C API。只不过它让我们不需要直接面对C,只要我们编写 Cython 代码即可,会自动帮我们转成 C 的代码。

所以随着 Cython 的出现,现在使用 Python / C API 编写扩展算是越来越少了,不过话虽如此,使用 Python / C API 编写可以极大的帮助我们熟悉 Python 的底层。

那么废话不多说,直接开始吧。

编写扩展模块的基本骨架

首先使用 C / C++ 为 Python 编写扩展的话,是需要遵循一定套路的,而这个套路很固定。那么下面就来介绍一下整个流程:

Python 的扩展模块是需要被 import 进来的,那么它必然要有一个入口。

// 这个 xxx 非常重要,这个是你最终生成的扩展模块的名字,前面的 PyInit 是写死的
PyInit_xxx(void)  // 模块初始化入口

有了入口之后,我们还需要创建模块,创建模块使用下面这个函数。

PyModule_Create  // 创建模块

创建模块,那么总要有模块信息吧。

PyModuleDef  // 模块信息

那么模块信息里面都可以包含哪些信息呢?模块名算吧,模块里面有哪些函数算吧。

PyMethodDef  // 模块函数信息, 一个数组, 因为一个模块可以包含多个函数

而一个 Python 中的函数底层会对应一个结构体,这个结构体里面保存了 Python 函数的元信息,并且还保存了一个指向 C 函数的指针,这是显然的。

我们通过一个例子来说明以下吧,这样会更好理解一些,具体细节在编写代码的时候再补充。

def f1():
    return 123


def f2(a):
    return a + 1

以上是非常简单的一个模块,里面只有两个简单的函数,但是我们知道当被导入时它就是一个 PyModuleObject 对象。里面除了我们定义的两个函数之外还有其它的属性,显然这是 Python 解释器在背后帮助我们完成的,具体流程也是我们上面说的那几步(省略了亿点点细节)。

那么我们如何使用 C 来进行编写呢?下面来操作一下。

/*
编写 Python 扩展模块,需要引入 Python.h 这个头文件
该头文件在 Python 安装目录的 include 目录下,我们必须要导入它
当然这个头文件里面还导入了很多其它的头文件,我们也可以直接拿来用
*/
#include "Python.h"

/*
编写我们之前的两个函数 f1 和 f2,必须返回 PyObject *
函数里面至少要接收一个 PyObject *self,而这个参数我们是不需要管的,当然不叫 self 也是可以的
显然跟方法里面的 self 是一个道理,所以对于 Python 调用者而言,f1 是一个不需要接收参数的函数
*/
static PyObject *
f1(PyObject *self) {
    return PyLong_FromLong(123);
}

static PyObject *
f2(PyObject *self, PyObject *a) {
    long x;
    // 转成 C 中的 long,进行相加,然后再转成 Python 的 int; 或者调用 PyNumber_Add() 也可以
    x = PyLong_AsLong(a);
    PyObject *result = PyLong_FromLong(x + 1);
    return result;
}
// 但是注意:虽然我们定义了 f1 和 f2,但是它们是 C 中的函数,不是 Python 的
// Python 中的函数在 C 中对应的是一个结构体,里面会有函数指针,指向这里的 f1 和 f2
// 但除了函数指针,还有其它的信息

/*
定义一个结构体数组,结构体类型为 PyMethodDef,显然这个 PyMethodDef 就是 Python 中的函数
PyMethodDef 里面有四个成员,分别是:函数名、函数指针(需要转成PyCFunction)、函数参数标识、函数的doc
关于 PyMethodDef 我们后面会单独说
*/
static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_NOARGS, // 后面单独说
        "this is a function named f1"
    },
    {"f2", (PyCFunction) f2, METH_O, "this is a function named f2"},
    // 结尾要有一个 {NULL, NULL, 0, NULL} 充当哨兵
    {NULL, NULL, 0, NULL}
};

/*
我们编写的 py 文件,解释器会自动把它变成一个模块,但是这里我们需要手动定义
下面定义一个 PyModuleDef 类型的结构体,它就是我们的模块信息
*/
static PyModuleDef module = {
    // 头部信息,PyModuleDef_Base m_base,正如所有对象都有 PyObject 这个结构体一样
    // 而 Python.h 中提供了一个宏,#define PyModuleDef_HEAD_INIT PyModuleDef_Base m_base; 我们可以使用 PyModuleDef_HEAD_INIT 来代替
    PyModuleDef_HEAD_INIT,
    "kagura_nana", // 模块的名字
    "this is a module named kagura_nana", // 模块的doc,没有的话直接写成NULL即可
    -1, // 模块的独立空间,这个不需要关心,直接写成 -1 即可
    methods, // 上面的 PyMethodDef 结构数组,必须写在这里,不然我们没法使用定义的函数
    // 下面直接写4个NULL即可
    NULL, NULL, NULL, NULL
};

// 以上便是 PyModuleDef 结构体实例的创建过程,至于里面的一些细节我们后面说
// 到目前为止,前置工作就做完了,下面还差两步
/*
扩展库入口函数,这是一个宏,Python 的源代码我们知道是使用 C 来编写的
但是编译的时候为了支持 C++ 的编译器也能编译,于是需要通过 extern "C" 定义函数
然后这样 C++ 编译器在编译的的时候就会按照 C 的标准来编译函数,这个宏就是干这件事情的,主要和 Python 中的函数保持一致
*/
PyMODINIT_FUNC

/*
模块初始化入口,注意:模块名叫 kagura_nana,那么下面就必须要写成 PyInit_kagura_nana
*/
PyInit_kagura_nana(void)
{
    // 将 PyModuleDef 结构体实例的指针传递进去,然后返回得到 Python 中的模块
    return PyModule_Create(&module);
}

整体逻辑还是非常简单的,过程如下:

  • include "Python.h",这个是必须的
  • 定义我们函数,具体定义什么函数、里面写什么代码完全取决于你的业务
  • 定义一个PyMethodDef结构体数组
  • 定义一个PyModuleDef结构体
  • 定义模块初始化入口,然后返回模块对象

那么如何将这个 C 文件变成扩展模块呢?显然要经过编译,而 Python 提供了 distutils 标准库,可以非常轻松地帮我们把 C 文件编译成扩展模块。

from distutils.core import *

setup(
    # 打包之后会有一个 egg_info,表示该模块的元信息信息,name 就表示打包之后的 egg 文件名
    # 显然和模块名是一致的
    name="kagura_nana",
    version="1.11", # 版本号
    author="古明地盆",
    author_email="66666@东方地灵殿.com",
    # 关键来了,这里面接收一个类 Extension,类里面传入两个参数
    # 第一个参数是我们的模块名,必须和 PyInit_xxx 中的 xxx 保持一致,否则报错
    # 第二个参数是一个列表,表示用到了哪些 C 文件,因为扩展模块对应的 C 文件不一定只有一个,我们这里的 C 文件还叫 main.c
    ext_modules=[Extension("kagura_nana", ["main.c"])]
)

当前的 py 文件名叫做 1.py,我们在控制台中直接输入 python 1.py install 即可。注意:在介绍 ctypes 我用的是 gcc,但这里默认是使用 Visual Studio 2017 进行编译的。

我们看到对应的 pyd 已经生成了,在你当前目录会有一个 build目录,然后 build 目录中 lib 开头的目录里面便存放了编译好的 pyd文件,并且还自动帮我们拷贝到了 site-packages 目录中。

我们看到了 kagura_nana.cp38-win_amd64.pyd 文件,中间的部分表示解释器的版本,所以编写扩展模块的方式虽然可定制性更高,但它除了操作系统之外,还需要特定的解释器版本。因为中间是 cp38,所以只能 Python3.8 版本的解释器才可以导入它。然后还有一个 egg-info,它是我们编写的模块的元信息,我们打开看看。

有几个我们没有写,所以是 UNKNOW,当然这都不重要,重要的是我们能不能调用,试一试吧。

import kagura_nana

print(kagura_nana)  # <module 'kagura_nana' from 'C:\\python38\\lib\\site-packages\\kagura_nana.cp38-win_amd64.pyd'>
print(kagura_nana.f1())  # 123
print(kagura_nana.f2(123))  # 124

可以看到调用是没有任何问题的,最后再看一个神奇的东西,我们知道在 pycharm 这样的智能编辑器中,通过 Ctrl 加左键可以调到指定模块的指定位置。

神奇的一幕出现了,我们点击进去居然还能跳转,其实我们在编译成扩展模块移动到 site-packages 之后,pycharm 会进行检测、然后将其抽象成一个普通的 py 文件,方便你查看。我们看到模块注释、函数的注释跟我们在 C 文件中指定的一样。但是注意:该文件只是 pycharm 方便你查看函数注释等信息而专门做的一个抽象,事实上你把这个文件删掉也是没有关系的。

因此我们可以再总结一下整体流程:

第一步:include "Python.h",必须要引入这个头文件,这个头文件中还引入了 C 中的一些头文件,具体都引入了哪些库我们可以查阅。当然如果不确定但又懒得看,我们还可以手动再引入一次,反正 include 同一个头文件只会引入一次。

第二步:理论上这不是第二步,但是按照编写代码顺序我们就认为它是第二步吧,对,就是按照我们上面写的代码从上往下撸。这一步你需要编写函数,这个函数就是 C 语言中定义的函数,这个函数返回一个 PyObject * ,至少要接收一个PyObject *,我们一般叫它 self,这第一个参数你可以看成是必须的,无论我们传不传其他参数,这个参数是必需要有的。所以如果只有这一个参数,那么我们就认为这个函数不接收参数,因为我们在调用的时候没有传递。

static PyObject *
f1(PyObject *self)
{
}

static PyObject *
f2(PyObject *self)
{
}

static PyObject *
f3(PyObject *self)
{
}
// 假设我们定义了这三个函数吧,三个函数都不接受参数

第三步:定义一个 PyMethodDef 类型的数组,这个数组也是我们后面的 PyModuleDef 对象中的一个参数,这个数组名字叫什么就无所谓了。至于 PyMethodDef,我们可以单独使用 PyMethodDef 创建实例,然后将变量写到数组中,也可以直接在数组中创建。如果是直接在数组中创建的话,那么就不需要再使用 PyMethodDef 定义了,直接在 {} 里面写成员信息即可。

static PyMethodDef module_functions[] = {
    {	
        // 暴露给 Python 的函数名
        "f1",
        // 函数指针,最好使用 PyCFunction 转一下,可以确保不出问题。
        // 如果不转,我自己测试没有问题,但是编译时候会给警告,最好还是按照标准,把指针的类型转换一下
        // 转换成 Python 底层识别的 PyCFunction
        (PyCFunction) f1, 
        METH_NOARGS, // 参数类型,至于怎么接收 *args 和 **kwargs 的参数,后面说
        "函数f1的注释"
    },
    {"f2", (PyCFunction)f2, METH_NOARGS, "函数f2的注释"},
    {"f3", (PyCFunction)f3, METH_NOARGS, "函数f3的注释"},
    //别忘记,下面的 {NULL, NULL, 0, NULL},充当哨兵
    {NULL, NULL, 0, NULL}
}

第四步:定义 PyModuleDef 对象,这个变量的名字叫什么也没有要求。

static PyModuleDef m = {
    PyModuleDef_HEAD_INIT, // 头部信息
    // 模块名,这个是有讲究的,你要编译的扩展模块叫啥,这里就写啥
    "kagura_nana", 
    "模块的注释",
    -1, // 模块的空间,这个是给子解释器调用的,我们不需要关心,直接写 -1 即可,表示不使用
    module_functions, // 然后是我们上面定义的数组名,里面放了一大堆的 PyMethodDef 结构体实例
    // 然后是四个 NULL,因为该结构还有其它成员,但我们不需要使用,所以指定 NULL 即可。当然有的编译器比较智能,你若不指定自动为 NULL
    // 但为了规范,我们还是手动写上,因为规范的做法就是给每个成员都赋上值
    NULL,
    NULL,
    NULL,
    NULL
}

第五步:写上一个宏,其实把它单独拆分出来,有点小题大做了。

PyMODINIT_FUNC
// 一个宏,主要是保证函数按照 C 的标准,不用在意,写上就行

第六步:创建一个模块的入口函数,我们说编译的扩展模块叫 kagura_nana,那么这个函数名就要这么写。

PyInit_kagura_nana(void)
{
    // 会根据上面定义的 PyModuleDef 实例,得到 Python 中的模块
    // PyModule_Create 就是用来创建 Python 中的模块的,直接将 PyModuleDef 定义的对象的指针扔进去
    // 便可得到 Python 中的模块,然后直接返回即可。
    return PyModule_Create(&m);
}

第七步:定义一个py文件,假设叫 xx.py,那么在里面写上如下内容,然后 python xx.py install 即可。

from distutils.core import  *

setup(
    # 这是生成的 egg 文件名,也是里面的元信息中的 Name
    name="kagura_nana",
    # 版本号
    version="10.22",  
    # 作者
    author="古明地觉",  
    # 作者邮箱
    author_email="东方地灵殿",
    # 当然还有其它参数,作为元信息来描述模块,比如 description:模块介绍。
    # 有兴趣的话可以看函数的注释,或者根据已有的 egg 文件自己查看
    # 下面是扩展模块,Extension("yousa", ["C源文件"])
    # 我们说 Extension 里面的第一个参数也必须是你的扩展模块的名字,并且必须要和 PyInit_xxx 以及 PyModuleDef 中的第一个成员保持一致
    # 至于第二个参数就是一个列表,你需要用到哪些 C 源文件。
    # 而且我们看到这个 Extension 也在一个列表里面,因为我们也可以传入多个 Extension 同时生成多个扩展模块。
    # 我们可以写好一个生成一个,你也可以一次性写多个,然后只编译一次。
    ext_modules=[Extension("hanser", ["a.c"])]

以上便是编写扩展模块的基本流程,但是里面还有很多细节没有说。

PyMethodDef

首先是 PyMethodDef,我们说它对应的是 Python 中的函数,那么我们肯定要来看看它的定义,藏身于 Include/methodobject.h 中。

struct PyMethodDef {
    /* 函数名 */
    const char  *ml_name;   
    /* 实现对应逻辑的 C 函数,但是需要转成 PyCFunction 类型,主要是为了更好的处理关键字参数 */
    PyCFunction ml_meth;    
    
    /* 参数类型 
    #define METH_VARARGS  0x0001  扩展位置参数,*args
    #define METH_KEYWORDS 0x0002  扩展关键字参数,**kwargs
    #define METH_NOARGS   0x0004  不需要参数
    #define METH_O        0x0008  需要一个参数
    #define METH_CLASS    0x0010  被 classmethod 装饰
    #define METH_STATIC   0x0020  被 staticmethod 装饰   
    */
    int         ml_flags;   
    
    //函数的 __doc__,没有的话传递 NULL
    const char  *ml_doc; 
};
typedef struct PyMethodDef PyMethodDef;

如果不需要参数,那么 ml_flags 传入一个 METH_NOARGS;接收一个参数传入 METH_O;所以我们上面的 f1 对应的 ml_flags 是 METHOD_NOARGS,f2 对应的 ml_flags 是 METH_O。

如果是多个参数,那么直接写成 METH_VARAGRS 即可,也就是通过扩展位置参数的方式,但是这要如何解析呢?比如:有一个函数f3接收3个参数,这在C中要如何实现呢?别急我们后面会说。

引用计数和内存管理

我们在最开始的时候就说过,PyObject 贯穿了我们的始终。我们说这里面存放了引用计数和类型指针,并且 Python 中所有对象底层对应的结构体都嵌套了 PyObject,因此 Python 中的所有对象都有引用计数和类型。并且 Python 的对象在底层,都可以看成是 PyObject 的一个扩展,因此参数、返回值都是 PyObject *,至于具体类型则是通过里面的 ob_type 动态判断。比如:之前使用的 PyLong_FromLong。

PyObject *
PyLong_FromLong(long ival)
{
    PyLongObject *v;
    // ...
    return (PyObject *)v;
}

此外 Python 还专门定义了几个宏,来看一下:

#define Py_REFCNT(ob)           (((PyObject*)(ob))->ob_refcnt)
#define Py_TYPE(ob)             (((PyObject*)(ob))->ob_type)
#define Py_SIZE(ob)             (((PyVarObject*)(ob))->ob_size)

Py_REFCNT:拿到对象的引用计数;Py_TYPE:拿到对象的类型;Py_SIZE:拿到对象的ob_size,也就是变长对象里面的元素个数。除此之外,Python 还提供了两个宏:Py_INCREF 和 Py_DECREF 来用于引用计数的增加和减少。

// 引用计数增加很简单,就是找到 ob_refcnt,然后 ++
#define Py_INCREF(op) (                         \
    _Py_INC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
    ((PyObject *)(op))->ob_refcnt++)

// 但是减少的话,做的事情稍微多一些
// 其实主要就是判断引用计数是否为 0,如果为 0 直接调用 _Py_Dealloc 将对象销毁
// _Py_Dealloc 也是一个宏,会调用对应类型对象的 tp_dealloc,也就是析构方法
#define Py_DECREF(op)                                   \
    do {                                                \
        PyObject *_py_decref_tmp = (PyObject *)(op);    \
        if (_Py_DEC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
        --(_py_decref_tmp)->ob_refcnt != 0)             \
            _Py_CHECK_REFCNT(_py_decref_tmp)            \
        else                                            \
            _Py_Dealloc(_py_decref_tmp);                \
    } while (0)

当然这些东西我们在系列的最开始的时候就已经说过了,但是接下来我们要引出一个非常关键的地方,就是内存管理。到目前为止我们没有涉及到内存管理的操作,但我们知道 Python 中的对象都是申请在堆区的,这个是不会自动释放的。举个栗子:

static PyObject *
f(PyObject *self)
{	
    PyObject *s = PyUnicode_FromString("你好呀~~~");
    // Py_None 就是 Python 中的 None, 同理还有 Py_True、Py_False,我们后面会继续提
    // 这里增加引用计数,至于为什么要增加,我们后面说
    Py_INCREF(Py_None);
    return Py_None;	
}

这个函数不需要参数,如果我们写一个死循环不停的调用这个函数,你会发现内存的占用蹭蹭的往上涨。就是因为这个 PyUnicodeObject 是申请在堆区的,此时内部的引用计数为 1。函数执行完毕变量 s 被销毁了,但是 s 是一个指针,这个指针被销毁了是不假,但是它指向的内存并没有被销毁。

static PyObject *
f(PyObject *self, PyObject *args, PyObject *kw)
{	
    PyObject *s = PyUnicode_FromString("hello~~~");
    Py_DECREF(s);
    Py_INCREF(Py_None);
    return Py_None;	
}

因此我们需要手动调用 Py_DECREF 这个宏,来将 s 指向的 PyUnicodeObject 的引用计数减 1,这样引用计数就为 0 了。不过有一个特例,那就是当这个指针作为返回值的时候,我们不需要手动减去引用计数,因为会自动减。

static PyObject *
f(PyObject *self)
{	
    PyObject *s = PyUnicode_FromString("hello~~~");
    // 如果我们把 s 给返回了,那么我们就不需要调用 Py_DECREF 了
    // 因为一旦作为返回值,那么会自动减去 1
    // 所以此时 C 中的对象是由 Python 来管理的,准确的说应该是作为返回值的指针指向的对象是由 Python 来管理的
    return s;	
    // 所以在返回 Py_None 的时候,我们需要手动将引用计数加 1,因为它作为了返回值。
    // 如果你不加 1,那么当你无限调用的时候,总会有那么一刻,Py_None 会被销毁,因为它的引用计数在不断减少
    // 但当销毁 Py_None 的时候,会出现 Fatal Python error: deallocating None,解释器异常退出
}

不过这里还存在一个问题,那就是我们在 C 中返回的是 Python 传过来的。

static PyObject *
f(PyObject *self, PyObject *val)
{	
	//传递过来一个 PyObject *,然后原封不动的返回
	return val;	
}

显然上面 val 指向的内存不是在 C 中调用 api 创建的,而是 Python 创建然后传递过来的,也就是说这个 val 已经指向了一块合法的内存(和增加 Py_None 引用计数类似)。但是内存中的对象的引用计数是没有变化的,虽说有新的变量(这里的 val)指向它了,但是这个 val 是 C 中的变量不是 Python 中的变量,因此它的引用计数是没有变化的。然后作为返回值返回之后,指向对象的引用计数减一。所以你会发现在 Python 中,创建一个变量,然后传递到 f 中,执行完之后再进行打印就会发生段错误,因为对应的内存已经被回收了。如果能正常打印,说明在 Python 中这个变量的引用计数不为 1,也可能是小整数对象池、或者有多个变量引用,那么就创建一个大整数或者其他的对象多调用几次,因为作为返回值,每次调用引用计数都会减1。

static PyObject *
f(PyObject *self)
{	
    // 假设创建一个 PyListObject
    PyObject *l1 = PyList_New(2);
    // 将 l1 赋值给 l2,但是不好意思,这两位老铁指向的 PyListObject 的引用计数还是 1
    PyObject *l2 = l1;
    Py_INCREF(Py_None);
    return Py_None;	
}

因此我们说,如果在 C 中创建一个 PyObject 的话,那么它的引用计数会是 1,因为对象被初始化了,引用计数默认是 1。至于传递,无论你在 C 中将创建 PyObject * 赋值给了多少个变量,它们指向的 PyObject 的引用计数都会是 1。因为这些变量是 C 中的变量,不是 Python 中的。

因此我们的问题就很好解释了,我们说当一个 PyObject * 作为返回值的时候,它指向的对象的引用计数会减去 1,那么当 Python 传递过来一个 PyObject * 指针的时候,由于它作为了返回值,因此调用之后会发现引用计数会减少了。因此当你在 Python 中调用扩展函数结束之后,这个变量指向的内存可能就被销毁了。如果你在 Python 传递过来的指针没有作为返回值,那么引用计数是不会发生变化的,但是一旦作为了返回值,引用计数会自动减 1,因此我们需要手动的加 1。

static PyObject *
f(PyObject *self, PyObject *val)
{	
    Py_INCREF(val);
    return val;	
}

因此我们可以得出如下结论:

如果在 C 中,创建一个 PyObject *var,并且 var 已经指向了合法的内存,比如调用 PyList_New、PyDict_New 等等 api 返回的 PyObject *,总之就是已经存在了 PyObject。那么如果 var 没有作为返回值,我们必须手动地将 var 指向的对象的引用计数减 1,否则这个对象就会在堆区一直待着不会被回收。可能有人问,如果 PyObject *var2 = var,我将 var 再赋值给一个变量呢?那么只需要对一个变量进行 Py_DECREF 即可,当然对哪个变量都是一样的,因为在 C 中变量的传递不会导致引用计数的增加。

如果 C 中创建的 PyObject * 作为返回值了,那么会自动将指向的对象的引用计数减 1,因此此时该指针指向的内存就由 Python 来管理了,就相当于在 Python 中创建了一个对象,我们不需要关心。

最后关键的一点,如果 C 中返回的指针指向的内存是 Python 中创建好的,假设我们在 Python 中创建了一个对象,然后把指针传递过来了,但是我们说这不会导致引用计数的增加,因为赋值的变量是 C 中的变量。如果 C 中用来接收参数的指针没有作为返回值,那么引用计数在扩展函数调用之前是多少、调用之后还是多少。然而一旦作为了返回值,我们说引用计数会自动减 1,因此假设你在调用扩展函数之前引用计数是 3,那么调用之后你会发现引用计数变成了2。为了防止段错误,一旦作为返回值,我们需要在返回之前手动地将引用计数加1。

C中创建的:不作为返回值,引用计数手动减 1、作为返回值,不处理;Python 中创建传递过来的,不作为返回值,不处理、作为返回值,引用计数手动加 1。

而实现引用计数增加和减少所使用的宏就是 Py_INCREF 和 Py_DECREF,但它们要求传递的 PyObject * 不可以为 NULL。如果可能为 NULL 的话,那么建议使用 Py_XINCREF 和 Py_XDECREF。

参数的解析

我们说,PyMethodDef 内部有一个 ml_flags 属性,表示此函数的参数类型,我们说有如下几种:

1. 不接受参数,METH_NOARGS,对应函数格式如下:

static PyObject *
f(PyObject *self)
{
    
}

2. 接受一个参数,METH_O,对应函数格式如下:

static PyObject *
f(PyObject *self, PyObject *val)
{
    
}

3. 接受任意个位置参数,METH_VARARGS,对应函数格式如下:

static PyObject *
f(PyObject *self, PyObject *args)
{
    
}

4. 接受任意个位置参数和关键字参数,METH_VARARGS | METH_KEYWORDS,对应函数格式如下:

static PyObject *
f(PyObject *self, PyObject *args, PyObject *kwargs)
{
    
}

第一种和第二种显然都很简单,关键是第三种和第四种要怎么做呢?我们先来看看第三种,解析多个位置参数可以使用一个函数:PyArg_ParseTuple。

解析多个位置参数

函数原型:int PyArg_ParseTuple(PyObject *args, const char *format, ...); 位于 Python/getargs.c 中

所以重点就在 PyArg_ParseTuple 上面,我们注意到里面有一个 format,显然类似于 printf,里面肯定是一些占位符,那么都支持哪些占位符呢?常用的如下:

  • i:接收一个 Python 中的 int,然后解析成 C 的 int
  • l:接收一个 Python 中的 int,然后将传来的值解析成 C 的 long
  • f:接收一个 Python 中的 float,然后将传来的值解析成 C 的 float
  • d:接收一个 Python 中的 float,然后将传来的值解析成 C 的 double
  • s:接收一个 Python 中的 str,然后将传来的值解析成 C 的 char *
  • u:接收一个 Python 中的 str,然后将传来的值解析成 C 的 wchar_t *
  • O:接收一个 Python 中的 object,然后将传来的值解析成 C 的 PyObject *

我们举个栗子:

static PyObject *
f(PyObject *self, PyObject *args)
{
    // 目前我们定义了一个 PyObject *args,如果是 METH_O,那么这个 args 就是对应的一个参数
    // 如果 METH_VARAGRS,还是只需要定义一个 *args 即可,只不过此时的 *args 是一个 PyTupleObject,我们需要将多个参数解析出来
    //假设此时我们这个函数是接收 3 个 int,然后相加    
    int a, b, c;
    /*
    下面我们需要使用 PyArg_ParseTuple 进行解析,因为我们接收三个参数
    这个函数返回一个整型,如果失败会返回 0,成功返回非 0
    */
    if (!PyArg_ParseTuple(args, "iii", &a, &b, &c)){
        // 失败我们需要返回 NULL
        return NULL;
    }
    return PyLong_FromLong(a + b + c);
}

我们还是编译一下,当然编译的过程我们就不显示了,跟之前是一样的。并且为了方便,我们的模块名就不改了,但是编译之后的 pyd 文件内容已经变了。不过需要注意的是,我们说编译之后会有一个 build 目录,然后会自动把里面的 pyd 文件拷贝到 site-packages 中,如果你修改了代码,但是模块名没有变的话,那么编译之后的文件名还和原来一样。如果一样的话,那么由于已经存在相同文件了,可能就不会再拷贝了。因此两种做法:要么你把模块名给改了,这样编译会生成新的模块。要么编译之前记得把上一次编译生成的 build 目录先删掉,我们推荐第二种做法,不然 site-packages 目录下会出现一大堆我们自己定义的模块。

然后我们将 ml_flags 改成 METH_VARARGS,来测试一下。

#include "Python.h"

static PyObject *
f(PyObject *self, PyObject *args)
{
    int a, b, c;
    if (!PyArg_ParseTuple(args, "iii", &a, &b, &c)){
        return NULL;
    }
    return PyLong_FromLong(a + b + c);
}

static PyMethodDef methods[] = {
    {
        "f",
        (PyCFunction) f,
        // 这里需要改成 METH_VARAGRS,这个地方很重要,因为它表示了函数的参数类型。如果这个地方不修改的话,Python 在调用函数时会发生段错误
        METH_VARARGS, 
        "this is a function named f"
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana", 
    "this is a module named kagura_nana", 
    -1, 
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}

我们编译成扩展模块之后,来测试一下,但是注意,你在调用的时候 pycharm 可能会感到别扭。

因为在调用函数 f 的是给你飘黄了,原因就是我们上一次在生成 pyd 的时候,里面的函数是 f1 和 f2,并没有 f。而我们 pycharm 会将 pyd 抽象成一个普通的 py 文件让你查看,但同时它也是 pycharm 自动提示的依据。因为上一次 pycharm 已经抽象出来了这个文件,而里面没有 f 这个函数,所以这里会飘黄。但是不用管,因为我们调用的是生成的 pyd 文件,跟 pycharm 抽象出来的 py 文件无关。

import kagura_nana

# 传参不符合,自动给你报错
try:
    print(kagura_nana.f())
except TypeError as e:
    print(e)  # function takes exactly 3 arguments (0 given)

try:
    print(kagura_nana.f(123))
except TypeError as e:
    print(e)  # function takes exactly 3 arguments (1 given)

try:
    print(kagura_nana.f(123, "xxx", 123, 123))
except TypeError as e:
    print(e)  # function takes exactly 3 arguments (4 given)

try:
    kagura_nana.f(123, 123.0, 123)  # int: 123, long: 123, float: 123.000000, double: 123.000000
except TypeError as e:
    print(e)  # integer argument expected, got float

print(kagura_nana.f(123, 123, 123))  # 369

怎么样,是不是很简单呢?当然 PyArg_ParseTuple 解析失败,Python 底层自动帮你报错了,告诉你缺了几个参数,或者哪个参数的类型错了。

我们这里是以 i 进行演示的,至于其它的几个占位符也是类似的。当然 O 比较特殊,因为它是转成 PyObject *,所以此时我们是可以传递元组、列表、字典等任意高阶对象的。而我们之前的 ctypes 则是不支持的,还是那句话,因为它没有涉及任何 Python / C API 的调用,显然数据的表达能力有限。

解析成 PyObject *

我们说 PyArg_ParseTuple 中的 i 代表 int、l 代表 long、f 代表 float、d 代表 double、s 代表 char*、u代表 wchar_t *,这些都比较简单。我们重点是 O,其实 O 也不难,无非就是后续的一些 Python / C API 调用罢了。

我们还是以普通的 py 文件为例:

def foo(lst: list):
    """
    假设我们传递一个列表, 然后返回一个元组, 并且将里面的元素都设置成元素的类型
    :return:
    """
    return tuple([type(item) for item in lst])


print(foo([1, 2, "3", {}]))  # (<class 'int'>, <class 'int'>, <class 'str'>, <class 'dict'>)

如果使用 C 来编写扩展的话,要怎么做呢?

#include "Python.h"

static PyObject *
foo(PyObject *self, PyObject *args)
{
    PyObject *lst;  // 首先我们这里要接收一个 PyObject *
    // 我们要修改 lst,让它指向我们传递的列表, 因此要传递一个二级指针进行修改
    if (!PyArg_ParseTuple(args, "O", &lst)){
        return NULL;
    }
    // 计算列表中的元素个数,申请同样大小的元组。
    // 其实还可以使用 PyList_Size,底层也是调用了 Py_SIZE,只是 PyList_Size 会进行类型检测,同理还有 PyTuple_Size 等等
    Py_ssize_t arg_count = Py_SIZE(lst);
    // 申请完毕之后,里面的元素全部是 NULL,然后我们来进行设置
    // 但是这里我们故意多申请一个,我们看看 NULL 在 Python 中的表现是什么
    PyObject *tpl = PyTuple_New(arg_count + 1);
    // 申明类型对象、以及元素
    PyObject *type, *val;
    for (int i = 0; i < arg_count; i++) {
        val = PyList_GetItem(lst, i);  // 获取对应元素,赋值给 val
        // 获取对应的类型对象,但得到的是 PyTypeObject *,所以需要转成 PyObject *
        // 或者你使用 Py_TYPE 这个宏也可以,内部自动帮你转了
        type = (PyObject *)val -> ob_type;
        //设置到元组中
        PyTuple_SetItem(tpl, i, type);
    }
    return tpl;
}

static PyMethodDef methods[] = {
    {
        "foo",
        (PyCFunction) foo,
        // 记得这里写上 METH_VARARGS, 假设我们写的是 METH_NOARGS, 那么即便我们上面定义了参数也是没有意义的
        // 调用的时候 Python 会提示你: TypeError: foo() takes no arguments
        METH_VARARGS,
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}

然后使用 Python 测试一下:

import kagura_nana
print(
    kagura_nana.foo([1, 2, "3", {}])
)  # (<class 'int'>, <class 'int'>, <class 'str'>, <class 'dict'>, <NULL>)

# 我们看到得到结果是一致的,并且我们多申请了一个空间,但是没有设置,所以结尾多了一个 <NULL>
# 但是注意:不要试图通过 kagura_nana.foo([1, 2, "3", {}])[-1] 的方式来获取这个 NULL,会造成段错误
# 因为 Python 操作指针会自动操作指针指向的内存,而 NULL 是一个空指针,指向的内存是非法的
# 另外段错误是一种非常可怕的错误,它造成的结果就是解释器直接就异常退出了。
# 并且这不是异常捕获能解决的问题,异常捕获也是解释器正常运行的前提下。因此申请容器的时候,要保证元数个数相匹配

从这里我们也能看出使用 C 来为 Python 写扩展是一件多么麻烦的事情,因此 Cython 的出现是一个福音。当然我们上面的代码只是演示,没有太大意义,完全可以用 Python 实现。

传递字符串

然后我们再来看看字符串的传递,比较简单,说白了这些都是 Python / C API 的调用。

#include "Python.h"

static PyObject *
f1(PyObject *self, PyObject *args)
{
   // 这里我们接受任意个字符串,然后将它们拼接在一起,最后放在列表中返回。
   // 由于是任意个,所以无法使用 PyArg_ParseTuple 了
   // 因为我们不知道占位符要写几个 O,但我们说 args 是一个元组,那么我们可以按照元组的方式进行解析
   Py_ssize_t arg_count = Py_SIZE(args);  // 计算元组的长度
   PyObject *res = PyUnicode_FromWideChar(L"", 0);  // 返回值,因为包含中文,所以是宽字符

   for (int i=0; i < arg_count; i++){
       // 将 res 和 里面的字符串依次拼接,等价于字符串的加法
       res = PyUnicode_Concat(res, PyTuple_GetItem(args, i));
   }
   // 我们上面这种做法比较笨,直接通过 PyUnicode_Join 直接拼接不香吗?我们目前先这么做,join 的话在下面的 f2 函数中
   // 然后创建一个列表,将结果放进去。我们申请列表,容量只需要为 1 即可
   PyObject *lst = PyList_New(1);
   PyList_SetItem(lst, 0, res);
   // 我们说 lst 是在 C 中创建的, 但是它作为了返回值, 所以我们不需要关心它的引用计数, 因为会自动减一
   // 那 res 怎么办?它要不要减少引用计数,答案是不需要、也不能,因为它作为了容器的一个元素(这里面有很多细节,我们暂且不表,在后面介绍 PyDictObject 的时候再说)
   return lst;
}

static PyObject *
f2(PyObject *self, PyObject *args)
{
    // 这里还可以指定连接的字符,这里就直接返回吧
    PyObject *res = PyUnicode_Join(PyUnicode_FromWideChar(L"||", 2), args);
    return res;
}

static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_VARARGS,
        NULL
    },
    {
        "f2",
        (PyCFunction) f2,
        METH_VARARGS,
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}

Python 进行调用,看看结果。

import kagura_nana

print(kagura_nana.f1("哼哼", "嘿嘿", "哈哈"))  # ['哼哼嘿嘿哈哈']
print(kagura_nana.f2("哼哼", "嘿嘿", "哈哈"))  # 哼哼||嘿嘿||哈哈

我们看到结果是没有问题的,还是蛮有趣的。

类型检查和返回异常

在 Python 中,当我们传递的类型不对时会报错。那么在底层我如何才能检测传递过来的参数是不是想要的类型呢?首先我们想到的是通过 ob_type,假设我们要求 val 是一个 int,那么:

#include "Python.h"

static PyObject *
f1(PyObject *self, PyObject *val)
{	
    // 获取类型名称, 如果是字符串,那么 tp_name 就是 "str",字典是 "dict"
    const char *tp_name = val -> ob_type -> tp_name;
    char *res;
    if (strcmp(tp_name, "int") == 0) {
        res = "success";
    } else {
        res = "failure";
    }
    return PyUnicode_FromString(res);
}

static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_O,
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}
import kagura_nana
print(kagura_nana.f1(123))  # success
print(kagura_nana.f1("123"))  # failure

以上是一种判断方式,但是 Python 底层给我们提供了其它的 API 来进行判断。比如:

  • 判断是否为整型: PyLong_Check
  • 判断是否为字符串: PyUnicode_Check
  • 判断是否为浮点型: PyFloat_Check
  • 判断是否为复数: PyComplex_Check
  • 判断是否为元组: PyTuple_Check
  • 判断是否为列表: PyList_Check
  • 判断是否为字典: PyDict_Check
  • 判断是否为集合: PySet_Check
  • 判断是否为字节串: PyBytes_Check
  • 判断是否为函数: PyFunction_Check
  • 判断是否为方法: PyMethod_Check
  • 判断是否为实例对象: PyInstance_Check
  • 判断是否为类(type的实例对象): PyType_Check
  • 判断是否为可迭代对象: PyIter_Check
  • 判断是否为数值: PyNumber_Check
  • 判断是否为序列(实现 __getitem__ 和 __len__): PySequence_Check
  • 判断是否为映射(必须实现 __getitem__、__len__ 和 __iter__): PyMapping_Check
  • 判断是否为模块: PyModule_Check

写法非常固定,因此我们上面的判断逻辑就可以进行如下修改:

static PyObject *
f1(PyObject *self, PyObject *val)
{
    char *res;
    if (PyLong_Check(val)) {
        res = "success";
    } else {
        res = "failure";
    }
    return PyUnicode_FromString(res);
}

这种写法是不是就简单多了呢?其它部分不需要动,然后你可以自己重新编译、并测试一下,看看结果是不是一样的。

然后问题来了,如果用户传递的参数个数不对,或者类型不对,那么我们应该返回一个 TypeError,或者说返回一个异常。那么在 C 中,要如何设置异常呢?其实设置异常,说白了就是把输出信息打印到 stderr 中,然后直接返回 NULL 即可。

#include "Python.h"

static PyObject *
f1(PyObject *self, PyObject *args)
{
    Py_ssize_t arg_count = Py_SIZE(args);
    if (arg_count != 3) {
        // 这里是我们设置的异常, 其实参数个数不对的话, 我们可以借助于 PyArg_ParseTuple 来帮助我们
        // 因为指定的占位符已经表明了参数的个数
        PyErr_Format(PyExc_TypeError, ">>>>>> f1() takes 3 positional arguments but %d were given", arg_count);
    }

    // 然后我们要求第一个参数是整型, 第二个参数是字符串, 第三个参数是列表
    PyObject *a, *b, *c;
    // 因为参数一定是三个, 否则逻辑不会执行到这里, 因此我们不需要判断了
    PyArg_ParseTuple(args, "OOO", &a, &b, &c);
    // 检测
    if (!PyLong_Check(a)) {
        PyErr_Format(PyExc_ValueError, "The 1th argument requires a int, but got %s", Py_TYPE(a) -> tp_name);
    }
    if (!PyUnicode_Check(b)) {
        PyErr_Format(PyExc_ValueError, "The 2th argument requires a str, but got %s", Py_TYPE(b) -> tp_name);
    }
    if (!PyList_Check(c)) {
        PyErr_Format(PyExc_ValueError, "The 3th argument requires a list, but got %s", Py_TYPE(c) -> tp_name);
    }
    
    // 检测成功之后, 我们将整数和字符串添加到列表中
    PyList_Append(c, a);
    PyList_Append(c, b);
    // 这里我们将列表给返回, 而它是 Python 传递过来的, 所以一旦返回、引用计数会减一, 因此我们需要手动加一
    Py_INCREF(c);
    return c;
}

static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_VARARGS,
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}

所以逻辑就是像上面那样,通过 PyErr_Format 来设置异常,这个会被 Python 端接收到,但是异常一旦设置,就必须要返回 NULL,否则会出现段错误。但反过来吗,返回 NULL 的话则不一定要设置异常,但如果你不设置,那么 Python 底层会默认帮你设置一个 SystemError,并且异常的 value 信息为:<built-in function f1> returned NULL without setting an error,提示你返回了 NULL 但没有设置 error。因为返回 NULL 表示程序需要终止了,那么就应该把为什么需要终止的理由告诉使用者。

然后我们来测试一下:

import kagura_nana

try:
    kagura_nana.f1()
except Exception as e:
    print(e)  # >>>>>> f1() takes 3 positional arguments but 0 were given

try:
    kagura_nana.f1(1, 2, 3, 4)
except Exception as e:
    print(e)  # >>>>>> f1() takes 3 positional arguments but 4 were given

try:
    kagura_nana.f1(1, 2, 3)
except Exception as e:
    print(e)  # The 2th argument requires a str, but got int

lst = ["xx", "yy"]
print(kagura_nana.f1(123, "123", lst))  # ['xx', 'yy', 123, '123']
print(lst)  # ['xx', 'yy', 123, '123']

所表现的一切,都和我们在底层设置的一样。另外我们再来看看这个函数的身份是什么:

import kagura_nana

def foo(): pass

print(kagura_nana.f1)  # <built-in function f1>
print(sum)  # <built-in function sum>
print(foo)  # <function foo at 0x000001F1BAAF61F0>

我们居然实现了一个内置函数,怎么样是不是很神奇呢?因为扩展模块里面的函数和解释器内置的函数本质上都是一样的,所以它们都是 built-in。

返回布尔类型和 None

我们说函数都必须返回一个 PyObject *,如果这个函数没有返回值,那么在 Python 中实际上返回的是一个 None,但是我们不能返回 NULL,None 和 NULL 是两码事。在扩展函数中,如果返回 NULL 就表示这个函数执行的时候,不符合某个逻辑,我们需要终止掉,不能再执行下去了。这是在底层,但是在 Python 的层面,你需要告诉使用者为什么不能执行了,或者说底层的哪一行代码不满足条件,因此这个时候我们会在 return NULL 之前需要手动设置一个异常,这样在 Python 代码中才知道为什么底层函数退出了。当然有时候会自动帮我们设置,比如们说的 PyArg_ParseTuple。

那么在底层如何返回一个 None 呢?既然要返回我们就需要知道它的结构是什么。

# 首先在 Python 中,None 也是有类型的
print(type(None))  # <class 'NoneType'>

这个 NoneType 在底层对应的是 _PyNone_Type,至于 None 在底层对应的结构体是 _Py_NoneStruct,所以我们返回的时候应该返回这个结构体的指针。不过官方不推荐直接使用,而是给我们定义了一个宏,#define Py_None (&_Py_NoneStruct),我们直接返回 Py_None 即可。

不光是 None,我们说还有 True 和 False,True 和 False 对应的结构体是:_Py_FalseStruct,_Py_TrueStruct,它们本质上是 PyLongObject,Python 也不推荐直接返回,也是定义了两个宏。

  • #define Py_False ((PyObject *) &_Py_FalseStruct)
  • #define Py_True ((PyObject *) &_Py_TrueStruct)

推荐我们使用 Py_False 和 Py_True。

另外:

  • return Py_None; 等价于 Py_RETURN_NONE;
  • return Py_True; 等价于 Py_RETURN_TRUE;
  • return Py_False; 等价于 Py_RETURN_FALSE;

可以自己测试一下,比如条件满足返回 Py_True,不满足返回 Py_False 等等。

传递关键字参数

我们上面的例子都是通过位置参数实现的,如果我们通过关键字参数传递呢?很明显是会报错的,因为我们参数名叫什么都不知道,所以上面的例子都不支持关键字参数。那么下面我们就来看看关键字参数要如何实现。

传递关键字参数的话,我们是通过 key=value 的方式来实现,那么在 C 中我们如何解析呢?既然支持关键字的方式,那么是不是也可以实现默认参数呢?答案是肯定的,我们知道解析位置参数是通过 PyArg_ParseTuple,而解析关键字参数是通过 PyArg_ParseTupleAndKeywords。

函数原型: int PyArg_ParseTupleAndKeywords(PyObject *args, PyObject *kw, const char *format, char *keywords[], ...)

我们看到相比原来的 PyArg_ParseTuple,多了一个 kw 和一个 char * 类型的数组,具体怎么用我们在编写代码的时候说。

#include "Python.h"

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)  
{
    // 我们说函数既可以通过位置参数、还可以通过关键字参数传递,那么函数的参数类型就要变成 METH_VARARGS | METH_KEYWORDS
    // 参数 args 就是 PyTupleObject 对象, kwargs 就是 PyDictObject 对象
    // 假设我们定义了三个参数,name、age、place,这三个参数可以通过位置参数传递、也可以通过关键字参数传递
    wchar_t *name;
    int age = 17;
    wchar_t *gender = L"FEMALE";

    // 告诉 Python 解释器参数的名字,注意:里面字符串的顺序就是函数定义的参数顺序
    // 这里的字符串就是函数的参数名,上面的是变量名。其实变量名字叫什么无所谓,只是为了一致我们会起相同的名字
    char *keys[] = {"name", "age", "gender", NULL};
    // 注意结尾要有一个 NULL,否则会报出段错误。

    // 解析参数,我们看到 format 中本来应该是 uiu 的,但是中间出现了一个 |
    // 这就表示 | 后面的参数是可以不填的,如果不填会使用我们上面给出的默认值
    // 因此这里 name 就是必填的,因为它在 | 的前面,而 age 和 gender 可以不填,如果不填就用我们上面给出的默认值
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "u|iu", keys, &name, &age, &gender)){
        return NULL;
    }  // keys 就是函数的所以参数的名字,然后后面把指针传进去,注意顺序要和参数顺序保持一致
    wchar_t res[100];
    swprintf(res, 100, L"name: %s, age: %d, gender: %s", name, age, gender);
    return PyUnicode_FromWideChar(res, wcslen(res));
}

static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_VARARGS | METH_KEYWORDS,  // 注意这里, 因为支持位置参数和关键字参数, 所以是 METH_VARARGS | METH_KEYWORDS
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}

用 Python 来测试一下。

import kagura_nana

try:
    print(kagura_nana.f1())
except Exception as e:
    print(e)  # function missing required argument 'name' (pos 1)

try:
    print(kagura_nana.f1(123))
except Exception as e:
    print(e)  # argument 1 must be str, not int

print(kagura_nana.f1("古明地觉"))  # name: 古明地觉, age: 17, gender: FEMALE
print(kagura_nana.f1("古明地恋", 16))  # name: 古明地恋, age: 16, gender: FEMALE
print(kagura_nana.f1("古明地恋", 16, "女"))  # name: 古明地恋, age: 16, gender: 女

我们看到一切都符合我们的预期,而且 PyArg_ParseTuple,和 PyArg_ParseTupleAndKeywords 可以自动帮我们检测参数是否合法,不合法抛出合理的异常。当然你也可以检测参数的个数,或者将参数一个一个获取、用 PyXxx_Check 系列检测函数进行判断,看看是否符合预期,当然这么做就比较麻烦了。

PyArg_ParseTuple 和 PyArg_ParseTupleAndKeywords 里面的占位符还可以接收一些特殊的符号,我们举个栗子。为了更好的说明,我们统一以 PyArg_ParseTupleAndKeywords 为例。

占位符 :

下面的是之前写的 C 代码,我们不做任何改动,来测试一下当参数传递错误时的报错信息。

#include "Python.h"

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    wchar_t *name;
    int age = 17;
    wchar_t *gender = L"FEMALE";
    char *keys[] = {"name", "age", "gender", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "u|iu", keys, &name, &age, &gender)){
        return NULL;
    }
    wchar_t res[100];
    swprintf(res, 100, L"name: %s, age: %d, gender: %s", name, age, gender);
    return PyUnicode_FromWideChar(res,wcslen(res));
}

static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_VARARGS | METH_KEYWORDS,
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}

我们用 Python 来测试一下,注意观察报错信息。

import kagura_nana

try:
    print(kagura_nana.f1())
except Exception as e:
    print(e)  # function missing required argument 'name' (pos 1)

try:
    print(kagura_nana.f1("古明地觉", xxx=123))
except Exception as e:
    print(e)  # 'xxx' is an invalid keyword argument for this function

try:
    print(kagura_nana.f1("古明地觉", name=123))
except Exception as e:
    print(e)  # argument for function given by name ('name') and position (1)

报错信息似乎没有什么特别的,但是注意了,我们来做一下改动。

    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "u|iu:abcdefg", keys, &name, &age, &gender)){
            return NULL;
        }

其它地方都不变,我们只在 format 字符串的结尾加上了一个 :abcdefg,然后编译再来测试一下。

import kagura_nana

try:
    print(kagura_nana.f1())
except Exception as e:
    print(e)  # abcdefg() missing required argument 'name' (pos 1)

try:
    print(kagura_nana.f1("古明地觉", xxx=123))
except Exception as e:
    print(e)  # 'xxx' is an invalid keyword argument for abcdefg()

try:
    print(kagura_nana.f1("古明地觉", name=123))
except Exception as e:
    print(e)  # argument for abcdefg() given by name ('name') and position (1)

你看到了什么?没错,默认的报错信息使用的是 function,但我们通过在占位符中指定 :xxx ,可以将 function 变成我们指定的内容 xxx,一般和函数名保持一致。另外需要注意的是,:xxx 要出现在占位符的结尾,并且只能出现一次。如果这样的话会变成什么样子呢?

PyArg_ParseTupleAndKeywords(args, kwargs, "u:aaa|iu:abcdefg", keys, &name, &age, &gender)

显然这变成了只接受一个参数,然后我们将参数不对时、返回报错信息中的 function 换成了 aaa|iu:abcdefg。并且你在传递参数的时候还会报出如下错误:

SystemError: More keyword list entries (3) than format specifiers (1)

因为占位符中相当于只有一个 u,也就是接收一个参数,但是我们后面跟了 &name、&age、&gender。关键字 entry 是 3,占位符是 1,两者不匹配。因此 :xxx 一定要出现在最后面,并且只能出现一次。

另外,即使函数不接收参数我们也是可以这么做的,比如:

#include "Python.h"

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    char *keys[] = {NULL};
    // 不接收参数
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "", keys)){
        return NULL;
    }
    Py_INCREF(Py_None);
    return Py_None;
}

static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_VARARGS | METH_KEYWORDS,
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}
import kagura_nana

try:
    print(kagura_nana.f1("xxx"))
except Exception as e:
    print(e)  # function takes at most 0 arguments (1 given)

然后我们加上 :xxx

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    char *keys[] = {NULL};
    // 这里还可以使用数字
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, ":123", keys)){
        return NULL;
    }
    Py_INCREF(Py_None);
    return Py_None;
}
import kagura_nana

try:
    print(kagura_nana.f1("xxx"))
except Exception as e:
    print(e)  # 123() takes at most 0 arguments (1 given)

我们看到返回信息也被我们修改了,以上就是 :xxx 的作用。所以目前我们看到了两个特殊符号,一个是 | 用来实现默认参数,一个是这里的 : 用来自定义报错信息中的函数名。

占位符 !

我们说占位符 O 表示接收一个 Python 中的对象,但这个对象显然是没有限制的,可以是列表、可以是字典等等。我们之前是通过 Check 的方式进行检测,但是 Python 底层为我们提供更简便的做法,先来看一个常规的例子:

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    char *keys[] = {"val1", "val2", "val3", NULL};
    PyObject *val1;
    PyObject *val2;    
    PyObject *val3;        
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OOO", keys, &val1, &val2, &val3)){
        return NULL;
    }
    Py_INCREF(Py_None);
    return Py_None;
}

这个例子很简单,就是接收三个 PyObject *,但如果我希望第一个参数的类型是浮点型,第三个参数的类型是字典,这个时候该怎么做呢?此时 ! 就派上用场了。

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    char *keys[] = {"val1", "val2", "val3", NULL};
    PyObject *val1;
    PyObject *val2;
    PyObject *val3;
    // 我们希望限制第一个参数和第三个参数的类型, 那么在它们的后面加上 ! 即可
    // 但是注意: 一旦加上了 !, 那么 O! 就要对应两个位置(分别是类型和变量, 当然都是指针)
    // 我们说, 第一个参数是浮点型, 那么第一个 O! 对应 &PyFloat_Type, &val1
    // 第二个参数没有限制, 那么就是 &val2
    // 第三个参数是字典, 那么最后一个 O! 对应 &PyDict_Type, &val3
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!OO!:my_func", keys, 
                                     &PyFloat_Type, &val1, &val2, &PyDict_Type, &val3)){
        return NULL;
    }
    Py_INCREF(Py_None);
    return Py_None;
}

然后其它地方不变,我们来编译测试一下。

import kagura_nana

try:
    print(kagura_nana.f1(123, 123, "xx"))
except Exception as e:
    print(e)  # my_func() argument 1 must be float, not int

try:
    print(kagura_nana.f1(123.0, 11, "xx"))
except Exception as e:
    print(e)  # my_func() argument 3 must be dict, not str

这个功能就很方便了,可以让我们更加轻松地限制参数类型。但如果你用过 Cython 的话,你会发现我这里所说的方便实在是不敢恭维。如果你要写扩展,那么我强烈推荐 Cython,而且用 Cython 可以轻松的连接 C / C++。

注意:! 只能跟在 O 的后面。

占位符 &

& 的话,对于我们编写扩展而言用的不是很多,首先 & 和 上面说的 ! 用法类似,并且都只能跟在 O 的后面。O! 的话,我们说会对应一个类型指针和一个 PyObject *(参数就会传递给它),会判断传递的参数的类型是否和指定的类型一致。但 O& 的话,则是对应一个函数(convert)和一个任意类型的指针(address),会执行 convert(object, address),这个 object 就是我们传递过来的参数。我们举个栗子:

void convert(PyObject *object, long *any){
    // 将 object 转成 long, 赋值给 *any
    *any = PyLong_AsLong(object);
}

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    char *keys[] = {"val1", NULL};
    long any = 0;
    // 我们传递一个 Python 中的整数(假设为 PyObject *val1), 那么这里就会执行 convert(val1, &any) 
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&", keys,
                                     convert, &any)){
        return NULL;
    }
    // 执行完毕之后, any 就会被改变, 为了方便我们就直接打印一下吧, 顺便加一个 1
    printf("any = %ld\n", any + 1);
    Py_INCREF(Py_None);
    return Py_None;
}

我们来测试一下:

print(kagura_nana.f1(123))
"""
any = 124
None
"""

效果大概就是这样,个人觉得对于我们编写扩展而言用处不是很大,了解一下即可。

占位符 ;

占位符 ;: 比较类似,但 ; 更加粗暴。至于怎么个粗暴法,看个栗子就一目了然了。

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    char *keys[] = {"val1", NULL};
    PyObject *val1;
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!;my name is van, i am a artist, a performance artist", keys,
                                    &PyFloat_Type, &val1)){
        return NULL;
    }
    Py_INCREF(Py_None);
    return Py_None;
}

然后我们来调用试试,看看会有什么结果:

import kagura_nana

try:
    print(kagura_nana.f1())
except Exception as e:
    print(e)  # function missing required argument 'val1' (pos 1)

try:
    print(kagura_nana.f1(123, 123))
except Exception as e:
    print(e)  # function takes at most 1 argument (2 given)

目前来看的话,似乎一切正常,但是往下看:

此时把整个报错信息都给修改了,因此这个符号也不是很常用。

注意:; 同样需要放到结尾,并且和 : 相互排斥,两者不可同时出现。

占位符 $

老规矩,还是先来看一个常规的例子。

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    char *keys[] = {"val1", "val2", "val3", NULL};
    PyObject *val1;
    PyObject *val2;
    PyObject *val3;
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OOO", keys,
                                    &val1, &val2, &val3)){
        return NULL;
    }
    Py_INCREF(Py_None);
    return Py_None;
}
import kagura_nana

print(kagura_nana.f1(123, 123, 123))
print(kagura_nana.f1(123, val2=123, val3=123))
print(kagura_nana.f1(123, 123, val3=123))
print(kagura_nana.f1(val1=123, val2=123, val3=123))

以上都是没有问题的,可以通过位置参数传递、也可以通过关键字参数传递,只要位置参数在关键字参数之前即可。但如果我们希望某个参数只能通过关键字的方式传递呢?

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    char *keys[] = {"val1", "val2", "val3", NULL};
    PyObject *val1;
    PyObject *val2;
    PyObject *val3;
    // 指定一个 $, 那么 $ 后面只能通过关键字参数的方式传递
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO$O", keys,
                                    &val1, &val2, &val3)){
        return NULL;
    }
    Py_INCREF(Py_None);
    return Py_None;
}

重新编译然后测试:

import kagura_nana

print(kagura_nana.f1(123, val2=123, val3=123))
print(kagura_nana.f1(123, 123, val3=123))
print(kagura_nana.f1(val1=123, val2=123, val3=123))
# 以上仍然是正常的, 都会打印 None
# 但是下面不行了, 因为 val3 必须通过关键字参数的方式传递
try:
    kagura_nana.f1(123, 123, 123)
except Exception as e:
    print(e)  # function takes exactly 2 positional arguments (3 given)

# 其实这就等价于如下:
def f1(val1, val2, *, val3):
    return None

不过有一点需要注意,目前来说,如果 |$ 同时出现的话,那么 | 必须要在 $ 的前面。所以如果既有仅限关键字参数、又有可选参数,那么仅限关键字参数必须同时也是可选参数,所以 | 要在 $ 的前面。如果我们把 | 写在了 $ 的后面,那么执行会抛异常。

并且,即便仅限关键字参数和默认参数相同,那也应该这么写 OO|$O,而不能这么写 OO$|O

占位符 #

这个 # 不可以跟在 O 后面,它是跟在 s 或者 u 后面,用来限制长度,有兴趣自己去了解一下。

Py_BuildValue

下面介绍一个非常方便的函数 Py_BuildValue,专门用来对数据进行打包的,返回一个 PyObject *,同样是通过占位符的方式。

Py_BuildValue 的占位符和 PyArg_ParseTuple 里面的占位符是一致的,只不过功能相反。比如:i,PyArg_ParseTuple 是将 Python 中的 int 转成 C 中的 int,而 Py_BuildValue 是将 C 中的 int 打包成 Python 中的 int。所以它们的占位符一致,功能正好相反,并且我们在介绍 PyArg_ParseTuple 的时候只介绍一部分占位符,其实支持的占位符不止我们上面说的那些,下面就来罗列一下。

再重复一次,PyArg_ParseTuple 和 Py_BuildValue 的占位符是一致的,但是功能相反。

我们只接用官方的栗子,因为官方给的栗子非常直观。

Py_BuildValue("")                       		            None
Py_BuildValue("i", 123)                                     123
Py_BuildValue("iii", 123, 456, 789)                         (123, 456, 789)
Py_BuildValue("s", "hello")                                 'hello'
Py_BuildValue("y", "hello")                                 b'hello'
Py_BuildValue("ss", "hello", "world")                       ('hello', 'world')
Py_BuildValue("s#", "hello", 4)                             'hell'
Py_BuildValue("y#", "hello", 4)                             b'hell'
Py_BuildValue("()")                                         ()
Py_BuildValue("(i)", 123)                                   (123,)
Py_BuildValue("(ii)", 123, 456)                             (123, 456)
Py_BuildValue("(i,i)", 123, 456)                            (123, 456)
Py_BuildValue("[i,i]", 123, 456)                            [123, 456]
Py_BuildValue("{s:i,s:i}", "abc", 123, "def", 456)          {'abc': 123, 'def': 456}
Py_BuildValue("((ii)(ii)) (ii)", 1, 2, 3, 4, 5, 6)          (((1, 2), (3, 4)), (5, 6))

如果是多个符号,自动会变成一个元组。我们来测试一下:

#include "Python.h"

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    PyObject *lst = PyList_New(5);
    PyList_SetItem(lst, 0,
                   Py_BuildValue("i", 123));
    PyList_SetItem(lst, 1,
                   Py_BuildValue("is", 123, "hello matsuri"));

    PyList_SetItem(lst, 2,
                   Py_BuildValue("[i, i]", 123, 321));

    PyList_SetItem(lst, 3,
                   Py_BuildValue("(s)s", "hello", "matsuri"));

    PyList_SetItem(lst, 4,
                   Py_BuildValue("{s: s}", "hello", "matsuri"));
    return lst;
}

static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_VARARGS | METH_KEYWORDS,  
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}
from pprint import pprint
import kagura_nana
pprint(kagura_nana.f1())
"""
[123,
 (123, 'hello matsuri'),
 [123, 321],
 (('hello',), 'matsuri'),
 {'hello': 'matsuri'}]
"""

我们看到结果是符合我们的预期的,另外除了 Py_BuildValue 之外,还有一个 PyTuple_Pack,这两者是类似的,只不过后者只接收 PyObject *,举个栗子就很清晰了:

Py_BuildValue("OO", a, b) 等价于 PyTuple_Pack(2, a, b)

这个是固定打包成元组,而且第一个参数是个数,不是 format,因此它不支持通过占位符来指定元素类型,而是只接收 PyObject *。

操作 PyDictObject

Python 中的字典在底层要如何读取、如何设置,这个我们必须要好好地说一说。像整型、浮点型、字符串、元组、列表、集合,它们都比较简单,我们就不详细说了。比如列表:Python 中插入元素是调用 insert,那么底层则是 PyList_Insert;追加元素是 append,那么底层则是 PyList_Append;设置元素是 __setitem__,那么底层则是 PyList_SetItem;同理获取元素是 PyList_GetItem,写法非常具有规范性。所以如果不知道某个 API 的话,可以去查看解释的源码,比如你想查看元组,那么就去 Include/tupleobject.h 中查看:

像这些凡是以 PyAPI 开头的都是可以直接用的,PyAPI_DATA 表示数据,PyAPI_FUNC 表示函数,至于它们的含义是什么,我们可以通过文档查看。在 Python 的安装目录的 Doc 目录下就有,点击通过关键字进行检索即可。当然基本数据类型的一些方法,相信通过函数名即可判断,比如:PyTuple_GetItem,很明显就是通过索引获取元素的。还是那句话,Python 解释器的整个工程,在命名方面都非常有规律。

所以我们的重点是字典的使用,因为字典比较特殊,它里面的键值对的形式,而列表、元组等容器里面的元素是单一独立的。

PyDictObject 的读取

先来介绍内部关于读取的一些 API:

  • PyDict_Contains(dic, key):判断字典中是否具有某个 key
  • PyDict_GetItem(dic, key):获取字典中某个 key 对应的 value
  • PyDict_GetItemString(dic, key):和 PyDict_GetItem 作用相同,但这里的 key 是一个 char *
  • PyDict_Keys(dic):获取所有的 key
  • PyDict_Values(dic):获取所有的 value
  • PyDict_Items(dic):获取所有的 key-value

下面我们来操作一波:

#include "Python.h"

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    PyObject *dic;
    char *keys[] = {"dic", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!", keys, &PyDict_Type, &dic)){
        return NULL;
    }
    PyObject *res;  // 返回值

    // 1. 检查是否包含 "name" 这个 key
    PyObject *name = PyUnicode_FromString("name");
    if (!PyDict_Contains(dic, name)){
        res = PyUnicode_FromString("key `name` does not exists");
    } else {
        res = PyDict_GetItem(dic, name);
        // 注意:这一步很关键,因为我们下面返回了 res,而这个 res 是从 Python 传递过来的字典中获取的
        // 因此它的引用计数不会加 1,只是指向了某个已存在的空间,因此返回之前我们需要将引用计数加 1
        // 至于 if 里面的 res,因为它是在 C 中创建了新的空间,所以不需要关心
        Py_INCREF(res);
    }
    // 此时我们能直接返回 res 吗? 很明显是不能的,因为我们上面还创建了一个 Python 的字符串 name
    // 这是在 C 中创建的,并且也没作为返回值,那么我们就必须要手动将其引用计数减 1
    // 因此这种时候更推荐使用 PyDict_GetItemString,它接收一个 C 字符串,函数结束时自动释放
    // 但是很明显这个函数局限性比较大
    Py_DECREF(name);
    return res;
}

static PyMethodDef methods[] = {
    {
        "f1",
        (PyCFunction) f1,
        METH_VARARGS | METH_KEYWORDS,  
        NULL
    },
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named kagura_nana",
    -1,
    methods,
    NULL, NULL, NULL, NULL
};

PyMODINIT_FUNC

PyInit_kagura_nana(void)
{
    return PyModule_Create(&module);
}

import kagura_nana

try:
    print(kagura_nana.f1(""))
except Exception as e:
    print(e)  # argument 1 must be dict, not str


print(kagura_nana.f1({}))  # key `name` does not exists
print(kagura_nana.f1({"name": "古明地觉"}))  # 古明地觉

PyDictObject 的遍历

首先我们说可以通过 PyDict_Keys、PyDict_Values、PyDict_Items 来进行遍历,下面演示一下。

static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    PyObject *dic;
    char *keys[] = {"dic", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!", keys, &PyDict_Type, &dic)){
        return NULL;
    }
    PyObject *res = PyList_New(3);  // 返回值
    PyList_SetItem(res, 0, PyDict_Keys(dic));
    PyList_SetItem(res, 1, PyDict_Values(dic));
    PyList_SetItem(res, 2, PyDict_Items(dic));
    return res;
}
import kagura_nana

print(kagura_nana.f1({"name": "satori", "age": 17}))
"""
[['name', 'age'], 
 ['satori', 17], 
 [('name', 'satori'), ('age', 17)]]
"""

而且我们看到 PyDict_Keys 等函数返回的是列表,这说明创建了一个新的空间,引用计数为 1。但我们没有调用 Py_DECREF,这是因为我们将其放在了一个新的列表中,如果作为某个容器的元素,那么引用计数也应该要增加。但对于 PyListObject、PyTupleObject 而言,通过 PyList_SetItem、PyTuple_SetItem 是不会增加指向对象的引用计数的,所以结果正好抵消,我们不需要对引用计数做任何处理。

但如果我们是通过 PyList_Append 进行追加、或者 PyList_Insert 进行插入的话,那么是会增加引用计数的,这样引用计数就增加了 2,因此我们还需要减去 1。所以这一点比较烦人,因为你光知道何时增加引用计数、何时减少引用计数还是不够的,你还要看某一个操作到底有没有增加、或者减少。就拿我们这里设置元素为例,本来作为容器内的一个元素,理论上是要增加引用计数的,但是结果却没有增加。而添加和插入元素,也是作为容器的一个元素,但是这两个操作却增加了。所以还是推荐 Cython,再度安利一波,写扩展用 Cython 真的非常香。

这里我们将元素都获取出来了,至于遍历也很简单,这里不测试了。

PyDictObject 的设置和删除

  • PyDict_SetItem(dic, key, value):设置元素
  • PyDict_DelItem(dic, key, value):删除元素
  • PyDict_Clear(dic):清空字典
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
    PyObject *dic;
    char *keys[] = {"dic", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!", keys, &PyDict_Type, &dic)){
        return NULL;
    }
    // 设置一个 "name": "satori"
    PyObject *key = PyUnicode_FromString("name");
    PyObject *value = PyUnicode_FromString("satori");
    PyDict_SetItem(dic, key, value);
    // 因为 key 和 value 是 C 中创建的,首先引用计数为 1
    // 然后它们又放到了字典里,对于字典而言,设置元素是会增加引用计数的,所以这里引用计数变成了 2
    // 因此我们需要手动将它们的引用计数减去 1,否则这个键值对永远不会被回收。
    // 所以最让人烦的就是这个引用计数,非常的讨厌,因为你不知道它到底有没有增加
    Py_XDECREF(key);
    Py_XDECREF(value);

    // 如果有 "age" 这个 key 就将其删掉
    key = PyUnicode_FromString("age");
    if (PyDict_Contains(dic, key)) {
        PyDict_DelItem(dic, key);
    }
    Py_XDECREF(key); // 同样减少引用计数
    Py_INCREF(Py_None);
    return Py_None;
}

测试一下:

import kagura_nana

dic = {"name": "mashiro", "age": 17}
kagura_nana.f1(dic)
print(dic)  # {'name': 'satori'}

当然还有很多其它 API,可以查看源代码(Include/dictobject.h)自己测试一下。

编写扩展类

我们之前在 C 中编写的都是函数,但光有函数显然是不够的,我们需要实现类。而在 C 中实现的类被称为扩展类,它和 Python 内置的类(int、dict、str等等)是等价的,都属于静态类,直接指向了 C 一级的数据结构。

下面来看看在 C 中如何实现扩展类,首先我们来实现一个最基本的扩展类,也就是只包含一些最关键的部分。然后再添加类参数、方法,以及继承等等。

当然最重要的一点,我们还要解决类的循环引用、以及自定义垃圾回收。像列表、元组、字典等容器,它们也都会发生循环引用。

前面有一点我们没有提,当一个容器(比如列表)引用计数减一的时候,里面的元素(指向的对象)的引用计数是不会发生改变的。只有当一个容器的引用计数为 0 被销毁的时候,在销毁之前会先将内部元素的引用计数都减 1,然后再销毁这个容器。

而循环引用是引用计数机制所面临的最大的痛点,所以 Python 中的 gc 就是来干这个事情的,通过分代技术根据对象的生命周期划分为三个链表,然后通过三色标记模型来找出那些具有循环引用的对象,改变它们的引用计数。所以在 Python 中一个对象是否要被回收,最终还是取决于它的引用计数是否为 0。如果是 Python 代码的话,我们在实现类的时候,解释器会自动帮我们处理这一点,但我们是做类扩展,因此这些东西就必须由我们来考虑了。

编写扩展类前奏曲

我们之前编写了扩展函数,我们说首先要创建一个模块,这里也是一样的,因为类也要在模块里面。编写函数是有套路的,编写类也是一样,我们还是先看看大致的流程,具体细节会在慢慢补充。

首先我们需要了解以下内容:

  • 1. 一个类要有类名、构造函数、析构函数
  • 2. 所有的类在底层都是一个 PyTypeObject 实例,而且类也是一个对象
  • 3. PyType_Ready 对类进行初始化,主要是进行属性字典的设置
  • 4. PyModule_AddObject,将扩展类添加到模块中

那么一个类在底层都有哪些属性呢?很明显,我们说所有的类都是一个 PyTypeObject 实例,那么我们就把这个结构体拷贝出来看一下就知道了。

// 下面我们来介绍一下内部成员都代表什么含义
typedef struct _typeobject {
    // 头部信息,PyVarObject ob_base; 里面包含了引用计数、类型、ob_size
    // 而创建这个结构体实例的话,Python 提供了一个宏,PyVarObject_HEAD_INIT(type, size)
    // 传入类型和大小可以直接创建,至于引用计数则默认为 1
    PyObject_VAR_HEAD
    // 创建之后的类名
    const char *tp_name; /* For printing, in format "<module>.<name>" */
    // 大小,用于申请空间的,注意了,这里是两个成员
    Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */

    /* Methods to implement standard operations */
	
    // 析构方法__del__,当删除实例对象时会调用这个操作
    // typedef void (*destructor)(PyObject *); 函数接收一个PyObject *,没有返回值
    destructor tp_dealloc;
    
    // 打印其实例对象是调用的函数
    // typedef int (*printfunc)(PyObject *, FILE *, int); 函数接收一个PyObject *、FILE * 和 int
    printfunc tp_print;
    
    // 获取属性,内部的 __getattr__ 方法
    // typedef PyObject *(*getattrfunc)(PyObject *, char *);
    getattrfunc tp_getattr;
    
    // 设置属性,内部的 __setattr__ 方法
    // typedef int (*setattrfunc)(PyObject *, char *, PyObject *);
    setattrfunc tp_setattr;
    
    // 在 Python3.5之后才产生的,这个不需要关注。
    // 并且在其它类的注释中,这个写的都是tp_reserved
    PyAsyncMethods *tp_as_async; /* formerly known as tp_compare (Python 2)
                                    or tp_reserved (Python 3) */
    // 内部的 __repr__方法
    // typedef PyObject *(*reprfunc)(PyObject *);
    reprfunc tp_repr;
	
    // 一个对象作为数值所有拥有的方法
    PyNumberMethods *tp_as_number;
    // 一个对象作为序列所有拥有的方法
    PySequenceMethods *tp_as_sequence;
    // 一个对象作为映射所有拥有的方法
    PyMappingMethods *tp_as_mapping;

    /* More standard operations (here for binary compatibility) */
	
    //内部的 __hash__ 方法
    // typedef Py_hash_t (*hashfunc)(PyObject *);
    hashfunc tp_hash;
    
    // 内部的 __call__ 方法
    // typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
    ternaryfunc tp_call;
    
    // 内部的 __repr__ 方法
    // typedef PyObject *(*reprfunc)(PyObject *);
    reprfunc tp_str;
    
    // 获取属性
    // typedef PyObject *(*getattrofunc)(PyObject *, PyObject *);
    getattrofunc tp_getattro;
    // 设置属性
    // typedef int (*setattrofunc)(PyObject *, PyObject *, PyObject *);
    setattrofunc tp_setattro;
	
    //作为缓存,不需要关心
	/*
    typedef struct {
    	 getbufferproc bf_getbuffer;
     	releasebufferproc bf_releasebuffer;
	} PyBufferProcs;
    */
    PyBufferProcs *tp_as_buffer;

    // 这个类的特点,比如:
    // Py_TPFLAGS_HEAPTYPE: 是否在堆区申请空间
    // Py_TPFLAGS_BASETYPE: 是否允许这个类被其它类继承
    // Py_TPFLAGS_IS_ABSTRACT: 是否为抽象类
    // Py_TPFLAGS_HAVE_GC: 是否被垃圾回收跟踪
    // 这里面有很多,具体可以去 object.h 中查看
    // 一般我们设置成 Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC 即可
    unsigned long tp_flags;
	
    // 这个类的注释
    const char *tp_doc; /* Documentation string */
	
    //用于检测是否出现循环引用,和下面的tp_clear是一组
    /*
    class A:
    	pass
    a = A()
    a.attr = a
    此时就会出现循环引用
    */
    // typedef int (*traverseproc)(PyObject *, visitproc, void *);
    traverseproc tp_traverse;

    // 删除对包含对象的引用
    inquiry tp_clear;

    // 富比较
    // typedef PyObject *(*richcmpfunc) (PyObject *, PyObject *, int);
    richcmpfunc tp_richcompare;

    // 弱引用,不需要关心
    Py_ssize_t tp_weaklistoffset;

    // __iter__方法
    // typedef PyObject *(*getiterfunc) (PyObject *);
    getiterfunc tp_iter;
    // __next__方法
    // typedef PyObject *(*iternextfunc) (PyObject *);
    iternextfunc tp_iternext;

    /* Attribute descriptor and subclassing stuff */
    // 内部的方法,这个 PyMethodDef 不陌生了吧
    struct PyMethodDef *tp_methods;
    // 内部的成员
    struct PyMemberDef *tp_members;
    // 一个结构体,包含了 name、get、set、doc、closure
    struct PyGetSetDef *tp_getset;
    
    // 继承的基类
    struct _typeobject *tp_base;
    
    // 内部的属性字典
    PyObject *tp_dict;
    
    // 描述符,__get__ 方法
    // typedef PyObject *(*descrgetfunc) (PyObject *, PyObject *, PyObject *);
    descrgetfunc tp_descr_get;
    
    // 描述符,__set__ 方法
    // typedef int (*descrsetfunc) (PyObject *, PyObject *, PyObject *);
    descrsetfunc tp_descr_set;
    
    // 生成的实例对象是否有属性字典
    // 我们上一个例子中的实例对象显然是没有属性字典的,因为我们当时没有设置这个成员
    Py_ssize_t tp_dictoffset;
    
    // 初始化函数
    // typedef int (*initproc)(PyObject *, PyObject *, PyObject *);
    initproc tp_init;
    
    // 为实例对象分配空间的函数
    // typedef PyObject *(*allocfunc)(struct _typeobject *, Py_ssize_t);
    allocfunc tp_alloc;
    
    // __new__ 方法
    // typedef PyObject *(*newfunc)(struct _typeobject *, PyObject *, PyObject *);
    newfunc tp_new;
    // 我们一般设置到 tp_new 即可,剩下的就不需要管了
    
    
    
    // 释放一个实例对象
    // typedef void (*freefunc)(void *); 一般会在析构函数中调用
    freefunc tp_free; /* Low-level free-memory routine */
    
    // typedef int (*inquiry)(PyObject *); 是否被 gc 跟踪
    inquiry tp_is_gc; /* For PyObject_IS_GC */
    
    // 继承哪些类,这里可以指定继承多个类
    // 这个还是有必要的,因此这个可以单独设置
    PyObject *tp_bases;
    
    //下面的就不需要关心了
    PyObject *tp_mro; /* method resolution order */
    PyObject *tp_cache;
    PyObject *tp_subclasses;
    PyObject *tp_weaklist;
    destructor tp_del;
    unsigned int tp_version_tag;
    destructor tp_finalize;
} PyTypeObject;

这里面我们看到有很多成员,如果有些成员我们不需要的话,那么就设置为 0 即可。不过即便设置为 0,但是有些成员我们在调用 PyType_Ready 初始化的时候,也会设置进去。比如 tp_dict,这个我们创建类的时候没有设置,但是这个类是有属性字典的,因为在 PyType_Ready 中设置了;但有的不会,比如 tp_dictoffset,这个我们没有设置,那么类在 PyType_Ready 中也不会设置,因此这个类的实例对象,就真的没有属性字典了。再比如 tp_free,我们也没有设置,但是是可以调用的,原因你懂的。

虽然里面的成员非常多,但是我们在实现的时候不一定每一个成员都要设置。如果只需要指定某几个成员的话,那么我们可以先创建一个 PyTypeObject 实例,然后针对指定的属性进行设置即可。

下面我们来编写一个简单的扩展类,具体细节在代码中体现。

#include "Python.h"

// 这一步是直接定义一个类,它就是我们在 Python 中使用的类,这里采用 C++,因此我们编译时的文件要从 main.c 改成 main.cpp
class MyClass {
public:
    PyObject_HEAD  // 公共的头部信息
};
/*
或者你直接使用结构体的方式也是可以的,这样源文件还叫 main.c 不需要修改
typedef struct {
    PyObject_HEAD  // 头部信息
} MyClass;
*/

// 这里我们实现 Python 中的 __new__ 方法,这个 __new__ 方法接收哪些参数来着
// 一个类本身,以及 __init__ 中的参数,我们一般会这样写 def __new__(cls, *args, **kwargs):
// 所以这里的第一个参数就不再是 PyObject *了,而是 PyTypeObject *
static PyObject *
MyClass_new(PyTypeObject *cls, PyObject *args, PyObject *kw)
{
    // 我们说 Python 中的 __new__ 方法默认都干了哪些事来着
    // 为创建的实例对象开辟一份空间,然后会将这份空间的指针返回回去交给 self
    // 当然交给 __init__ 的还有其它参数,这些参数是 __init__ 需要使用的,__new__ 方法不需要关心
    // 但是毕竟要先经过 __new__ 方法,所以 __new__ 方法中要有参数位能够接收
    // 最终 __new__ 会将自身返回的 self 连同其它参数组合起来一块交给 __init__
    // 所以 __init__ 中 self 我们不需要关心,我们只需要传递 self 后面的参数即可,因为在 __new__ 会自动传递self
    // 另外多提一嘴:我们使用实例对象调用方法的时候,会自动传递 self,你有没有想过它为什么会自动传递呢?
    // 其实这个在底层是使用了描述符,至于底层是怎么实现的,我们在之前已经说过了

    // 所以我们这里要为 self 分配一个空间,self 也是一个指针,但是它已经有了明确的类型,所以我们需要转化一下
    // 当然这里不叫 self 也是可以的,只是我们按照官方的约定,不会引起歧义
    // 分配空间是通过调用 PyTypeObject 的 tp_alloc 方法,传入一个 PyTypeObject *,以及大小,这里是固定的所以是 0
    MyClass *self = (MyClass *)cls -> tp_alloc(cls, 0);  // 此时就由 Python 管理了
    // 记得返回 self,转成 PyObject *,当然我们这里是 __new__ 方法的默认实现,你也可以做一些其它的事情来控制一下类的实例化行为
    return (PyObject *)self;
}

// 构造函数接收三个 PyObject *, 但它返回的是一个 int, 0 表示成功、-1 表示失败
static int
MyClass_init(PyObject *self, PyObject *args, PyObject *kw)
{
    // 假设这个构造函数接收三个参数:name,age,gender
    char *name;
    int age;
    char *gender;
    char *keys[] = {"name", "age", "gender", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kw, "sis", keys, &name, &age, &gender)){
        // 这里失败了不能返回 NULL,而是返回 -1,__init__ 比较特殊
        return -1;
    }
    //至于如何设置到 self 当中,我们后面演示,这里先打印一下
    printf("name = %s, age = %d, gender = %s\n", name, age, gender);

    // 我们说结果为 0 返回成功,结果为 -1 返回失败,所以走到这里的话应该返回 0
    return 0;
}

// 析构函数, 返回值是 void,关于这些函数的参数和返回值的定义可以查看上面介绍的 PyTypeObject 结构体
void
MyClass_del(PyObject *self)
{
    // 打印一句话吧
    printf("call __del__\n");
    // 拿到类型,调用 tp_free 释放,这个是释放实例对象所占空间的。所以 tp_alloc 是申请、tp_dealloc 是释放
    Py_TYPE(self) -> tp_free(self);
}


static PyModuleDef module = {
    PyModuleDef_HEAD_INIT, // 头部信息
    "kagura_nana",  // 模块名
    "this is a module named hanser", // 模块注释
    -1,  // 模块空间
    0,  // 这里是 PyMethodDef 数组,但是我们这里没有 PyMethodDef,所以就是 0,也就是我们这里面没有定义函数
    NULL,
    NULL,
    NULL,
    NULL
};

PyMODINIT_FUNC
PyInit_kagura_nana(void) {
    // 创建类的这些过程,我们也可以单独写,我们这里第一次演示就直接写在模块初始化函数里面了
    // 实例化一个 PyTypeObject,但是这里面的属性非常多,我们通过直接赋值的方式需要写一大堆,所以先定义,然后设置指定的属性
    static PyTypeObject cls;

    // 我们知道 PyTypeObject 结构体的第一个参数就是 PyVarObject ob_base;
    // 需要引用计数(初始为1)、类型 &PyType_Type、ob_size(不可变,写上0即可)
    PyVarObject ob_base = {1, &PyType_Type, 0};
    cls.ob_base = ob_base;  // 类的公共头部
    // 这里是类名,但是这个 MyClass 是 Python 中打印的时候显示的名字,或者说调用 __name__ 显示的名字
    // 假设我们上面的是 MyClass1,那么在 Python 中你就需要使用 MyClass1 来实例化
    // 但是使用 type 查看的时候显示的 MyClass,因为类名叫 MyClass,但是很明显这两者应该是一致的
    cls.tp_name = "MyClass";
    cls.tp_basicsize = sizeof(MyClass);  // 类的空间大小
    cls.tp_itemsize = 0; // 设置为 0
    // 设置类的 __new__ 方法、__init__ 方法、__del__ 方法
    cls.tp_new = MyClass_new;
    cls.tp_init = MyClass_init;
    cls.tp_dealloc = MyClass_del;

    // 初始化类,调用 PyType_Ready,而且 Python 内部的类在创建完成之后也会调用这个方法进行初始化,它会对创建类进行一些属性的设置
    // 记得传入指针进去
    if (PyType_Ready(&cls) < 0){
        // 如果结果小于0,说明设置失败
        return NULL;
    }

    // 这个是我们自己创建的类,所以需要手动增加引用计数
    Py_XINCREF(&cls);
    // 加入到模块中,这个不需要在创建 PyModuleDef 的时候指定,而是可以单独添加
    // 我们需要先把模块创建出来,然后通过 PyModule_AddObject 将类添加进去
    PyObject *m = PyModule_Create(&module);
    // 传入 创建的模块的指针 m、类名(这个类名要和我们上面设置的 tp_name 保持一致)、以及由 PyTypeObject * 转化得到的 PyObject *
    // 另外多提一嘴,这里的 m、和 cls 以及上面 module 都只是 C 中的变量,具体的模块名和类名是 kagura_nana 和 MyClass
    PyModule_AddObject(m, "MyClass", (PyObject *)&cls);
    return m; // 将模块对象返回
}

然后是用于编译的 py 文件:

from distutils.core import *

setup(
    name="kagura_nana",
    version="1.11",
    author="古明地盆",
    author_email="66666@东方地灵殿.com",
    # 这里改成 main.cpp
    ext_modules=[Extension("kagura_nana", ["main.cpp"])],
)

注意:之前使用的都是自己住的地方的台式机,里面装了相应的环境,因为机器性能比较好。但是春节本人回家了,现在使用的是自己的笔记本,而笔记本里面没有装 Visual Studio 等环境,因此接下来环境会选择我阿里云上的 CentOS。

编译的方式跟之前一样,只不过需要先执行一下 yum install gcc-c++,否则编译时会抛出:

gcc: error trying to exec 'cc1plus': execvp: No such file or directory

如果你已经装了,那么是没有问题的,但也建议执行确认一下。下面操作一波:

>>> import kagura_nana
>>> kagura_nana
<module 'kagura_nana' from '/usr/local/lib64/python3.6/site-packages/kagura_nana.cpython-36m-x86_64-linux-gnu.so'>
>>> try:
...     # 然后实例化一个类
...     # 我们说这个类的构造函数中接收三个参数,我们先不传递,看看会有什么表现
...     self = kagura_nana.MyClass()
... except Exception as e:
...     print(e)
... 
call __del__
Required argument 'name' (pos 1) not found

尽管实例化失败,但是这个对象在 __new__ 方法中被创建了,所以依旧会调用 __del__。然后我们传递参数,但是我们在构造函数中只是打印,并没有设置到 self 中。

>>> self = kagura_nana.MyClass("mashiro", 16, "female")
name = mashiro, age = 16, gender = female
>>> self.name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'MyClass' object has no attribute 'name'

我们看到调用失败了,因为我们没有设置到 self 中,然后再看看析构函数。

>>> del self
call __del__
>>> 

成功调用,然后里面的 printf 也成功执行。

给实例对象添加属性

整体流程我们大致了解了,下面看看如何给实例对象添加属性。我们说 PyTypeObject 里面有一个 tp_members 属性,很明显它就是用来指定实例对象的属性的。

#include "Python.h"
#include "structmember.h"  // 添加成员需要导入这个头文件

class MyClass {
public:
    PyObject_HEAD
    // 添加成员,这里面的参数要和 __init__ 中的参数保持一致,你可以把 name、age、gender 看成是要通过 self. 的方式来设置的属性
    // 假设这里面没有 gender,那么即使 Python 中传了 gender 这个参数、并且解析出来了
    // 但是你仍然没办法设置,所以实例化的对象依旧无法访问
    PyObject *name;
    PyObject *age;
    PyObject *gender;
};
/*
// 你仍然可以使用结构体的方式定义
typedef struct{
	PyObject_HEAD
	PyObject *name;
	PyObject *age;
	PyObject *gender;
}MyClass;
 */

static PyObject *
MyClass_new(PyTypeObject *cls, PyObject *args, PyObject *kw)
{
    MyClass *self = (MyClass *)cls -> tp_alloc(cls, 0);
    return (PyObject *)self;
}

static int
MyClass_init(PyObject *self, PyObject *args, PyObject *kw)
{
    // 这里不使用 C 的类型了,使用 PyObject *,参数和原来一样
    PyObject *name;
    PyObject *age = NULL;
    PyObject *gender = NULL;
    // 注意:上面申明的三个 PyObject * 变量叫什么名字其实是没有所谓的,重点是 MyClass 和 下面 keys
    // keys 里面的字符串就是 __init__ 中的参数名,MyClass 中的变量则是实例对象的属性名
    // 假设把 MyClass 这个类中的 name 改成 NAME,那么最终的形式就等价于 self.NAME = name
    char *keys[] = {"name", "age", "gender", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kw, "O!|O!O!", keys, &PyUnicode_Type, &name,
                                     &PyLong_Type, &age, &PyUnicode_Type, &gender)){
        return -1;
    }
    // 注意: 有一个很关键的点,在 __init__ 函数调用结束之后,name、age、gender 的引用计数会减一
    // 而它们又是从 Python 传递过来的,所以为了保证不出现悬空指针,我们必须要将引用计数手动加 1
    Py_XINCREF(name);
    // 而 age 和 gender 是可以不传的,我们需要给一个默认值。
    // 当传递了 age,那么增加引用计数;没有传递 age,我们自己创建一个,由于是创建,引用计数初始为 1,所以此时就无需增加了。gender 也是同理
    if (age) Py_XINCREF(age); else age = PyLong_FromLong(17);
    if (gender) Py_XINCREF(gender); else gender = PyUnicode_FromWideChar(L"萌妹子", 3);
    // 这里就是设置 __init__ 属性的,将解析出来的参数设置到 __init__ 中
    // 注意 PyObject * 要转成 MyClass *,并且考虑优先级,我们需要使用括号括起来
    ((MyClass *)self) -> name = name;
    ((MyClass *)self) -> age = age;
    ((MyClass *)self) -> gender = gender;
    // 此时我们的构造函数就设置完成了
    return 0;
}

void
MyClass_del(PyObject *self)
{
    // 同样的问题,当对象在销毁的时候,实例对象的成员的引用计数是不是也要减去 1 呢
    Py_XDECREF(((MyClass *)self) -> name);
    Py_XDECREF(((MyClass *)self) -> age);
    Py_XDECREF(((MyClass *)self) -> gender);
    Py_TYPE(self) -> tp_free(self);
}


static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named hanser",
    -1,
    0,
    NULL,
    NULL,
    NULL,
    NULL
};

PyMODINIT_FUNC
PyInit_kagura_nana(void) {
    static PyTypeObject cls;

    PyVarObject ob_base = {1, &PyType_Type, 0};
    cls.ob_base = ob_base;
    cls.tp_name = "MyClass";
    cls.tp_basicsize = sizeof(MyClass);
    cls.tp_itemsize = 0;
    cls.tp_new = MyClass_new;
    cls.tp_init = MyClass_init;
    cls.tp_dealloc = MyClass_del;

    // 添加成员,这是一个 PyMemberDef 类型的数组,然后显然要把数组名放到类的 tp_members 中
    // PyNumberDef 结构体有以下成员:name type offset flags doc
    static PyMemberDef members[] = {
        //这些成员具体值是什么?我们需要在 MyClass_init 中设置
        {
            "name", // 成员名
            T_OBJECT_EX, // 类型,关于类型我们一会儿介绍
            // 接收结构体对象和一个成员
            // 获取对应值的偏移地址,由于 Python 中的类是动态变化的,所以 C 只能通过偏移的地址来找到对应的成员,offsetof 是一个宏
            // 而这里面的 name 就是我们定义的 MyClass 里面的 name,所以如果 MyClass 里面不设置,那么这里会报错
            offsetof(MyClass, name),
            0, // 变量的读取类型,设置为 0 表示可读写,设置为 1 表示只读
            "this is a name" //成员说明
        },
        // 这里将 age 设置为只读
        {"age", T_OBJECT_EX, offsetof(MyClass, age), 1, "this is a age"},
        {"gender", T_OBJECT_EX, offsetof(MyClass, gender), 0, "this is a gender"},
        {NULL}  // 结尾有一个{NULL}
    };
    // 设置成员,这一步很关键,否则之前的相当于白做
    cls.tp_members = members;

    if (PyType_Ready(&cls) < 0){
        return NULL;
    }

    Py_XINCREF(&cls);
    PyObject *m = PyModule_Create(&module);
    PyModule_AddObject(m, "MyClass", (PyObject *)&cls);
    return m;
}

我们来测试一下:

>>> import kagura_nana
>>> self = kagura_nana.MyClass("古明地觉")
>>> self.name, self.age, self.gender
('古明地觉', 17, '萌妹子')
>>> 
>>> self = kagura_nana.MyClass("古明地恋", 16, "美少女")
>>> self.name, self.age, self.gender
('古明地恋', 16, '美少女')
>>> 
>>> self.name, self.gender = "koishi", "びしょうじょ"
>>> self.name, self.age, self.gender
('koishi', 16, 'びしょうじょ')
>>> 
>>> # 我们看到一些都没有问题,但接下来重点来了
... 
>>> self.age = 16
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: readonly attribute
>>> 

一切正常,并且我们看到 age 是只读的,因为我们在 PyMemberDef 中将其设置为只读,我们来看一下这个结构体。该结构体的定义藏身于 Include/structmember.h 中。

typedef struct PyMemberDef {
    const char *name;   // 实例属性的名字, 比如我们上面的 name、age、gender
    int type;		   // 实例属性的类型, 这一点很关键, 支持的类型我们一会说
    Py_ssize_t offset;  // 实例属性的偏移量,通过 offsetof(TYPE, MEMBER) 这个宏来获取
    int flags;		   // 设置为 0 表示可读可写, 设置为 1 表示只读
    const char *doc;    // 属性说明
} PyMemberDef;

然后我们重点看一下里面的 type 成员,它表示属性的类型,支持如下选项:

  • #define T_SHORT 0
  • #define T_INT 1
  • #define T_LONG 2
  • #define T_FLOAT 3
  • #define T_DOUBLE 4
  • #define T_STRING 5
  • #define T_OBJECT 6
  • #define T_CHAR 7
  • #define T_BYTE 8
  • #define T_UBYTE 9
  • #define T_USHORT 10
  • #define T_UINT 11
  • #define T_ULONG 12
  • #define T_STRING_INPLACE 13
  • #define T_BOOL 14
  • #define T_OBJECT_EX 16
  • #define T_LONGLONG 17
  • #define T_ULONGLONG 18
  • #define T_PYSSIZET 19
  • #define T_NONE 20

我们的类(MyClass)中的成员应该是 PyObject *,但是用来接收参数的变量可以不是,只不过在设置实例属性的时候需要再转成 PyObject *,如果接收的就是 PyObject *,那么就不需要再转了。而上面这些描述的就是参数的类型,所以我们一般用 T_OBJECT_EX 即可,但是还有一个 T_OBJECT,这两者的区别是前者如果接收的是 NULL(没有接收到值),那么会引发一个 AttributeError。

到目前为止,我们应该感受到使用 C/C++ 来写扩展是一件多么痛苦的事情,特别是引用计数,一搞不好就出现内存泄漏或者悬空指针。因此,关键来了,再次安利一波 Cython。

除了 __init__、__new__、__del__ 之外,你还可以添加其它的方法,比如 tp_call、tp_getset 等等。

给类添加成员

一个类里面可以定义很多的函数,那么这在 C 中是如何实现的呢?很简单,和模块中定义函数是一致的。

#include "Python.h"
#include "structmember.h"  // 添加成员需要导入这个头文件

class MyClass {
        public:
        PyObject_HEAD
        // 添加成员,这里面的参数要和 __init__ 中的参数保持一致,你可以把 name、age、gender 看成是要通过 self. 的方式来设置的属性
        // 假设这里面没有 gender,那么即使 Python 中传了 gender 这个参数、并且解析出来了
        // 但是你仍然没办法设置,所以实例化的对象依旧无法访问
        PyObject *name;
        PyObject *age;
        PyObject *gender;
};


static PyObject *
MyClass_new(PyTypeObject *cls, PyObject *args, PyObject *kw)
{
    MyClass *self = (MyClass *)cls -> tp_alloc(cls, 0);
    return (PyObject *)self;
}

static int
MyClass_init(PyObject *self, PyObject *args, PyObject *kw)
{
    PyObject *name;
    PyObject *age = NULL;
    PyObject *gender = NULL;
    char *keys[] = {"name", "age", "gender", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kw, "O!|O!O!", keys, &PyUnicode_Type, &name,
                                     &PyLong_Type, &age, &PyUnicode_Type, &gender)){
        return -1;
    }
    Py_XINCREF(name);
    if (age) Py_XINCREF(age); else age = PyLong_FromLong(17);
    if (gender) Py_XINCREF(gender); else gender = PyUnicode_FromWideChar(L"萌妹子", 3);
    ((MyClass *)self) -> name = name;
    ((MyClass *)self) -> age = age;
    ((MyClass *)self) -> gender = gender;
    return 0;
}

void
MyClass_del(PyObject *self)
{
    Py_XDECREF(((MyClass *)self) -> name);
    Py_XDECREF(((MyClass *)self) -> age);
    Py_XDECREF(((MyClass *)self) -> gender);
    Py_TYPE(self) -> tp_free(self);
}

// 下面来给类添加成员函数啦,添加方法跟之前的创建函数是一样的
static PyObject *
age_incr_1(PyObject *self, PyObject *args, PyObject *kw)
{
    ((MyClass *)self) -> age = PyNumber_Add(((MyClass *)self) -> age, PyLong_FromLong(1));
    return Py_None;
}
//构建 PyMethodDef[], 方法和之前创建函数是一样的,但是这是类的方法,记得添加到类的 tp_methods 成员中
static PyMethodDef MyClass_methods[] = {
        {"age_incr_1", (PyCFunction)age_incr_1, METH_VARARGS | METH_KEYWORDS, "method age_incr_1"},
        {NULL, NULL, 0, NULL}
};


static PyModuleDef module = {
        PyModuleDef_HEAD_INIT,
        "kagura_nana",
        "this is a module named hanser",
        -1,
        0,
        NULL,
        NULL,
        NULL,
        NULL
};

PyMODINIT_FUNC
PyInit_kagura_nana(void) {
    static PyTypeObject cls;

    PyVarObject ob_base = {1, &PyType_Type, 0};
    cls.ob_base = ob_base;
    cls.tp_name = "MyClass";
    cls.tp_basicsize = sizeof(MyClass);
    cls.tp_itemsize = 0;
    cls.tp_new = MyClass_new;
    cls.tp_init = MyClass_init;
    cls.tp_dealloc = MyClass_del;

    static PyMemberDef members[] = {
        {
            "name",
            T_OBJECT_EX,
            offsetof(MyClass, name),
            0,
            "this is a name"
        },
        {"age", T_OBJECT_EX, offsetof(MyClass, age), 0, "this is a age"},
        {"gender", T_OBJECT_EX, offsetof(MyClass, gender), 0, "this is a gender"},
        {NULL}
    };
    cls.tp_members = members;
    // 设置方法
    cls.tp_methods = MyClass_methods;

    if (PyType_Ready(&cls) < 0){
        return NULL;
    }

    Py_XINCREF(&cls);
    PyObject *m = PyModule_Create(&module);
    PyModule_AddObject(m, "MyClass", (PyObject *)&cls);
    return m;
}

我们看到几乎没有任何区别,那么下面就来测试一下:

>>> import kagura_nana
>>> self = kagura_nana.MyClass("古明地恋", 16, "美少女")
>>> self.age_incr_1()
>>> self.age
17
>>> 

循环引用造成的内存泄漏

我们说 Python 的引用计数有一个重大缺陷,那就是它无法解决循环引用。

while True:
    my = MyClass("古明地觉")
    my.name = my

如果你执行上面这段代码的话,那么你会发现内存不断飙升,很明显我们上面在 C 中定义的类是没有考虑循环引用的,因为它没有被 GC 跟踪。

我们看到由于内存使用量不断增加,最后被操作系统强制 kill 掉了,主要就在于我们没有解决循环引用,导致实例对象不断被创建、但却没有被回收(引用计数最大的缺陷)。如果想要解决循环引用的话,那么就需要 Python 中的 GC 出马,而使用 GC 的前提是这个类的实例对象要被 GC 跟踪,因此我们还需要指定 tp_flags。除此之外,我们还要指定 tp_traverse(判断内部成员是否被循环引用)和 tp_clear(清理)两个函数,至于具体细节编写代码时有所体现。最后我们上面的那个类也是不允许被继承的,如果想被继承,同样需要指定 tp_flags。

>>> import kagura_nana
>>> class A(kagura_nana.MyClass):
...     pass
... 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: type 'MyClass' is not an acceptable base type
>>>

我们看到 MyClass 不是一个可以被继承的类,那么下面我们来进行修改。

#include "Python.h"
#include "structmember.h"

class MyClass {
        public:
        PyObject_HEAD
        PyObject *name;
        PyObject *age;
        PyObject *gender;
};


static PyObject *
MyClass_new(PyTypeObject *cls, PyObject *args, PyObject *kw)
{
    MyClass *self = (MyClass *)cls -> tp_alloc(cls, 0);
    return (PyObject *)self;
}

static int
MyClass_init(PyObject *self, PyObject *args, PyObject *kw)
{
    PyObject *name;
    PyObject *age = NULL;
    PyObject *gender = NULL;
    char *keys[] = {"name", "age", "gender", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kw, "O!|O!O!", keys, &PyUnicode_Type, &name,
                                     &PyLong_Type, &age, &PyUnicode_Type, &gender)){
        return -1;
    }
    Py_XINCREF(name);
    if (age) Py_XINCREF(age); else age = PyLong_FromLong(17);
    if (gender) Py_XINCREF(gender); else gender = PyUnicode_FromWideChar(L"萌妹子", 3);
    ((MyClass *)self) -> name = name;
    ((MyClass *)self) -> age = age;
    ((MyClass *)self) -> gender = gender;
    return 0;
}


static PyObject *
age_incr_1(PyObject *self, PyObject *args, PyObject *kw)
{
    ((MyClass *)self) -> age = PyNumber_Add(((MyClass *)self) -> age, PyLong_FromLong(1));
    return Py_None;
}
static PyMethodDef MyClass_methods[] = {
        {"age_incr_1", (PyCFunction)age_incr_1, METH_VARARGS | METH_KEYWORDS, "method age_incr_1"},
        {NULL, NULL, 0, NULL}
};

// 判断是否被循环引用,参数和返回的值的定义还是参考源码,这里面的参数名要固定
static int MyClass_traverse(MyClass *self, visitproc visit, void *arg){
    // 底层帮你提供了一个宏
    Py_VISIT(self -> name);
    Py_VISIT(self -> age);
    Py_VISIT(self -> gender);
    return 0;
}
// 清理
static int MyClass_clear(MyClass *self){
    Py_CLEAR(self -> name);
    Py_CLEAR(self -> age);
    Py_CLEAR(self -> gender);
    return 0;
}

void
MyClass_del(PyObject *self)
{
    // 我们在 MyClass_clear 中使用了 Py_CLEAR,那么这里减少引用计数的逻辑就不需要了,直接调用 MyClass_clear 即可
    MyClass_clear((MyClass *) self);
    // 我们说 Python 会跟踪创建的对象,如果被回收了,那么应该从链表中移除
    PyObject_GC_UnTrack(self);
    Py_TYPE(self) -> tp_free(self);
}


static PyModuleDef module = {
        PyModuleDef_HEAD_INIT,
        "kagura_nana",
        "this is a module named hanser",
        -1,
        0,
        NULL,
        NULL,
        NULL,
        NULL
};

PyMODINIT_FUNC
PyInit_kagura_nana(void) {
    static PyTypeObject cls;

    PyVarObject ob_base = {1, &PyType_Type, 0};
    cls.ob_base = ob_base;
    cls.tp_name = "MyClass";
    cls.tp_basicsize = sizeof(MyClass);
    cls.tp_itemsize = 0;
    cls.tp_new = MyClass_new;
    cls.tp_init = MyClass_init;
    cls.tp_dealloc = MyClass_del;

    static PyMemberDef members[] = {
        {
            "name",
            T_OBJECT_EX,
            offsetof(MyClass, name),
            0,
            "this is a name"
        },
        {"age", T_OBJECT_EX, offsetof(MyClass, age), 0, "this is a age"},
        {"gender", T_OBJECT_EX, offsetof(MyClass, gender), 0, "this is a gender"},
        {NULL}
    };
    cls.tp_members = members;
    cls.tp_methods = MyClass_methods;

    // 解决循环引用造成的内存泄漏,通过 Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC 开启垃圾回收,同时允许该类被继承
    cls.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC;
    // 设置 tp_traverse 和 tp_clear
    cls.tp_traverse = (traverseproc) MyClass_traverse;
    cls.tp_clear = (inquiry) MyClass_clear;
    // 如果想指定继承的类的话,那么通过 tp_bases 指定即可,这里不再说了
    if (PyType_Ready(&cls) < 0){
        return NULL;
    }

    Py_XINCREF(&cls);
    PyObject *m = PyModule_Create(&module);
    PyModule_AddObject(m, "MyClass", (PyObject *)&cls);
    return m;
}

下面我们来继续测试一下,看看有没有问题:

可以看到,此时类可以被继承了,并且也没有出现循环引用导致的内存泄漏。

真的想说,用 C 写扩展实在是太不容易了,很明显这还只是非常简单的,因为目前这个类基本没啥方法。如果加上描述符、自定义迭代器,或者我们再多写几个方法。方法之间互相调用,导入模块(目前还没有说)等等,绝对是让人头皮发麻的事情,所以写扩展我一般只用 Cython。

全局解释器锁

我们使用 C / C++ 写扩展除了增加效率之外,最大的特点就是能够释放掉 GIL,关于 GIL 也是一个老生常谈的问题。我在前面系列已经说过,这里不再赘述了。

那么问题来了,在 C 中如何获取 GIL 呢?

// 首先 Python 中的线程是对 C 线程的一个封装,同时还会对应一个 PyThreadState(线程状态) 对象,用来对线程状态进行描述
// 而如果要使用 Python / C API 的话,那么就不能是 C 中的线程,而是 Python 中的线程
Py_GILState_STATE gstate;
// 所以 Python 为了简便而提供了一个函数 PyGILState_Ensure,在 C 中创建了一个线程,那么调用这个函数后,C 线程就会被封装成 Python 中的线程
// 不然的话,我们要写好多代码。这一步会对 Python 中线程进行初始化创建一个 PyThreadState 对象,同时获取 GIL
//
gstate = PyGILState_Ensure(); 
// 做一些其它操作,注意:一旦使用 Python / C API,那么必须要获取到 GIL
call_some_function();
// 释放掉 GIL
PyGILState_Release(gstate);

一旦在 C 中获取到 GIL,那么 Python 的其它线程都必须处于等待状态,并且当调用扩展模块中的函数时,解释器是没有权利迫使当前线程释放 GIL 的,因为调用的是 C 的代码,Python 解释器能控制的只有 Python 的字节码这一层。所以在一些操作执行结束后,必须要主动释放 GIL,否则 Python 的其它线程永远不会得到被调度的机会。

但有时我们做的是一些纯 C / C++ 操作,不需要和 Python 进行交互,这个时候希望告诉 Python 解释器,其它的线程该执行执行,不用等我,这个时候怎么做呢?首先Python 底层给我们提供了两个宏:Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS。

// 将当前线程状态给保存下来,然后其它线程就可以继续执行了,从名字上也能看出,开始允许多个线程并行执行
#define Py_BEGIN_ALLOW_THREADS { \
                        PyThreadState *_save; \
                        _save = PyEval_SaveThread();

// 恢复线程状态,回到解释器的 GIL 调用中
#define Py_END_ALLOW_THREADS    PyEval_RestoreThread(_save); \
                 }

从宏定义中我们可以看出,这两个宏是需要成对出现的,当然你也可以使用更细的 API 自己控制。总之:当释放 GIL 的时候,一定不要和 Python 进行交互,或者说不能有任何 Python / C API 的调用。

#include "Python.h"
#include <pthread.h>

// 子线程调用的函数, 要求接受一个 void *、返回一个 void*
void* test(void *lst) {
    // 对于扩展而言,我们是通过 Python 调用里面的函数,所以调用它的是 Python 中的线程
    // 但这是我们使用 pthread 创建的子线程进行调用,不是 Python 中的,因此它不能和 Python 有任何的交互
    // 而我们是需要和 Python 交互的,这里面的参数 lst 就是由 PyObject * 转化得到的,因此我们需要封装成 Python 中的线程
    PyGILState_STATE gstate;
    gstate = PyGILState_Ensure();
    // 这里面和 Python 进行交互
    PyObject *lst1 = (PyObject *) lst;
    // 我们往里面添加设置几个元素
    PyObject *item = PyLong_FromLong(123);
    PyList_Append(lst1, item);
    // 注意:以上引用计数变成了 2,我们需要再减去 1
    Py_XDECREF(item);
    item = PyUnicode_FromString("hello matsuri");
    PyList_Append(lst1, item);
    Py_XDECREF(item);
    // 假设我们以上 Python 的逻辑就调用完了,那么我们是不是要将 GIL 给释放掉呢?否则其它线程永远没有机会得到调度
    // 干脆我们就不释放了,看看效果吧
    return NULL;
}

static PyObject* test_gil(PyObject *self, PyObject *args){
    // 假设我们接受一个 list
    PyObject *lst = NULL;
    if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &lst)){
        return NULL;
    }

    // 创建线程 id
    pthread_t tid;
    // 创建一个线程
    int res = pthread_create(&tid, NULL, test, (void *)lst);
    if (res != 0) {
        printf("pthread_create error: error_code = %d\n", res);
    }
    return Py_None;
}


static PyMethodDef methods[] = {
    {"test_gil", (PyCFunction) test_gil, METH_VARARGS, "this is a function named test_gil"},
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named hanser",
    -1,
    methods,
    NULL,
    NULL,
    NULL,
    NULL
};

PyMODINIT_FUNC
PyInit_kagura_nana(void) {
    PyObject *m = PyModule_Create(&module);
    return m;
}

我们来测试一下:

我们看了程序就无法执行了,因为 Python 只能利用单核,我们在 C 中开启了子线程,然后创建对应的 Python 线程。此时就有两个 Python 线程,只不过一个是主线程,另一个是在 C 中创建的子线程,然后这个子线程通过 Python / C API 获取了 GIL,但是用完了不释放,这就导致了主线程永远得不到机会执行。当然也无法接收 Ctrl + C 命令,因此我们需要新启一个终端 kill 掉它。

#include "Python.h"
#include <pthread.h>

void* test(void *lst) {
    PyGILState_STATE gstate;
    gstate = PyGILState_Ensure();
    PyObject *lst1 = (PyObject *) lst;
    PyObject *item = PyLong_FromLong(123);
    PyList_Append(lst1, item);
    Py_XDECREF(item);
    item = PyUnicode_FromString("hello matsuri");
    PyList_Append(lst1, item);
    Py_XDECREF(item);
    // 这里将 GIL 释放掉
    PyGILState_Release(gstate);
    // 然后下面就不可以再有任何 Python / C API 的出现了
    return NULL;
}

static PyObject* test_gil(PyObject *self, PyObject *args){
    PyObject *lst = NULL;
    if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &lst)){
        return NULL;
    }

    pthread_t tid;
    int res = pthread_create(&tid, NULL, test, (void *)lst);
    if (res != 0) {
        printf("pthread_create error: error_code = %d\n", res);
    }
    return Py_None;
}


static PyMethodDef methods[] = {
    {"test_gil", (PyCFunction) test_gil, METH_VARARGS, "this is a function named test_gil"},
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named hanser",
    -1,
    methods,
    NULL,
    NULL,
    NULL,
    NULL
};

PyMODINIT_FUNC
PyInit_kagura_nana(void) {
    PyObject *m = PyModule_Create(&module);
    return m;
}

然后我们再来测试一下:

我们看到此时就没有任何问题了,当 C 中的线程将 GIL 给释放掉之后,此时它和 Python 线程就没有关系了,它就是 C 的线程。那么下面可以写纯 C / C++ 代码,此时可以实现并行执行。但是能不用多线程就不用多线程,因为多线程出现 bug 之后难以调试。

另外我们目前是在 C 中创建的 Python 线程,但是很明显这需要你对 C 的多线程理解有一定要求。那么我也可以不在 C 中创建,而是在 Python 中创建子线程去调用。

#include "Python.h"


static PyObject* test_gil(PyObject *self, PyObject *args){
    PyObject *lst = NULL;
    if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &lst)){
        return NULL;
    }
    // 此时该函数要被 Python 的子线程进行调用,但是很明显默认还是受到 GIL 的限制的
    Py_BEGIN_ALLOW_THREADS  // 释放掉 GIL,此时调用该函数的 Python 线程将不再受到解释器的制约,从而实现并行执行
    // 但是很明显,这里面不可以有任何的 Python / C API 调用
    long a;
    while (1) a ++;  // 不停的对 a 进行自增,显然程序会一直卡在这里
    Py_END_ALLOW_THREADS  // 获取 GIL,此时会回到解释器的线程调度中
    // 下面就可以包含 Python 逻辑了,如果再遇到纯 C / C++ 逻辑,那么就再通过这两个宏继续实现并行
    // 当然为了演示,我们上面是个死循环
    return Py_None;
}


static PyMethodDef methods[] = {
    {"test_gil", (PyCFunction) test_gil, METH_VARARGS, "this is a function named test_gil"},
    {NULL, NULL, 0, NULL}
};

static PyModuleDef module = {
    PyModuleDef_HEAD_INIT,
    "kagura_nana",
    "this is a module named hanser",
    -1,
    methods,
    NULL,
    NULL,
    NULL,
    NULL
};

PyMODINIT_FUNC
PyInit_kagura_nana(void) {
    PyObject *m = PyModule_Create(&module);
    return m;
}

然后我们在 Python 中创建子线程去调用:

我们开启了一个子线程,去调用扩展模块中的函数,然后主线程也写了一个死循环。下面看一下 CPU 的使用率:

我们看到成功利用了多核,此时我们就通过编写扩展的方式来绕过了解释器中 GIL 的限制。

所以对于一些 C / C++ 逻辑,它们不需要和 Python 进行所谓的交互,那么我们就可以把 GIL 释放掉。因为 GIL 本来就是为了保护 Python 中的对象的,为了内存管理,CPython 的开发人员为了直接在解释器上面加上了一把超级大锁,但是当我们不需要和 Python 对象进行交互的时候,就可以把 GIL 给释放掉。

GIL 是字节码级别互斥锁,当线程执行字节码的时候,如果自身已经获取到 GIL ,那么会判断是否有释放的 GIL 的请求(gil_drop_request):有则释放、将 CPU 使用权交给其它线程,没有则直接执行字节码;如果自身没有获取到 GIL,那么会先判断 GIL 是否被别的线程获取,若被别的线程获取就一直申请、没有则拿到 GIL 执行字节码。

总结

这一次我们聊了聊 Python 和 C/C++ 联合编程,我们可以在 Python 中引入 C/C++,也可以在 C/C++ 中引入 Python,甚至还可以定制 Python 解释器。只不过笔者是主 Python 的,因此在 C/C++ 中引入 Python 就不说了。

Python 引入 C/C++ 主要是通过编写扩展的方式,这真的是一件痛苦的事情,需要你对 Python / C API 有很深的了解,最后仍然安利一波 Cython。

这应该是我有史以来写过的最长的文章了。

posted @ 2021-02-12 01:56  古明地盆  阅读(4991)  评论(11编辑  收藏  举报