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 命令进行编译。

img

当然这两种命令的使用方式都是类似的,或者你也可以使用 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

img

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

img

变量名为 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 提供的交互能力还是比较有限的,最明显的问题就是不同语言数据类型不同,一些复杂的交互方式还是比较难做到的,还有多线程的控制问题等等。

举个小栗子

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

1
2
3
int f(){
return 123;
}

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 中进行打印,举个栗子:

1
2
3
4
5
#include <stdio.h>

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

然后编译,进行调用。

1
2
3
4
import ctypes

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#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++ 动态库,但显然目前还是远远不够的。比如说:

1
2
3
double f() {
return 3.14;
}

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

1
2
3
4
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

img

下面来演示一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 *:空指针

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 也是支持的,我们后面会介绍。

参数传递

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

1
2
3
4
5
6
#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。

1
2
3
4
5
6
7
8
9
10
11
12
13
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#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 中是可以被修改的。

1
2
3
4
5
6
7
8
9
10
11
#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 语言传递可以修改字符的空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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,那么表示创建对应长度的字符缓存。除此之外,还可以指定字节串,此时的字符缓存大小和指定的字节串大小是一致的:

1
2
3
4
5
6
7
8
9
10
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 还可以在指定字节串的同时,指定空间大小。

1
2
3
4
5
6
7
8
9
10
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 来传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#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 就表示这个字符数组结束了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 去调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 这么个东西,我们来看一下:

1
2
3
4
5
6
7
8
9
from ctypes import *

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

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

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

img

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

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

显示窗体和隐藏窗体

1
2
3
4
5
6
7
8
9
import win32gui
import win32con

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

控制窗体的位置和大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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)

img

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

1
2
3
4
5
6
7
8
9
10
11
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)

语音播放

1
2
3
4
5
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 返回值的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 指定一下返回值到底是什么类型,只有这样才能拿到动态链接库中函数的正确的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#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 调用动态链接库的函数返回的都是整型,至于返回的整型的值到底是什么?从哪里来的?我们不需要关心,你可以理解为地址、或者某块内存的脏数据,但是不管怎么样,结果肯定是不正确的(如果函数返回的就是整形除外)。因此我们需要提前声明一下返回值的类型。声明方式:

1
lib.CFunction.restype = ctypes类型

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 是不需要参数的,如果我们传了那么就会忽略掉,依旧能得到正常的返回值
# 但是不要这么做,因为没准就出问题了,所以还是该传几个参数就传几个参数

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#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 也建议不要混用,而且传参的时候最好也进行转化。

img

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

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

ctypes.byref 和 ctypes.pointer 创建指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 返回的指针相当于左值。举个栗子:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 接收两个 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 则是用来获取指针的。

声明类型

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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

img

传递数组

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

1
2
3
4
5
6
7
8
9
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>

下面演示一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 字符数组默认是以 \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 定义一个结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 中传递一个结构体,然后再返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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

如果是结构体指针呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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 来获取。再举个栗子对比一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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 会管理它。

img

回调函数

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#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;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#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 语言可以识别的函数。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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
"""

img

以上便是 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 进来的,那么它必然要有一个入口。

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

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

1
PyModule_Create  // 创建模块

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

1
PyModuleDef  // 模块信息

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

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

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

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

1
2
3
4
5
6
def f1():
return 123


def f2(a):
return a + 1

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/*
编写 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 文件编译成扩展模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 进行编译的。

img

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

img

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

img

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

1
2
3
4
5
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 加左键可以调到指定模块的指定位置。

img

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static PyObject *
f1(PyObject *self)
{
}

static PyObject *
f2(PyObject *self)
{
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 对象,这个变量的名字叫什么也没有要求。

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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* 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。

1
2
3
4
5
6
7
PyObject *
PyLong_FromLong(long ival)
{
PyLongObject *v;
// ...
return (PyObject *)v;
}

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

1
2
3
#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 来用于引用计数的增加和减少。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 引用计数增加很简单,就是找到 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 中的对象都是申请在堆区的,这个是不会自动释放的。举个栗子:

1
2
3
4
5
6
7
8
9
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 是一个指针,这个指针被销毁了是不假,但是它指向的内存并没有被销毁。

1
2
3
4
5
6
7
8
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 了。不过有一个特例,那就是当这个指针作为返回值的时候,我们不需要手动减去引用计数,因为会自动减。

1
2
3
4
5
6
7
8
9
10
11
12
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 传过来的。

1
2
3
4
5
6
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。

1
2
3
4
5
6
7
8
9
10
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。

1
2
3
4
5
6
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,对应函数格式如下:

1
2
3
4
5
static PyObject *
f(PyObject *self)
{

}

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

1
2
3
4
5
static PyObject *
f(PyObject *self, PyObject *val)
{

}

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

1
2
3
4
5
static PyObject *
f(PyObject *self, PyObject *args)
{

}

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

1
2
3
4
5
static PyObject *
f(PyObject *self, PyObject *args, PyObject *kwargs)
{

}

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

解析多个位置参数

1
函数原型: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 *

我们举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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,来测试一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#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 可能会感到别扭。

img

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 文件为例:

1
2
3
4
5
6
7
8
9
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 来编写扩展的话,要怎么做呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#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 测试一下:

1
2
3
4
5
6
7
8
9
10
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 的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#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 进行调用,看看结果。

1
2
3
4
import kagura_nana

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

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

类型检查和返回异常

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#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

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

1
2
3
4
5
6
7
8
9
10
11
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 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#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 信息为: returned NULL without setting an error,提示你返回了 NULL 但没有设置 error。因为返回 NULL 表示程序需要终止了,那么就应该把为什么需要终止的理由告诉使用者。

然后我们来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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']

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

1
2
3
4
5
6
7
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 呢?既然要返回我们就需要知道它的结构是什么。

1
2
# 首先在 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。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#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 来测试一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 代码,我们不做任何改动,来测试一下当参数传递错误时的报错信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#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 来测试一下,注意观察报错信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 要出现在占位符的结尾,并且只能出现一次。如果这样的话会变成什么样子呢?

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 底层为我们提供更简便的做法,先来看一个常规的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 *,但如果我希望第一个参数的类型是浮点型,第三个参数的类型是字典,这个时候该怎么做呢?此时 ! 就派上用场了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}

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

1
2
3
4
5
6
7
8
9
10
11
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 就是我们传递过来的参数。我们举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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;
}

我们来测试一下:

1
2
3
4
5
print(kagura_nana.f1(123))
"""
any = 124
None
"""

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

占位符 ;

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

1
2
3
4
5
6
7
8
9
10
11
12
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;
}

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

1
2
3
4
5
6
7
8
9
10
11
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)

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

img

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

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

占位符 $

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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))

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

重新编译然后测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

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

img

并且,即便仅限关键字参数和默认参数相同,那也应该这么写 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 的占位符是一致的,但是功能相反。

img

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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))

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#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 *,举个栗子就很清晰了:

1
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 中查看:

img

像这些凡是以 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

下面我们来操作一波:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#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 来进行遍历,下面演示一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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):清空字典
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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;
}

测试一下:

1
2
3
4
5
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 实例,那么我们就把这个结构体拷贝出来看一下就知道了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
// 下面我们来介绍一下内部成员都代表什么含义
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 实例,然后针对指定的属性进行设置即可。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#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 文件:

1
2
3
4
5
6
7
8
9
10
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++,否则编译时会抛出:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
>>> 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 中。

1
2
3
4
5
6
>>> 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 中,然后再看看析构函数。

1
2
3
>>> del self
call __del__
>>>

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

给实例对象添加属性

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#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;
}

我们来测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> 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* 中。

1
2
3
4
5
6
7
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 中是如何实现的呢?很简单,和模块中定义函数是一致的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#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;
}

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

1
2
3
4
5
6
>>> import kagura_nana
>>> self = kagura_nana.MyClass("古明地恋", 16, "美少女")
>>> self.age_incr_1()
>>> self.age
17
>>>

循环引用造成的内存泄漏

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

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

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

img

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

1
2
3
4
5
6
7
8
>>> 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 不是一个可以被继承的类,那么下面我们来进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#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;
}

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

img

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

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

全局解释器锁

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

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

1
2
3
4
5
6
7
8
9
10
11
// 首先 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。

1
2
3
4
5
6
7
8
// 将当前线程状态给保存下来,然后其它线程就可以继续执行了,从名字上也能看出,开始允许多个线程并行执行
#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 的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#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;
}

我们来测试一下:

img

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#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;
}

然后我们再来测试一下:

img

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#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 中创建子线程去调用:

img

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

img

我们看到成功利用了多核,此时我们就通过编写扩展的方式来绕过了解释器中 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。