分享

支持静态声明的类型

 古明地觉O_o 2022-12-08 发布于北京

Cython 中的 C 指针


上一篇文章我们说了,C 的类型在 Cython 里面都是支持的,下面我们来看一下指针。

cdef double a
cdef double *b = NULL

# 和 C 一样, * 要放在类型或者变量的附近
# 但是如果在一行中声明多个指针变量
# 那么每一个变量都要带上 *
cdef double *c, *d

# 如果是这样的话
# 则表示声明一个指针变量和一个整型变量
cdef int *e, f 

既然可以声明指针变量,那么也能够取得某个变量的地址才对。是的,在 Cython 中通过 & 获取一个变量的地址。

cdef double a = 3.14
cdef double *b = &a 

问题来了,既然可以获取指针,那么能不能通过 * 来获取指针指向的值呢?答案是可以获取值,但方式不是通过 * 来实现。Python 的 * 有特殊含义,没错,就是 *args 和 **kwargs,它们允许一个函数收任意个数的参数,并且通过 * 还可以对一个序列进行解包。

因此对于 Cython 来讲,无法通过 *p 来获取 p 指向的内存。在 Cython 中获取指针指向的内存,可以通过类似于 p[0] 这种方式,p 是一个指针变量,那么 p[0] 就是 p 指向的内存。

cdef double a = 3.14
cdef double *b = &a

print(f"a = {a}")
# 修改b指向的内存
b[0] = 6.28
# 再次打印a
print(f"a = {a}")

该文件叫做 cython_test.pyx,我们在另一个 py 文件中导入它。

import pyximport
pyximport.install(language_level=3)

import cython_test
"""
a = 3.14
a = 6.28
"""

.pyx 文件里面有 print 语句,导入的时候自动打印,而打印结果显示 a 确实被修改了。因此我们在 Cython 中可以通过 & 来获取指针,也可以通过指针[0]的方式获取指针指向的内存。唯一的区别就是 C 里面使用 * 来解引用,而 Cython 里面如果也使用 *,比如 *b = 6.28,那么在语法上是不被允许的。

C 和 Cython 中关于指针还有一个区别,就是指针在指向一个结构体的时候。假设有一个结构体指针叫做 s,里面有两个成员 a 和 b,都是整型。那么对于 C 而言,可以通过 s -> a + s -> b 的方式将两个成员相加;但是对于 Cython 来说,则是 s.a + s.b。我们看到这个和 Go 是类似的,无论是结构体指针还是结构体本身,都是使用 . 的方式访问结构体内部的成员。


静态类型变量和动态类型变量的混合


Cython 允许静态类型变量和动态类型变量之间进行赋值,这是一个非常强大的特性。它允许我们使用动态的 Python 对象,并且在决定性能的地方能很轻松地将其转化为快速的静态对象。

假设我们有几个静态的 C int 要组合成一个 Python 的元组,如果使用 Python/C API 创建和初始化的话,会很乏味,需要几十行代码以及大量的错误检查;而在Cython中,只需要像 Python 一样做即可:

cdef int a, b, c 
t = (a, b, c)

然后我们来导入一下:

import pyximport
pyximport.install(language_level=3)

import cython_test

# 静态声明的变量如果没有指定初始值
# 那么默认为零值
print(cython_test.t)  # (0, 0, 0)
print(type(cython_test.t))  # <class 'tuple'>
print(type(cython_test.t[0]))  # <class 'int'>

# 虽然t可以访问,但 a、b、c 是无法访问的,因为它们是 C 中的变量
# 使用 cdef 定义的变量都会被屏蔽掉,在 Python 中是无法使用的
try:
    print(cython_test.a)
except Exception as e:
    print(e)  # module 'cython_test' has no attribute 'a'

执行的过程很顺畅,这里要说的是:a、b、c 都是使用 cdef 静态声明的整型变量,Cython 允许使用它们创建动态类型的 Python 元组,然后将该元组分配给 t。所以这个例子便体现了 Cython 的美丽和强大之处,可以用一种显而易见的方式创建一个元组,而无需考虑其它情况。因为 Cython 的目的就在于此,希望概念上简单的事情在实际操作上也很简单。

想象一下使用 Python/C API 的场景,如果要创建一个元组该怎么办?首先要使用 PyTuple_New 申请指定元素个数的空间,还要考虑申请失败的情况;然后调用 PyTuple_SetItem 将元素一个一个的设置进去,这显然是非常麻烦的,肯定没有 t = (a, b, c) 来的直接。

不过话虽如此,但并不是所有东西都可以这么做的。上面的例子之所以有效,是因为Python 的 int 和 C 的 int(还有 short、long 等等)有明显的对应关系。

如果是指针呢?我们知道 Python 里面没有指针这个概念,或者说指针被隐藏了,只有解释器才能操作指针。因此在 Cython 中,我们不可以在 def 定义的函数里面返回和接收指针,以及打印指针、指针作为 Python 的动态数据结构(如:元组、列表、字典等等)中的某个元素,这些都是不可以的。

回到元组的那个例子,如果 a、b、c 是一个指针,那么必须要在放入元组之前进行解引用,或者说放入元组中的只能是它们指向的值。因为 Python 在语法层面没有指针的概念,所以不能将指针放在元组里面。

同理:假设 cdef int a = 3,那么可以是 cdef int *b = &a,但绝不能是 b = &a。因为直接 b = xxx 的话,那么 b 是 Python 的变量,其类型则需要根据值来推断,然而值是一个指针,所以这是不允许的。

但 cdef int b = a 和 b = a 则都是合法的,因为 a 是一个整数,C 的整数可以转化成 Python 的整数,所以编译的时候会自动转化。只不过前者相当于创建了一个 C 的变量 b,Python 导入的时候无法访问;而后者相当于创建一个 Python 变量 b,Python 导入的时候可以访问。

举个例子:

cdef int a
b = &a
"""
cdef int a
b = &a
   ^
------------------------------------------------------------

cython_test.pyx:5:4: Cannot convert 'int *' to Python object
Traceback (most recent call last):
"""

我们看到在导入的时候,编译失败了。因为 b 是 Python 的类型, 而 &a 是一个 int *,所以无法将 int * 转化成 Python 对象。

cdef int a = 3
cdef int b = a
c = a
import pyximport
pyximport.install(language_level=3)

import cython_test

try:
    print(cython_test.a)
except Exception as e:
    print(e)  # module 'cython_test' has no attribute 'a'

try:
    print(cython_test.b)
except Exception as e:
    print(e)  # module 'cython_test' has no attribute 'b'

print(cython_test.c)  # 3

整数显然是可以的,因为 C 和 Python 都有整数,只不过 a 和 b 是 C 的变量,无法访问;而 c 是 Python 的变量,可以访问。不过问题又来了,看一下下面的几种情况:

先定义一个C的变量,然后给这个变量重新赋值:

cdef int a = 3
a = 4

Python 在导入的时候能否访问到 a 呢?答案是访问不到的,虽说是 a = 4 像是创建 一个 Python 的变量,但是不好意思,上面已经创建了 C 的变量 a。因此下面再操作 a,都是操作 C 的变量 a,如果来一个a = "xxx",那么是不合法的。因为 a 已经是整数了,再将一个字符串赋值给 a 显然会报错。

先定义一个 Python 变量,再定义一个同名的 C 变量:

b = 3
cdef int b = 4
"""
b = 3
^
------------------------------------------------------------

cython_test.pyx:4:0: Previous declaration is here
"""

即使一个是 Python 的变量,一个是 C 的变量,也依旧不可以重名。不然在 Cython 内部访问 b 的话,究竟访问哪一个变量呢?所以 b = 3 的时候,变量就已经被定义了,而 cdef int b = 4 又定义了一遍,显然是不合法的。

不光如此,cdef int c = 4 之后再写上 cdef int c = 5 仍然属于重复定义,不合法。但 cdef int c = 4 之后,写上 c = 5 是合法的,因为这相当于改变 c 的值,并没有重复定义。

先定义一个 Python 变量,再定义一个同名的 Python 变量:

cdef int a = 666
v = a
print(v)
cdef double b = 3.14
v = b
print(v)

这么做是合法的,其实从 Cython 是 Python 的超集这一点就能理解。主要是:Python 中变量的创建方式和 C 中变量的创建方式是不一样的,Python 的变量只是一个指向某个值的指针,而 C 的变量就是代表值本身。

cdef int a = 5 相当于创建了一个变量 a,这个变量 a 代表的就是 5 本身,只不过这个 5 是 C 的整数 5。而 v = a 相当于先根据 a 的值、也就是 C 的整数 5 创建一个 Python 的整数 5, 然后再让 v 指向它。

那么 v = b 也是同理,因为 v 是 Python 的变量,它想指向谁就指向谁。而 b 是一个 C 的 double,可以转成 Python 的 float。但如果将一个指针赋值给 v 就不可以了,因为 Python 没有任何一个数据类型可以和 C 的指针相对应。

再来看一个栗子:

num = 666

a = num
b = num 
print(id(a) == id(b))  # True

首先这个栗子很简单,因为 a 和 b 指向了同一个对象,但如果是下面这种情况呢?

cdef int num = 666

a = num
b = num
print(id(a) == id(b)) 

你会发现打印的是 False,因为此时这个 num 是 C 的变量,然后 a = num 会先根据 num 的值创建一个 Python 的整数,然后再让 a 指向它;同理 b 也是如此,而显然这会创建两个不同的 666,虽然值一样,但是地址不一样。

如果将 666 改成 123,会发现打印的是 True,原因是 Python 内部存在小整数对象池。

所以这就是 Cython 的方便之处,不需要我们自己转化,而是在编译的时候自动转化。当然还是按照我们之前说的,自动转化的前提是可以转化,也就是两者之间要互相对应,比如整数、浮点数。

那么常见的对应关系都有哪些呢?我们总结一下:

注意:C 的布尔类型在 Cython 里面叫做 bint,0 为假,非 0 为真。

这里再多说一句整数溢出的情况,举个例子:

# 显然 C 的 int 是存不下的
i = 2 << 81  

cdef int j = i

我们看到转成 C 的 int 时,如果存不下会自动尝试使用 long。若还存不下,则报错。


使用 Python 类型进行静态声明


使用 cdef 声明变量属于静态声明,这种方式声明的变量只能在 Cython 内部使用,Python 是无法访问的;而不使用 cdef、也就是直接创建一个变量,属于动态声明,这种方式声明的变量 Python 可以访问。

然后使用 cdef 声明变量的时候,我们给变量指定类型可以提升效率,但到目前为止我们用的都是 C 的类型,那么 Python 的类型可不可以呢?显然是可以的。

只要是在 CPython 中实现了,并且 Cython 有权限访问的话,都可以用来进行静态声明,而 Python 的内建类型都是满足要求的。换句话说,只要在 Python 中可以直接拿来用的,都可以直接当成 C 的类型来进行声明(bool 类型除外,bool 的话使用 bint)。

# 声明的时候直接初始化
cdef tuple b = tuple("123")
cdef list c = list("123")
cdef dict d = {"name""古明地觉"}
cdef set e = {"古明地觉""古明地恋"}
cdef frozenset f = frozenset(["古明地觉""古明地恋"])

A = a
B = b
C = c
D = d
E = e
F = f

我们测试一下:

import pyximport
pyximport.install(language_level=3)

from cython_test import *
print(A)  # 古明地觉
print(B)  # ('1', '2', '3')
print(C)  # ['1', '2', '3']
print(D)  # {'name': '古明地觉'}
print(E)  # {'古明地恋', '古明地觉'}
print(F)  # frozenset({'古明地恋', '古明地觉'})

得到的结果是正确的,完全可以使用 Python 的类型静态声明。这里在使用 Python 的类型进行静态声明的时候,我们都赋上了一个初始值,但如果只是声明没有赋上初始值,那么得到的结果是一个 None。

注意:只要是用 Python 的类型进行静态声明且不赋初始值,那么结果都是 None。比如:cdef tuple b; B = b,那么 Python 在打印 B 的时候得到的就是 None,而不是一个空元组。不过整型是个例外,因为 int 我们实际上用的是 C 里面 int,会得到一个 0,当然还有 float。

问题来了,为什么 Cython 可以做到这一点呢?实际上这些结构在 CPython 中都是已经实现好了的,Cython 只需将它们设置为指向底层某个数据结构的 C 指针。比如 cdef tuple a,那么 a 就是一个 PyTupleObject *,它们可以像普通变量一样使用。


用于加速的静态类型


我们上面介绍了在 Cython 中使用 Python 的类型进行静态声明,这咋一看有点古怪,为什么不直接使用 Python 的方式创建变量呢?

比如 a = [1, 2, 3] 不香么?为什么非要使用 cdef list a = [1, 2, 3] 这种形式呢?答案是为了遵循一个通用的 Cython 原则:我们提供的静态信息越多,Cython 就越能优化结果。

因为 a = [1, 2, 3],这个 a 可以指向任意的对象,但是 cdef list a = [1, 2, 3] 的话,这个 a 只能指向列表。

cdef list a = [123]
# 合法
a = [234]
# 不合法,因为 a 只能指向列表
a = (234)

在使用 cdef 声明时,如果变量的类型是 C 的类型,那么变量代表值;如果变量的类型是 Python 的类型,那么变量仍是指向值的指针。

不然虽然这里的 a 仍是一个指针,但它不是泛型指针 PyObject *,而是 PyListObject *,在明确了类型的时候,执行的速度会更快。我们举个例子:

a = []
a.append(1)

我们只看 a.append(1) 这一行,显然它再简单不过了,但是你知道解释器是怎么操作的吗?

1)检测类型,Python 的变量是一个 PyObject *,因为任何对象在底层都嵌套了 PyObject 这个结构体,但具体是什么类型则需要进一步检索才知道。通过 ob_type 成员,拿到其类型。

2)判断类型对象内部是否有 append 方法,有的话则获取,这又需要一次查找。

3)进行调用。

因此我们看到一个简单的 append,Python 内部是需要执行以上几个步骤的,但如果我们事先规定好了类型呢?

cdef list a = []
a.append(1)

对于动态变量而言,解释器事先并不知道它指向哪种类型的对象,只能运行时动态转化。但如果创建的时候指定了类型为 list,那么此时的 a 不再是 PyObject *而是 PyListObject *,解释器知道 a 指向了一个列表。

而我们对列表进行 append 的时候,底层会调用的 C 一级的函数 PyList_Append,索引赋值调用的是 PyList_SetItem,索引取值调用的是 PyList_GetItem,等等等等。每一个操作在 C 一级都指向了一个具体的函数,如果提前知道了类型,那么 Cython 生成的代码可以将上面的三步变成一步。

没错,既然知道指向的是列表了,那么 a.append 会直接返回 PyList_Append 这个 C 一级的函数,这样省去了类型检测、属性查找等步骤,直接调用即可。

所以列表解析比普通的 for 循环快也是如此,因为 Python 对内置结构非常熟悉,当我们使用的是列表解析式,那么解释器就知道要创建一个列表了,因此同样会直接使用 PyList_Append 这个 C 一级的函数。而如果是普通的 for 循环加上 append,那么解释器就要花费很多时间在类型转化和属性查找上面,需要先兜兜转转经过几次查找,然后才能找到 PyList_Append。

但需要注意的是,上面的变量 a 虽然是 list 类型,但它是使用 cdef 静态声明的变量,所以依旧不能被 Python 访问,只能在 Cython 内部使用。可能有人好奇这是为什么,下面来解释一下。

我们知道 Python 的变量是要存储在名字空间里面的,名字空间是一个字典,但字典在底层是用 C 的数组实现的。而 C 的数组要求里面的元素类型必须一致,所以这也是为什么 Python 的变量都是泛型指针 PyObject *。因为指向不同对象的指针,类型是不同的,但指针可以互相转化,因此它们都要转成同一种类型的指针之后,才能放到名字空间里面,而这个指针就是泛型指针 PyObject *。

PyObject 是对象的基石,它里面保存了对象的引用计数(ob_refcnt)和类型(ob_type),任何一个对象,内部都嵌套了 PyObject。所以无论什么对象,它的指针都必须转成 PyObject * 之后才能交给变量保存,然后通过变量操作的时候,也要先根据 ob_type 判断对象的类型,然后再去寻找相关操作。

但我们上面的是静态列表,使用 cdef 声明变量 a 的时候指定了 list,那么 a 就是PyListObject *。所以解释器在操作变量 a 的时候,知道它指向一个列表,因此就省去了类型判断相关的步骤,得到性能的提升。但与此相对的,由于它不是 PyObject *,所以无法放在名字空间中,自然也无法被 Python 访问了。

如果是动态声明的列表,那么 PyListObject * 会转成 PyObject *,然后交给变量保存,此时会放到名字空间中,让 Python 能够访问。但很明显,在具体操作的时候,速度就不那么快了。

同理我们在 Cython 中使用 for 循环的时候,也是如此。如果我们循环一个可迭代对象,而这个可迭代对象内部的元素都是同一种类型(假设是 dict 对象),那么在循环之前可以先声明循环变量的类型。比如:cdef dict item,然后再 for item in xxx,这样也能提高效率。

总之 Python 慢的原因就是无法基于类型进行优化, 以及对象都申请在堆区。所以我们使用 Cython 的时候,一定要规定好类型,通过 cdef 引入静态类型系统,来保证执行的效率。但这么做的缺点就是一旦规定好类型(无论是 C 的类型还是 Python 的类型),后续就不能再改变了,不过动态性和程序的运行效率本身就是无法兼得的。

而提升效率的另一个手段就是不要把对象放在堆区申请,换句话说如果能用 C 的类型,就不要用 Python 的类型。但很明显,我们不可能不用 Python 的类型,像整型、浮点型还好,而其它复杂的 Python 类型该用还是要用的。

总之,使用 Cython 的重点是做好类型标注。

Python 类型不可以使用指针

这里还需要强调一下,使用 Python 的类型声明变量的时候不可以使用指针的形式,比如:cdef tuple *t,这么做是不合法的,会报错:

Pointer base type cannot be a Python object

此外,我们使用 cdef 的时候指定了类型,那么赋值的时候就不可以那么无拘无束了。比如:cdef tuple a = list("123") 就是不合法的,因为声明了 a 指向一个元组,但是我们给了一个列表,那么编译扩展模块的时候就会报错:TypeError: Expected tuple, got list。

这里再思考一个问题,我们说 Cython 中使用 cdef 创建的变量无法被直接访问,需要将其赋值给 Python 中的变量才可以使用。那么,在赋完值的时候,这两个变量指向的是同一个对象吗?

cdef list a = list("123")
# a是一个PyListObject *, 但b是一个PyObject *
# 但是这两位老铁是不是指向同一个PyListObject对象呢?
b = a  
# 打印一下a is b
print(a is b)
# 修改a的第一个元素之后,再次打印b
a[0] = "xxx"
print(b)

我们测试一下:

import pyximport
pyximport.install(language_level=3)

import cython_test
"""
True
['xxx', '2', '3']
"""

我们看到 a 和 b 确实指向同一个对象,并且 a 在本地修改了之后,会影响到 b。因为 b = a 本质上就是将 PyListObject * 转成了 PyObject *,然后交给变量 b。但很明显,虽然指针类型不一样,但存储的地址是一样的。

两个变量指向的是同一个列表、或者 PyListObject 结构体实例,所以操作任何一个变量都会影响另一个。只不过变量 a 操作的时候会快一些,而变量 b 操作的时候会做一些额外的工作。


小结


Cython 将 C 的类型引入到了 Python 中,通过 cdef 声明变量时规定好类型,可以极大地减少 CPU 执行的机器码数量。并且 C 的数据默认是分配在栈上面的,执行的时候会更快。当然啦,Cython 同时理解 C 和 Python,所以 Cython 里面不仅可以使用 C 的类型,还可以使用 Python 的内置类型。

如果使用 Python 的类型静态声明,那么对象仍会分配在堆上,只是返回的指针不再是泛型指针,而是某个具体对象的指针。这样可以避免类型检测等开销,依旧能实现效率的提升。

要是你觉得效率提升的还不够,那么在 Cython 里面还可以将列表替换成 C 数组,将字典替换成 C 结构体,进一步实现效率的提升。但很明显,此时就不像是写 Python 了。当用到 C 数组、结构体等复杂结构时,一般都是为了调用已存在的 C 库函数,比如某个 C 库函数需要接收一个结构体。

所以在不涉及已有的 C 库时C 的数据结构我们只使用整数、浮点数即可(默认行为)。如果列表、集合、字典之类的复杂数据结构也想办法用 C 的数据结构代替的话,那我觉得还不如直接用 C++ 或者 Rust。

关于 C 数组、结构体相关的内容后面会介绍,而要不要在你的项目中使用它们就看你自己了。总之使用 Python 开发程序,能够轻松地获得开发效率,因为 Python 灵活且动态,但与此同时也要忍受运行时的低效率。

虽然通过引入 Cython,可以轻松地将程序的性能从 60 分提高到 90 分。但 Cython 毕竟是为 Python 服务的,所以想从 90 分再往上提高就非常困难了,代码也会变得更加复杂。如果真的追求极致的性能,那么最佳做法是换一门更有效率的静态语言,因为 Python 程序不管怎么优化,也不可能真的媲美 C++ 和 Rust 之类的静态语言。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多