《Cython系列》3. Cython 语法的介绍与深入解析 (内含Python解释器相关知识)

楔子

前面我们说了 Cython 是什么,为什么我们要用它,以及如何编译和运行 Cython 代码。有了这些知识,那么是时候进入 Cython 的深度探索之路了。

Cython 和 Python 的差别从大方向上来说无非两个,一个是:运行时解释和预先编译;另一个是:动态类型和静态类型。

解释执行 VS 编译执行

为了更好地理解为什么 Cython 可以提高 Python 代码的执行性能,有必要对比一下 Python 虚拟机执行 Python 代码和操作系统执行已经编译的 C 代码之间的差别。

Python代码在运行之前,会先被编译成 pyc 文件(里面存储的是 Python 底层的PyCodeObject 对象),然后读取里面的 PyCodeObject 对象,执行内部的字节码。而字节码是能够被 Python 虚拟机解释或者执行的基础指令集,并且虚拟机独立于平台,因此在一个平台生成的字节码可以在任意平台运行。虚拟机将一个高级字节码翻译成一个或者多个可以被操作系统执行、最终被CPU执行的低级操作(指令)。这种虚拟化很常见并且十分灵活,可以带来很多好处:其中一个好处就是不会被挑剔的编译器嫌弃(相较于编译型语言,你在一个平台编译的可执行文件在其它平台上可能就用不了了),而缺点是运行速度比本地编译好的代码慢。

而站在 C 的角度,由于不存在虚拟机或者解释器,因此也就不存在所谓的高级字节码。C 代码会被直接翻译、或者编译成机器码,可以直接以一个可执行文件或者动态库(dll 或者 so)的形式存在。但是注意:它依赖于当前的操作系统,是为当前平台和架构量身打造的,因为可以直接被 CPU 执行,而且级别非常低(伴随着速度快),所以它与所在的操作系统是有关系的。

那么有没有一种办法可以弥补虚拟机的字节码和 CPU 的机器码之间的宏观差异呢?答案是有的,那就是 C 代码可以被编译成一种称之为扩展模块的特定类型的动态库,并且这些模块必须是成熟的 Python 模块,但是里面的代码已经是经由标准 C 编译器编译成的机器代码。那么 Python 在导入扩展模块执行的时候,虚拟机不会再解释高级字节码,而是直接运行机器代码,这样就能移除解释器的性能开销。

这里我们提一下扩展模块,我们说 Windows 中存在dll(动态链接库)、Linux中存在 so(共享文件)。如果只是 C 或者 C++、甚至是是 Go 等等编写的普通源文件,然后编译成 dll 或者 so,那么这两者可以通过 ctypes 调用,但是无法通过 import 导入。如果你强行导入,那么会报错:

ImportError: dynamic module does not define module export function

但是如果是遵循 Python/C API 编写,然后使用 Python 编译成扩展模块的话,尽管该扩展模块在 Linux 上也是 .so、Windows 上是 pyd(pyd也是个dll),但它们是可以直接被 Python 解释器识别被导入的。

那么 Cython 是怎么插上一脚的呢?正如我们在上一篇博客说的那样,我们可以使用 cython 编译器和标准 C 编译器将 Cython 源代码翻译成依赖特定平台的扩展模块,这样 Python 在执行扩展模块的时候,就等同于运行机器码,省去了翻译的过程。

那么将一个普通的 Python 代码编译成扩展模块的话(我们说Cython是Python的超集,即使是纯Python也是可以编译成扩展模块的),效率上可以有多大的提升呢?根据 Python 代码所做的事情,这个差异会非常广泛,但是通常将 Python 代码转换成等效的扩展模块的话,效率大概有10%到30%的提升。因为一般情况下,代码既有 IO 密集也会有 CPU 密集。

所以即便没有任何的 Cython 代码,纯 Python 在编译成扩展模块之后也会有性能的提升。并且如果是纯计算型,那么效率会更高。

Cython 给了我们免费加速的便利,让我们在不写 Cython、或者只写纯Python的情况下,还能得到优化。但这种只针对纯 Python 进行的优化显然只是扩展模块的冰山一角,真正的性能改进是使用 Cython 的静态类型来替换 Python 的动态解析。

因为我们说 Python 不会进行基于类型的优化,所以即使编译成扩展模块,但如果类型不确定,还是没有办法达到高效率的。

就拿两个变量相加举例:我们说 Python 不会做基于类型方面的优化,所以这一行代码对应的机器码数量显然会很多,即使编译成了扩展模块之后,其对应的机器码数量也是类似的(内部会有优化,因此机器码数量可能会少一些,但不会少太多)。这两者区别就是:Python 是有一个翻译的过程,将字节码翻译成机器码;而扩展模块是已经直接就帮你全部都翻译成机器码了。但是CPU执行的时候,由于机器码数量是差不多的,因此执行时间也是差不多的,区别就是少了一个翻译的过程。但是很明显,Python 将字节码翻译成机器码花费的时间几乎是不需要考虑的,重点是在 CPU 执行机器码所花费的时间。

因此将纯 Python 代码编译成扩展模块,速度不会提升太明显,提升的 10~30% 也是 cython 编译器内部的优化,比如:发现函数中某个对象在函数结束就不被使用了,所以将其分配的栈上等等。如果使用 Cython 时指定了类型,那么类型确定的话机器码的数量就会大幅度减少。CPU执行10条机器码花的时间和执行1条机器码花的时间那个长,不言而喻。

因此使用 Cython,重点是规定好类型,一旦类型确定,那么速度会快很多。

动态类型 VS 静态类型

Python 语言和 C、C++ 之间的另一个重要的差异就是:前者是动态类型,后者是静态类型。静态类型语言要求在编译的时候就必须指定变量的类型,我们经常会通过显式的声明一个变量来完成这一点,或者在某些情况下编译器会自动推断变量的类型。另一方面,如果一旦声明某个变量,那么之后此作用域该中变量的类型就不可以再改变了。

看起来限制还蛮多的,那么静态类型可以带来什么好处呢?除了编译时的类型检测,编译器也可以根据静态类型生成适应相应平台的高性能机器码。

动态语言(针对于 Python)则不一样了,对于动态语言来说,类型不是和变量绑定的,而是和对象绑定的,变量只是一个指向对象的指针罢了。因此 Python 中如果想创建一个变量,那么必须在创建的同时赋上值,不然 Python 不知道这个变量到底指向哪一个对象。而像 C 这种静态语言,可以创建一个变量的同时不赋上初始值,比如:int n,因为已经知道 n 是一个 int 类型了,而且分配的空间大小已经确定了。

并且对于动态语言来说,变量即使在同一个作用域中也可以指向任意的对象。并且我们说 Python 中的变量是一个指针,比如:a = 666,相当于创建了一个整型 666,然后让 a 这个变量指向它;如果再来一个 a = "古明地觉",那么会再创建一个字符串,然后让 a 指向这个字符串,或者说 a 不再存储整型 666 的地址,而是存储新创建的字符串的地址。

所以在运行 Python 程序时,解释器要花费很多时间来确认要执行的低阶操作,并抽取相应的数据。考虑到 Python 设计的灵活性,解释器总是要一种非常通用的方式来确定相应的低阶操作,因为 Python 中的变量在任意时刻可以有任意类型。以上便是所谓的动态解析,而 Python 的通用动态解析是缓慢的。还是以 a + b 为栗:

  • 1. 解释器要检测 a 引用的对象的类型,这在C一级至少需要一次指针查找。
  • 2. 解释器从该类型中寻找加法方法的实现,这可能一个或者多个额外的指针查找和内部函数调用。
  • 3. 如果解释器找到了相应的方法,那么解释器就有了一个实际的函数调用。
  • 4. 解释器会调用这个加法函数,并将 a 和 b 作为参数传递进去。
  • 5. 我们说 Python 中的对象在C中都是一个结构体,比如:整型在 C 中是 PyLongObject,内部有引用计数、类型、ob_size、ob_digit,这些成员是什么不必关心,总之其中一个成员肯定是存放具体的值的,其他成员是存储额外的属性的。而加法函数显然要从这两个结构体中筛选出实际的数据,显然这需要指针查找以及将数据从 Python 类型转换到 C 类型。如果成功,那么会执行加法的实际操作;如果不成功,比如类型不对,发现 a 是整型但 b 是个字符串,就会报错。
  • 6. 执行完加法操作之后,必须将结果再转回 Python 中的对象,然后获取它的指针、转成 PyObject * 之后才能够返回。

而 C 语言面对 a + b 这种情况,表现则是不同的。因为 C 是静态编译型语言,C 编译器在编译的时候就决定了执行的低阶操作和要传递的参数数据。在运行时,一个编译好的 C 程序几乎跳过了 Python 解释器要必须执行的所有步骤。对于 a + b,编译器提前就确定好了类型,比如整型,那么编译器生成的机器码指令是寥寥可数的:将数据加载至寄存器,相加,存储结果。

所以我们看到编译后的 C 程序几乎将所有的时间都只花在了调用快速的 C 函数以及执行基本操作上,没有 Python 的那些花里胡哨的动作。并且由于静态语言对变量类型的限制,编译器会生成更快速、更专业的指令,这些指令是为其数据量身打造的。因此某些操作,使用 C 语言可以比使用 Python 快上几百倍甚至几千倍,这简直再正常不过了。

因此 Cython 在性能上可以带来如此巨大的提升的原因就在于,它将静态类型引入 Python 中,静态类型将 运行时的动态解析 转化成 基于类型优化的机器码。

另外,在 Cython 之前我们只能通过在 C 中重新实现 Python 代码来从静态类型中获益,也就是用 C 来编写所谓的扩展模块。而 Cython 可以让我们很容易地写类似于 Python 代码的同时,还能使用 C 的静态类型系统。而我们下面将要学习的第一个、也是最重要的 Cython 关键字:cdef,它是我们通往 C 性能的大门。

通过 cdef 进行静态类型声明

首先 Python 中声明变量的方式在 Cython 中也是可以使用的,因为 Python 代码也是合法的 Cython 代码。

a = [x for x in range(12)]
b = a
a[3] = 42.0
assert b[3] == 42.0
a = "xxx"
assert isinstance(b, list)

在 Cython 中,没有类型化的动态变量的行为和 Python 完全相同,通过赋值语句 b = a 允许 b 和 a 都指向同一个列表。在 a[3] = 42.0 之后,b[3] = 42.0 也是成立的,因此断言成立。即便后面将 a 修改了,也只是让 a 指向了新的对象,调整相应的引用计数,而对 b 而言则没有受到丝毫影响,因此 b 指向的依旧是一个列表。这是完全合法、并且有效的 Python 代码。

而对于静态类型变量,我们在 Cython 中通过 cdef 关键字并指定类型、变量名的方式进行声明。比如:

cdef int i
cdef int j
cdef float k
# 我们看到就像使用 Python 和 C 的混合体一样
j = 0
i = j
k = 12.0
j = 2 * k
assert i != j

上面除了变量的声明之外,其它的使用方式和 Python 并无二致,当然简单的赋值的话基本上所有语言都是类似的。但是 Python 的一些内置的函数、类、关键字等等都是可以直接使用的,因为我们在 Cython 中是可以直接写 Python 代码的,它是 Python 的超集。

但是有一点需要注意:我们上面创建的变量 i、j、k 是 C 中的类型(int、float 比较特殊,后面会解释),其意义最终是要遵循 C 的标准的。

不仅如此,就连使用 cdef 声明变量的方式也是按照 C 的标准来的。

cdef int i, j, k
cdef float x, y

# 或者
cdef int a = 1, b = 2
cdef float c = 3.0, b = 4.1

而在函数内部,cdef 也是要进行缩进的,它们声明的变量也是一个局部变量。

def foo():
    # 这里的 cdef 是缩进在函数内部的
    cdef int i
    cdef int N = 2000
    cdef float a, b = 2.1

并且 cdef 还可以使用类似于 Python 中上下文的方式。

def foo():
    # 这种声明方式也是可以的, 和上面的方式是完全等价的
    cdef:
        int i
        int N = 2000
        float a, b = 2.1
    # 但是注意声明的变量要注意缩进
    # Python 对缩进是有讲究的, 它规定了 Python 中的作用域
    # 所以我们看到 Cython 在语法方面还是保留了 Python 的风格

关于静态和常量

如果你了解 C 的话,那么思考一下:如果想在函数中返回一个局部变量的指针并且外部在接收这个指针之后,还能访问指针指向的值,这个时候该怎么办呢?我们知道 C 函数中的变量是分配在栈上的(不使用 malloc 函数,而是直接创建一个变量),函数结束之后变量对应的值就被销毁了,所以这个时候即使返回一个指针也是无意义的。尽管比较低级的编译器检测不出来,你在返回指针之后还是能够访问指向的内存,但是这只是你当前使用的编辑器比较笨,它检测不出来。如果是高级一点的编译器,那么你在访问的时候会报出段错误或者打印出一个错误的值;而更高级的编译器甚至连指针都不让你返回了,因为指针指向的内存已经被回收了,你要这个指针做什么?因此指针都不让你返回了。

而如果想做到这一点,那么只需要在声明变量的同时在前面加上 static 关键字,比如 static int i,这样的话 i 这个变量就不会被分配到栈区,而是会被分配到文字常量区,它的声明周期就不会随着函数的结束而结束,而是伴随着整个程序。

但是 static 并不是一个有效的 Cython 关键字,因此我们无法在 Cython 声明一个 C 的静态变量。除了 static,在C中还有一个 const,用来声明一个不可变的变量,也就是常量,一旦使用 const声明,比如 const int i = 3,那么这个 i 在后续就不可以被修改了。而在Cython中,const 是支持的,但是目前我们不需要关心,后续系列会介绍。

注意:以上的 int、float 都是 C 中的类型,不是 Python 中的类型(后面会详细说),但除了 int、float 之外,我们还能在 Cython 中声明哪些 C 的类型呢?

首先基础类型,像 short、int、long、unsigned short、long long、size_t、ssize_t 等等都是支持的,声明变量的方式均为:cdef 类型 变量,可以声明的时候赋初始值,也可以不赋初始值(这些都是 C 中的类型);还有指针、数组、定义类型别名、结构体、共同体、函数指针等等也是支持的,我们后面介绍的时候说。

举个栗子,如果要在 Cython 中定义一个 C 的函数指针要怎么做呢?

cdef int (*signal(int (*f)(int)))(int)

我们看到函数指针的声明和 C 也是一模一样的,只需在开头加上一个 cdef 而已。但是不要慌,我们一般不会定义这种函数指针,直接定义一个 Python 函数不香吗?谁没事定义这玩意儿。

但是问题来了,你知道上面的函数指针指向一个什么函数吗?一点一点分析的话,会发现指向的函数接受一个函数指针作为参数、并返回一个函数指针,而这两个函数指针均指向接收整型、返回整型的函数。

有点绕,可以慢慢分析,不过日常也用不到这样的函数声明。否则就类似于写 C 了,因为我们说写 Cython 的感觉是像 Python 的。

Cython中的自动类型推断

在 Cython 中声明一个静态类型变量,使用 cdef 并不是唯一的方法,Cython 会对函数体中没有进行类型声明的变量自动执行类型推断。比如:for 循环中全部都是整型相加,没有涉及到其它类型的变量,那么 Cython 在自动对变量进行推断的时候会发现这个变量可以被优化为静态类型的变量。

但是一个程序不会那么智能地对于一个动态类型的语言进行全方位的优化,默认情况下,Cython 只有在确认这么做不会改变代码块的语义之后才会进行类型推断。

看一下下面这个简单的函数:

def automatic_inference():
    i = 1
    d = 2.0
    c = 3 + 4j
    r = i * d + c
    return r

在这个例子中,Cython 将字面量 1、3 +4j 以及变量 i、c、r 标记为通用的Python 对象。尽管这些对象的类型和 C 中的类型具有高度的相似性,但是 Cython 会保守地推断整型 i 可能无法代表 C 中的 long(C 中的整数有范围,而 Python没有、可以无限大),因此会将其作为符合 Python 代码语义的 Python 对象。而对于 d = 2.0,则可以自动推断出符合 C 中的 double,因为 Python 中的浮点数对应的值在底层就是使用一个 double 来存储的。所以最终对于用户来讲,变量 d 看似是一个 Python 中的对象,但是 Cython 在执行的时候会讲其视为 C 中的 double 以提高性能。

这就是即使我们写纯 Python,cython 编译器也能进行优化的原因,因为会进行推断。但是很明显,我们不应该让 cython 编译器去推断,而是我们来明确指定对应的类型。

当然我们如果非要 cython 编译器去猜,也是可以的,而且还可以通过 infer_types 编译器指令,在一些可能会改变 Python 代码语义的情况下给 Cython 留有更多的余地来推断一个变量的类型。比如:当两个整型相加时可能导致的结果溢出,因为 Python 中的整型在底层是使用数组来存储的,所以不管多大都可以相加,只要你的内存足够。但是在 C 中不可以,因为 C 中的变量是有明确的类型的,既然是类型,那么空间在一开始就已经确定了。比如 int 使用4个字节,而一旦结果使用4个字节无法表示的时候,就会得到一个意向不到的错误结果。所以如果非要 Cython 来类型推断的话,我们是需要给其留有这样的余地的。

对于一个函数如果启动这样的类型推断的话,我们可以使用 infer_types 的装饰器形式。不过还是那句话,我们应该手动指定类型,而不是让 cython 编译器去推断,因为我们是代码的编写者,类型什么的我们自己最清楚。

cimport cython

@cython.infer_types(True)
def more_inference():
    i = 1
    d = 2.0
    c = 3 + 4j
    r = i * d + c
    return r

这里出现了一个新的关键字叫做 cimport,至于它的含义我们后面会说,目前只需要知道它和 import 关键字一样,是用来导入模块的即可。然后我们通过装饰器 @cython.infer_types(True),启动了相应的类型推断,也就是给 Cython 留有更多的猜测空间。

当 Cython 支持更多的推断的时候,变量 i 被类型化为 C long;d 和之前一样是 double,而 c 和 r 都是复数变量,复数则依旧使用 Python 中的复数类型。但是注意:并不代表启用 infer_types 时,就万事大吉了;我们知道在不指定 infer_types 的时候,Cython 在推断类型的时候显然是采用最最保险的方法、在保证程序正确执行的情况下进行优化,不能因为为了优化而导致程序出现错误,显然正确性和效率之间正确性是第一位的。而整型由于存在溢出的问题,所以 Cython 是不会自动转化为 C long 的;但是我们通过 infer_types 启动了更多的类型推断,因此在不改变语义的情况下 Cython 是会将整型推断为C long的,但是溢出的问题它不知道,所以在这种情况下是需要我们来要负责确保整型不会出现溢出。

Cython 中的 C 指针

正如我们说的,可以使用 C 的语法在 Cython 中声明一个 C 指针。

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

# 我们看到在Cython中没有指定初始值, 所以默认为0
# 比如我们直接 a = int(), 那么 a 也是 0
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 都是静态的整型,Cython 允许使用它们创建动态类型的 Python 元组,然后将该元组分配给 t。所以这个小栗子便体现了 Cython 的美丽和强大之处,可以以显而易见的方式创建一个元组,而无需考虑其它情况。因为 Cython 的目的就在于此,希望概念上简单的事情在实际操作上也很简单。

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

虽说如此,但并不是所有东西都可以这么做的。上面的例子之所以有效,是因为Python int 和 C int(short、long等等)有明显的对应关系。如果是指针呢?首先我们知道 Python 中没有指针这个概念,或者说指针被 Python 隐藏了,只有解释器才能操作指针。因此在 Cython 中,我们不可以在函数中返回一个指针,以及打印一个指针、指针作为 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

我们看到 a 和 b 是 C 中的类型,无法访问,但变量 c 是可以访问的。不过问题又来了,看一下下面的几种情况:

先定义一个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
warning: cython_test.pyx:5:9: cdef variable 'b' declared after it is used
"""
# 即使一个是 Python 的变量, 一个是 C 的变量, 也依旧不可以重名。不然访问 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 中是一个指向某个值的指针, 而 C 中的变量就是代表值本身
# cdef int a = 666, 相当于创建了一个变量 a, 这个变量 a 代表的就是 666 本身, 而这个 666 是 C 中整数 666
# 而 v = a 相当于先根据 a 的值、也就是 C 中 整数666 创建一个 Python 的整数 666, 然后再让 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,虽然值一样,但是地址不一样。

所以这就是 Cython 的方便之处,不需要我们自己转化,而是在编译的时候会自动转化。当然还是按照我们之前说的,自动转化的前提是可以转化,也就是两者之间要互相对应(比如 Python 的 int 和 C 的 int、long,Python 的 float 和 C 的 float、double 等等)。

而我们说因为 Python int 和 C/C++ int 之间是对应的,所以 Python 会自动转化,那么其它类型呢?Python 类型和 C/C++ 类型之间的对应关系都有哪些呢?

注意:对于这些 C 的类型,Cython 有更丰富的类型来表示。

bint 类型

bint 在 C 中是一个布尔类型,但其实本质上是一个整型,然后会自动转化为 Python 的布尔类型,当然 Python 中布尔类型也是继承自整型。bint 类型有着标准 C 的实现:0为假,非0为真。

cdef bint flag1 = 123  # 非0是True
cdef bint flag2 = 0  # 0是False
a = flag1
b = flag2

这里我们要进行赋值给 Python 中的变量,不然后续无法访问。

import pyximport
pyximport.install(language_level=3)

import cython_test
print(cython_test.a)  # True
print(cython_test.b)  # False

整数类型与转换溢出

在 Python2 中,有 int 和 long 两种类型来表示整数。Python2 中的 int 使用 C 中的 long 来存储,是有范围的,而 Python2 中的 long 是没有范围的;但在Python3中,只有int,没有long,而所有的 int 对象都是没有范围的。

将 Python 中的整型转化成 C 中的整型时,Cython 生成代码会检测是否存在溢出。如果 C 中的 long 无法表示 Python 中的整型,那么运行时会抛出 OverflowError。

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

cdef int j = i
import pyximport
pyximport.install(language_level=3)

import cython_test

"""
  ...
  ... 
  File "cython_test.pyx", line 3, in init cython_test
    cdef int j = i
ImportError: Building module cython_test failed: ['OverflowError: Python int too large to convert to C long\n']
"""

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

float类型

Python 中的 float 对应的值在 C 中也是用 double 来存储的,对于浮点来说可以放心使用。

typedef struct {
    PyObject_HEAD
    double ob_fval;
} PyFloatObject;
// Python 中的对象在底层都是一个结构体, float 对象则是一个 PyFloatObject
// 而 PyObject_HEAD 是一些额外信息:引用计数、指向对应类型的指针
// 而是 ob_fval 则是真正存放具体的值的, 显然这是一个double

复数类型

Python 中的复数在 C 中是使用两个 double 来存储的,一个存储实部、一个存储虚部。

typedef struct {
    double real;
    double imag;
} Py_complex;

typedef struct {
    PyObject_HEAD
    Py_complex cval;
} PyComplexObject;

复数不常用,了解一下即可。

bytes类型、str类型

在 Cython 中我们如果想创建一个字节串可以使用 bytes,而创建一个字符串则是 str 或者 unicode。没错,这些都是 Python 中的类型,关于 C 类型和 Python 类型在 Cython 中的表现我们后面会详细说。

# 创建一个字节串使用 bytes
cdef bytes name = "古明地觉".encode("utf-8")
# 创建一个字符串可以使用 str, 和 Python 一样
cdef str where1 = "东方地灵殿"
# 也可以使用 unicode, 但是字符串要有前缀u,两种方式在 Python3 是等价的, 因此建议使用 str
# 之所以会有 unicode 是为了兼容 Python2
cdef unicode where2 = u"东方地灵殿"

NAME = name
WHERE1 = where1
WHERE2 = where2
import pyximport
pyximport.install(language_level=3)

import cython_test
print(cython_test.NAME.decode("utf-8"))  # 古明地觉
print(cython_test.WHERE1)  # 东方地灵殿
print(cython_test.WHERE2)  # 东方地灵殿
print(cython_test.NAME.decode("utf-8")[2])  # 地
print(cython_test.WHERE1[: 2] + cython_test.WHERE2[2:])  # 东方地灵殿

当然还有很多很多类型,别着急,我们在后续会慢慢介绍。

使用 Python 类型进行静态声明

我们之前使用 cdef 的时候用的都是 C 中的类型,比如 cdef int、cdef float,当然 Python 中也有这两个,不过我们使用的确实是 C 中的类型,再或者 cdef unsigned long long 等等也是可以的。那么可不可以使用 Python 中的类型进行静态声明呢,其实细心的话会发现是可以的,因为我们上面使用了 cdef str 声明变量了。

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

# 声明的时候直接初始化
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 *,它们可以像普通变量一样使用,当然 Python 中的变量也是一样的,a = tuple(),那么 a 同样是一个 PyTupleObject *。

同理我们想一下 C 扩展,我们使用 Python/C API 编写扩展模块的时候,也是一样的道理,只不过还是那句话,使用 C 来编写扩展非常的麻烦,因为用 C 来开发本身就是麻烦的事情。所以 Cython 帮我们很好的解决了这一点,让我们可以将写 Python 一样写扩展,会自动地将我们的代码翻译成C级的代码。因此从这个角度上讲,Cython 可以做的,使用纯 C 来编写扩展也是完全可以做的,区别就是一个简单方便,一个麻烦。更何况使用 C 编写扩展,需要掌握Python/C API,而且还需要有 Python 解释器方面的知识,门槛还是比较高的,可能一开始掌握套路了还不觉得有什么,但是到后面当你使用 C 来实现一个 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 创建的变量无法被直接访问,需要将其赋值给 Python 中的变量才可以使用。那么,在赋完值的时候,这两个变量指向的是同一个对象吗?

cdef list a = list("123")
# a是一个PyListObject *, 然后b也是一个PyListObject *
# 但是这两位老铁是不是指向同一个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。毕竟两个变量指向的是同一个列表、或者 PyListObject 结构体实例,当然我们使用 del 删除一个元素也是同理。

我们说 Cython 中的变量和 Python 中的变量是等价的,那么 Python 中变量可以使用的 api,Cython 中的变量都可以使用,比如 a.insert、a.append 等等。只不过对于 int 和 float 来说,C 中也存在同名的类型,那么会优先使用 C 的类型,这也是我们期望的结果。

而且一旦使用的是 C 里面的类型,比如 cdef int = 1;cdef float b = 22.33,那么 a 和 b 就不再是 PyLongObject * 和 PyFloatObject * 了,因为它们用的不是 Python 中的类型,而是 C 中的类型。所以 a 和 b 的类型就是 C 中实打实的 int 和 float,并且 a 和 b 也不再是一个指针,它们代表的就是具体的整数和浮点数。

为什么要在使用 int 和 float 的时候,要选择 C 中 int 和 float 呢?答案很好理解,因为 Cython 本身就是用来加速计算的,而提到计算,显然避不开 int 和 float,因此这两位老铁默认使用的 C 里面类型。事实上单就 Python 中的整型和浮点来说,在运算时底层也是先转化成 C 的类型,然后再操作,最后将操作完的结果再转回 Python 中的类型。而如果默认就使用C的类型,就少了转换这一步了,可以极大提高效率。

然而即便是 C 中的整型和浮点型,在操作的时候和 C 还是有一些不同的,主要就在于除法和取模。什么意思呢,我们往下看。

当我们操作的是 Python 的 int 时,那么结果是不会溢出的;如果操作的是静态的 C 对象,那么整型可能存在溢出,这些我们是知道的。但是除此之外,还有一个最重要的区别就是除法和取模,在除法和取模上,C 的类型使用的却不是 C 的标准。举个栗子:

当使用有符号整数计算模的时候,C 和 Python 有着明显不同的行为:比如 -7 % 5,如果是 Python 的话那么结果为 3,C 的话结果为 -2。显然 C 的结果是符合我们正常人思维的,但是为什么 Python 得到的结果这么怪异呢?

事实上不光是 C,Go、Js 也是如此,计算 -7 % 5 的结果都是-2,但 Python 得到 3 主要是因为其内部的机制不同。我们知道 a % b,等于 a - (a / b) * b,其中 a / b 表示两者的商。比如 7 % 2,等于 7 - (7 / 2) * 2 = 7 - 3 * 2 = 1,对于正数,显然以上所有语言计算的结果都是一样的。

而负数出现差异的原因就在于:C 在计算 a / b 的时候是截断小数点,而 Python 是向下取整。比如上面的 -7 % 5,等于 -7 - (-7 / 5) * 5。-7 / 5 得到的结果是负的一点多,C的话直接截断得到 -1,因此结果是 -7 - (-1) * 5 = -2;但 Python 是向下取整,负的一点多变成 -2,因此结果变成了 -7 - (-2) * 5 = 3。

# Python中 / 默认是得到浮点, 整除的话使用 //
# 我们看到得到的是 -2
print(-7 // 5)  # -2

因此在除法和取模方面,尤其需要注意。另外即使在Cython中,也是一样的。

cdef int a = -7
cdef int b = 5
cdef int c1 = a / b
cdef int c2 = a // b
print(c1)
print(c2)
print(-7 // 5)

以上打印的结果都是 -2,说明 Cython 默认使用 Python 的语义进行除法,当然还有取模,即使操作的对象是静态类型的 C 标量。这么做原因就在于为了最大程度的和 Python 保持一致,如果想要启动 C 语义都需要显式地进行开启。然后我们看到 a 和 b 是静态类型的 C 变量,它们也是可以使用 // 的,因为 Cython 的目的就像写Python一样。但是我们看到无论是 a / b 还是 a // b 得到的都是 -2,这很好理解。因为在 Cython 中 a 和 b 都是静态的 int,而在C中对两个 int 使用加减乘除得到的依旧是一个 int,因此会将中间得到的浮点数变成整型,至于是直接截断还是向下取整则是和 Python 保持一致的,是按照 Python 的标准来的。至于 a // b 对于整型来说就更不用说了,a // b 本身就表示整除,因此在 Cython 中两个 int 之间使用 / 和使用 // 是一样的。然后我们再来举个浮点数的例子。

cdef float a = -7
cdef float b = 5
cdef float c1 = a / b
cdef float c2 = a // b
print(c1)
print(c2)
import cython_test
"""
-1.399999976158142
-2.0
"""

此时的 a 和 b 都是浮点数,那么 a / b 也是个浮点,所以没有必要截断了,小数位会保留;而 a // b虽然得到的也是浮点(只要 a 和 b 中有一个是浮点,那么 a / b 和 a // b 得到的也是浮点),但它依旧具备整除的意义,所以 a // b 得到结果是 -2.0,然后赋值给一个 float 变量,还是 -2.0。不过为什么 a // b 得到的是 -2.0,可能有人不是很明白,因此关于 Python 中 / 和 // 在不同操作数之间的差异,我们再举个栗子看一下:

7 / 2 == 3.5  # 3.5, 很好理解
7 // 2 == 3  # // 表示整除, 因此 3.5 会向下取整, 得到 3
-7 / 2 == -3.5  # -3.5, 很好理解
-7 // -2 = -4  # // 表示取整, 因此 -3.5 会向下取整, 得到 -4

7.0 / 2 == 3.5  # 3.5, 依旧没问题
7.0 // 2 == 3.0  # //两边出现了浮点, 结果也是浮点; 但 // 又是整除, 所以你可以简单认为是先取整(得到 3), 然后变成浮点(得到3.0)
-7.0 / 2 == -3.5  # -3.5, 依旧很简单
-7.0 // 2 == -7.8 // 2 == -4.0  # -3.5 和 -3.9 都会向下取整, 然后得到-4, 但结果是浮点, 所以是-4.0

-7.0 / -2 == 3.5  # 3.5, 没问题
-7.0 // -2 == 3  # 3.5向下取整, 得到3

所以 Python 的整除或者说地板除还是比较奇葩的,主要原因就在于其它语言是截断(小数点后面直接不要了),而 Python 是向下取整。如果是结果为正数的话,截断和向下取整是等价的,所以此时基本所有语言都是一样的;而结果为负数的话,那么截断和向下取整就不同了,因为 -3.14 截断得到的是 -3、但向下取整得到的不是 -3,而是 -4。因此这一点务必要记住,算是 Python 的一个坑吧。话说如果没记错的话,好像只有 Python 采用了向下取整这种方式,别的语言(至少C、js、Go)都是截断的方式。

还有一个问题,那就是整型和浮点型之间可不可以相互赋值呢?先说结论:

  • 整型赋值给浮点型是可以的
  • 但是浮点型赋值给整型不可以
# 7是一个纯数字, 那么它既可以在赋值 int 类型变量时表示整数7
# 也可以在赋值给 float 类型变量时表示 7.0
cdef int a = 7
cdef float b = 7

# 但如果是下面这种形式, 虽然也是可以的, 但是会弹出警告
cdef float c = a
# 提示: '=': conversion from 'int' to 'float', possible loss of data
# 因为 a 的值虽然也是 7, 但它已经具有相应的类型了, 就是一个 int, 将 int 赋值给 float 会警告

# 而将浮点型赋值给整型则不行
# 这行代码在编译的时候会报错: Cannot assign type 'double' to 'int'
cdef int d = 7.0

而且我们说,使用 cdef int、cdef float 声明的变量不再是指向 Python 中 int对象、float对象的PyLongObject *、PyFloatObject *,其类型就是 C 中的 int、float。尽管整型没有考虑溢出,但是它在做运算的时候遵循 Python 的规则(主要是除法),那么可不可以让其强制遵循C的规则呢?

cimport cython

# 通过@cython.cdivision(True)进行装饰即可完成这一点
@cython.cdivision(True)
def divides(int a, int b):
    return a / b
import cython_test
print(-7 // 2)  # -4
# 函数参数 a 和 b 都是整型, 相除得到还是整型
# 如果是 Python 语义, 那么在转化的时候会向下取整得到 -4, 但这里是 C 语义, 所以是截断得到 -3
print(cython_test.divides(-7, 2))  # -3

除了这种方式,还可以下面下面两种方式来指定。

1. 通过上下文管理器的方式

cimport cython

def divides(int a, int b):
    with cython.cdivision(True):
        return a / b

2. 通过注释的方式进行全局声明

# cython: cdivision=True

def divides(int a, int b):
    return a / b

如果什么都不指定的话,执行一下看看。

def divides(int a, int b):
    return a / b
import cython_test

print(-7 // 2)  # -4
print(cython_test.divides(-7, 2))  # -4

此时就和Python语义是一样的了。

总结:

  • 使用 cdef int、cdef float 声明的变量不再是 Python 中的 int、float,也不再对应 CPython 中的 PyLongObject * 和PyFloatObject *,而就是 C 中的 int 和 float。
  • 虽然是 C 中的 int 和 float,并且也没有像 Python 一样考虑整型溢出的问题(实际上溢出的情况非常少,如果可能溢出的话,就不要使用 C 中的 int 或者 long,而是使用 Python 的 int),但是在进行运算的时候是遵循 Python 的语义的。因为 Cython 就是为了优化 Python 而生的,因此在各个方面都要和 Python 保持一致。
  • 但是也提供了一些方式,禁用掉 Python 的语义,而是采用 C 的语义。方式就是上面说的那三种,它们专门针对于整除和取模,因为加减乘都是一样的,只有除和取模会有歧义。

不过这里还有一个隐患,因为我们在除法的时候使其遵循 C 的语义,而 C 不会对分母为 0 的情况进行考虑,而 Python 则会进行检测。如果分母为 0,在 Python 中会抛出:ZeroDivisionError,在C中会可能导致未定义的行为(从硬件损坏和数据损害都有可能,好吓人,妈妈我怕)。

Cython 中还有一个 cdivision_warnings,使用方式和 cdivision 完全一样,表示:当取模的时候如果两个操作数中有一个是负数,那么会抛出警告。

cimport cython

@cython.cdivision_warnings(True)
def mod(int a, int b):
    return a % b
import cython_test

# -7 - (2 * -4) == 1
print(cython_test.mod(-7, 2))  
# 提示我们取整操作在 C 和 Python 有着不同的语义, 同理 cython_test.mod(7, -2) 也会警告
"""
RuntimeWarning: division with oppositely signed operands, C and Python semantics differ
  return a % b
1
"""


# -7 - (-2 * 3) = -1
print(cython_test.mod(-7, -2))  # -1

# 但是这里的 cython_test.mod(-7, -2) 却没有弹出警告,这是为什么呢?
# 很好理解,我们说只有商是负数的时候才会存在歧义,但是 -7 除以 -2 得到的商是 3.5,是个正数
# 而正数的表现形式在截断和向下取整中都是一致的,所以不会警告
# 同理 cython_test.mod(7, 2) 一样不会警告

另外这里的警告是同时针对 Python 和 C 的,即使我们再使用一层装饰器 @cython.cdivision(True) 装饰、将其改变为 C 的语义的话,也一样会弹出警告的。个人觉得 cdivision_warnings 意义不是很大,了解一下即可。

用于加速的静态类型

我们上面介绍了在 Cython 中使用 Python 的类型进行声明,这咋一看有点古怪,为什么不直接使用 Python 的方式创建变量呢?a = (1, 2, 3) 不香么?为什么非要使用 cdef tuple a = (1, 2, 3) 这种形式呢?答案是 "为了遵循一个通用的 Cython 原则":我们提供的静态信息越多,Cython 就越能优化结果。所以 a = (1, 2, 3),这个 a 可以指向任意的对象,但是 cdef tuple a = (1, 2, 3) 的话,这个 a 只能指向元组,在明确了类型的时候,执行的速度会更快。

看一个列表的例子:

lst = []
lst.append(1)

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

1. 检测类型,我们说 Python 中变量是一个 PyObject *,因为任何对象在底层都嵌套了 PyObject 这个结构体,但具体是什么类型则需要一步检索才知道。通过 PyTypeObject *type = lst -> ob_type,拿到其类型。

2. 转化类型,PyListObject *lst = (PyListObject *)lst

3. 查找属性,我们调用的是 append 方法,因此调用 PyObject_GetAttrString,参数就是字符串 "append",找到指向该方法的指针。如果不是 list,但是内部如果有 append 方法也是可以的,然后进行调用。

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

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

那么此时会有什么差别呢?我们对 list 对象进行 append 的时候底层调用的 C 一级的函数是 PyList_Append,通过索引赋值的时候调用的是 PyList_SetItem,索引取值的时候调用的是 PyList_GetItem,等等等等。每一个操作在 C 一级都指向了一个具体的函数,如果我们提前知道了类型,那么 Cython 生成的代码可以将上面的三步变成一步,没错,直接通过 C api 让 lst.append 指向 PyList_Append 这个 C 一级的函数,这样省去了类型检测、转换、属性查找等步骤,直接调用调用即可。

所以列表解析比普通的 for 循环快也是如此,因为 Python 对内置的结构非常熟悉,当我们使用的是列表解析式,那么也同样会直接指向 PyList_Append 这个 C 一级的函数。

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

引用计数和静态字符串类型

我们知道 Python 会自动管理内存的,解释器 CPython 通过直接的引用计数来判断一个对象是否应该被回收,但是无法解决循环引用,于是 Python 中又提供了垃圾回收来解决这一点。

这里多提一句 Python 中的 gc,我们知道 Python 判断一个对象回收的标准就是它的引用计数是否为 0,为 0 就被回收。但是这样无法解决循环引用,于是 Python 中的 gc 就是来解决这个问题的。那么它是怎么解决的呢?

首先什么样的对象会发生循环引用呢?不用说,显然是可变对象,比如:列表、类的实例对象等等,像 int、str 这些不可变对象肯定是不会发生循环引用的,单纯的引用计数足以解决。

而对于可变对象,Python 会通过分代技术,维护三个链表:零代链表、一代链表、二代链表。将那些可变对象移到链表上,然后通过三色标记模型找到那些发生循环引用的对象,将它们的引用计数减一,从而解决循环引用的问题。不过有人好奇了,为什么是三个链表,一个不行吗?事实上,Python 检测循环引用、或者触发一次 gc 还是要花费一些代价的,对于某些经过 gc 的洗礼之后还活着的对象,我们认为它们是比较稳定的,不应该每次触发 gc 就对它们进行检测。所以 Python 会把零代链表中比较稳定的对象移动到一代链表中,同理一代链表也是如此,不过最多就是二代链表,没有三代链表。当清理零代链表的次数达到 10 次的时候,会清理一次一代链表;清理一代链表达到 10 次的时候,会清理一次二代链表。

而 Cython 也为我们处理所有的引用计数问题,确保 Python 对象(无论是Cython动态声明、还是Python动态声明)在引用计数为 0 时被销毁。

很好理解,就是内存管理的问题 Cython 也会负责的。其实不用想也大概能猜到 Cython 会这么做,毕竟 cdef tuple a = (1, 2, 3) 和 a = (1, 2, 3) 底层都对应 PyTupleObject *,只不过后者在操作的时候需要先通过 PyObject * 获取类型 (PyTupleObject *) 再转化罢了,而前者则省略了这一步。但它们底层都是 CPython 中的结构体,所以内存都由解释器管理。还是那句话,Cython 代码是要被翻译成 C 的代码的,在翻译的时候会自动处理内存的问题,当然这点和 Python 也是一样的。

但是当 Cython 中动态变量和静态变量混合时,那么内存管理会有微妙的影响。我们举个栗子:

# char *, 在 Cython 只能接收一个 ascii 字符串, 或者 bytes 对象
# 但下面这行代码是编译不过去的
cdef char *name = "古明地觉".encode("utf-8")
# 不是说后面可以跟一个 bytes 对象吗? 
# 但问题是这个 bytes 对象它是一个临时对象, 什么是临时对象呢? 就是创建完了但是没有变量指向它
# 这里的 name 是使用 C 的类型创建的变量, 所以它不会增加这个 bytes 对象的引用计数
# 因此这个 bytes 对象创建出来之后就会被销毁, 编译时会抛出:Storing unsafe C derivative of temporary Python reference
# 告诉我们创建出来的Python对象是临时的


# 这么做是可以的, 并且 "komeiji satori" 会被解释成 C 中的字符串
cdef char *name = "komeiji satori"
# 同理 cdef int a = 123; 这个 123 也是 C 中的整型, 但 cdef char *name = "古明地觉" 则不行, 因为它不是ascii字符串

那么如何解决这一点呢?答案是使用变量保存起来就可以了。

# 这种做法是完全合法的, 因为我们这个 bytes 对象是被 name1 指向了
name1 = "古明地觉".encode("utf-8")
cdef char *buf1 = name1

# 然鹅这么做是不行的, 编译是通过的, 但是执行的时候会报错:TypeError: expected bytes, str found
name2 = "komeiji satori"
cdef char *buf2 = name2
# 可能有人觉得, cdef char *buf2 = "komeiji satori"就可以, 为什么赋值给一个变量就不行了
# 因此 char * 它需要接收的是 C 中的字符串, 或者 Python 中的 bytes, 而我们赋值一个变量的时候它就已经是 Python 中的字符串了

因此关于 char * 来总结一下:

  • cdef char *buf = "satori".encode("utf-8") 理论上是合理的,但是由于这个对象创建完之后就被销毁,所以不行。这个是在编译的时候就会被检测到,因为这属于内存方面的问题。
  • cdef char *buf = "satori" 是可以的,因为此时 "satori" 会被解释成 C 中的字符串。
  • name = "古明地觉".encode("utf-8"); cdef char *buf = name 也可以的,因为 name 指向了字节对象,所以不会被销毁,能够提取它的 char 指针。
  • name = "satori"; cdef char *buf = name 则不行,原因在于我们将 "satori" 赋值给了 name,那么这个 name 显然就是 Python 中的字符串,而我们不可以将 Python 中的字符串赋值给 C 中的 char *,只能赋字节串,因此会报错。但该错误是属于赋值出错了,因此它是一个运行时错误,所以编译成扩展模块的时候是可以正常通过的。

不过还是那句话,只有当直接给 char * 变量赋一个 ascii 字符串的时候,才会被当成是 C 中的字符串,如果赋了非 ascii 字符串、或者是 ascii 字符串用变量接收了并且赋的是变量,那么也是不合法的。因此建议,字符串直接使用 str 即可,没有必要使用 char *。

那么下面的代码有没有问题呢?如果有问题该怎么改呢?

word1 = "hello".encode("utf-8")
word2 = "satori".encode("utf-8")

cdef char *word = word1 + word2

会不会出问题呢?显然会有大问题,尽管 word1 和 word2 指向了相应的 bytes 对象,但是 word1 + word2 则是会创建一个新的 bytes 对象,这个新的 bytes 对象可没有人指向。因此提取其 char * 之后也没用,因为这个新创建的 bytes 对象会被直接销毁。

而解决的办法有两种:

  • tmp = word1 + word2; cdef char *word = tmp,使用一个动态的方式创建一个变量指向它,确保它不会被销毁。
  • cdef bytes tmp = word1 + word2; cdef char *word = tmp,道理一样,只不过使用的是静态声明的方式。

另外,其实像上面这种情况并不常见,基本上只有 char * 会有这个问题,因为它比较特殊,底层使用一个指针来表示字符串。和 int、long 不同,cdef long a = 123,这个 123 直接就是 C 中的 long,我们可以直接使用;但将 Python 中的 bytes 对象赋值给 char *,在 C 的级别 char * 所引用的数据还是由 CPython 进行管理的,char * 缓冲区无法告诉解释器还有一个对象(非 Python 对象)引用它,这就导致了它的引用计数不会加1,而是创建完之后就会被销毁。

所以我们需要提前使用 Python 中的变量将其保存起来,这样就不会删除了。而我们说只有char *会面临这个问题,而其它的则无需担心。但是我们完全可以不使用 char *,使用 str 和 bytes 难道不好吗?

Cython的函数

我们上面所学的关于动态变量和静态变量的知识也适用于函数,Python 的函数和 C 的函数都有一些共同的属性:函数名称、接收参数、返回值,但是 Python 中的函数更加的灵活这强大。因为 Python 中一切皆对象,所以函数也是一等公民,可以随意赋值、并具有相应的状态和行为,这种抽象是非常有用的。

一个Python函数可以:

  • 在导入时和运行时动态创建
  • 使用 lambda 关键字匿名创建
  • 在另一个函数(或其它嵌套范围)中定义
  • 从其它函数中返回
  • 作为一个参数传递给其它函数
  • 使用位置参数和关键字参数调用
  • 函数参数可以使用默认值

C函数调用开销最小,比Python函数快几个数量级。一个C函数可以:

  • 可以作为一个参数传递给其它函数,但这样做比 Python 麻烦的多
  • 不能在其它函数内部定义,而这在 Python 中不仅可以、而且还非常常见,毕竟 Python 中常用的装饰器就是通过高阶函数加上闭包实现的,而闭包则可以理解为是函数的内部嵌套其它函数。
  • 具有不可修改的静态分配名称
  • 只能接受位置参数
  • 函数参数不支持默认值

正所谓鱼和熊掌不可兼得,Python 的函数调用虽然慢几个数量级(即使没有参数),但是它的灵活性和可扩展性都比 C 强大很多,这是以效率为代价换来的。而 C 的效率虽然高,但是灵活性没有 Python 好,这便是各自的优缺点。

那么说完 Python 函数和 C 函数各自的优缺点之后该说啥啦,对啦,肯定是 Cython 如何将它们组合起来、吸取精华剔除糟粕的啦,阿sir。

在 Cython 中使用 def 关键字定义 Python 函数

Cython 支持使用 def 关键字定义一个通用的 Python 函数,并且还可以按照我们预期的那样工作。比如:

def rec(n):
    if n == 1:
        return 1
    return n * rec(n - 1)
import pyximport
pyximport.install(language_level=3)

import cython_test

print(cython_test.rec(20))  # 2432902008176640000

显然这是一个 Python 语法的函数,参数 n 是接收一个动态的 Python 变量,但它在 Cython 中也是合法的,并且表现形式是一样的。

我们知道即使是普通的 Python 函数,我们也可以通过 cython 进行编译,但是就调用而言,这两者是没有任何区别的。不过我们说执行扩展里面的代码时,已经绕过了解释器解释字节码这一过程;但是 Python 代码则不一样,它是需要被解释执行的,因此在运行期间可以随便动态修改内部的属性。我们举个栗子就很清晰了:

Python 版本:

# 文件名:a.py
def foo():
    return 123

# 另一个文件
from a import foo

print(foo())  # 123

print(foo.__name__)  # foo
foo.__name__ = "哈哈"
print(foo.__name__)  # 哈哈

Cython 版本:

def foo():
    return 123
import pyximport
pyximport.install(language_level=3)


from cython_test import foo
print(foo())  # 123
print(foo.__name__)  # foo

foo.__name__ = "哈哈"
"""
   ...
   ...
    foo.__name__ = "哈哈"
AttributeError: attribute '__name__' of 'builtin_function_or_method' objects is not writable
"""

我们看到报错了:'builtin_function_or_method' 的属性 '__name__' 不可写,因为 Python 中的函数是一个类型函数,它是通过解释器的,所以它可以修改自身的一些属性。但是 Cython 代码在编译之后,变成了 builtin_function_or_method,绕过了解释这一步,因为不能对它自身的属性进行修改。事实上,Python 的一些内置函数也是不能修改的。

try:
    getattr.__name__ = "xxx"
except Exception as e:
    print(e)  # attribute '__name__' of 'builtin_function_or_method' objects is not writable

这些内置的函数直接指向了底层 C 一级的函数,因此它们的属性是不能够被修改的。

回到刚才的用递归计算阶乘的例子上来,显然 rec 函数里面的 n 是一个动态变量,如果想要加快速度,就要使用静态变量,也就是规定好类型。

def rec(long n):
    if n == 1:
        return 1
    return n * rec(n - 1)

此时当我们传递的时候,会将值转成 C 中的 long,如果无法转换则会抛出异常。

另外在 Cython 中定义任何函数,我们都可以将动态类型的参数和静态类型的参数混合使用。 Cython 允许静态参数具有默认值,并且可以按照位置参数或者关键字参数的方式传递。

# 这样的话, 我们就可以不传参了, 默认 n 是 20
def rec(long n=20):
    if n == 1:
        return 1
    return n * rec(n - 1)

但是遗憾的是,即便我们使用了 long n 这种形式定义参数,效率也不会有提升。因为这里的 rec 还是一个 Python 函数,它的返回值也是一个 Python 中的整型,而不是静态的 C long。因此在计算 n * rec(n - 1) 的时候,Cython 必须生成大量代码,从返回的 Python 整数中提取底层的 C long,然后乘上静态类型的变量 n,最后再将结果得到的 C long 打包成 Python 的整型。所以整个过程基本上是没什么变化的。

那么如何才能提升性能呢?显然这明显可以不使用递归而是使用循环的方式,当然这个我们不谈,因为这个 Cython 没啥关系。我们想做的是告诉Cython:"这是一个 C long,你要在不创建任何 Python 整型的情况下计算它,我会将你最终计算好的结果包装成 Python 中的整型,总之你计算的时候不需要 Python 整数参与。"

如何完成呢?往下看。

在 Cython 中使用 cdef 关键字定义 C 函数

cdef 关键字除了创建变量之外,还可以创建具有 C 语义的函数。cdef 定义的函数其参数和返回值通常都是静态类型的,它们可以处理 C 指针、结构体、以及其它一些无法自动转换为 Python 类型的 C 类型。所以把 cdef 定义的函数看成是长得像 Python 函数的 C 函数即可。

cdef long rec(long n):
    if n == 1:
        return 1
    return n * rec(n - 1)

我们之前的例子就可以改写成上面这种形式,我们看到结构非常相似,主要区别就是指定了返回值的类型。

但是此时的函数是没有任何 Python 对象参与的,因此不需要从 Python 类型转化成 C 类型。该函数和纯 C 函数一样有效,调用函数的开销最小。另外,即便是 cdef 定义的函数,我们依旧可以创建 Python 对象和动态变量,或者接收它们作为参数也是可以的。但是 cdef 编写的函数应该是在,为了获取 C 的效率、不需要动态变量的情况下编写的。

当然我们在 Cython 源文件中可以使用 cdef 定义函数、也可以是用 def 定义函数,这是显然的。cdef 函数返回的类型可以是任何的静态类型(如:指针、结构体、C数组、静态Python类型)。如果省略了返回值,那么默认是object 比如:cdef f1():等价于cdef object f1(),也就是说此时返回任何对象都是可以的。关于返回值的问题,我们来举个例子。

# 合法, 返回的是一个 list 对象
cdef list f1():
    return []

# 等于cdef object f2(): 而 Python 中任何对象都是 object 对象
cdef f2():
    pass

# 虽然要求返回列表, 但是返回 None 也是可以的(None特殊, 后面会继续说)
cdef list f3():
    pass

# 同样道理
cdef list f4():
    return None

# 这里是会报错的:TypeError: Expected list, got tuple
cdef list f5():
    return 1, 2, 3

使用 cdef 定义的函数,可以被其它的函数(cdef 和 def 都行)调用,但是 Cython 不允许从外部 Python 代码来调用 cdef 函数,我们之前使用 cdef 定义的变量也是如此。因为 Python 中函数也可以看成是变量,所以我们通常会定义一个 Python 中的函数,然后让 Python 中的函数来调用 cdef 定义的函数,所以此时的 Python 函数就类似于一个包装器,用于向外界提供一个访问的接口。

cdef long _rec(long n):
    if n == 1:
        return 1
    return n * rec(n - 1)

def rec(n):
    return _rec(n)

这种方式是最快的,之前的方式都有大量的 Python 开销。

但不幸的时,这种方式有一个弊端,相信肯定都能想到。那就是 C 中的整数类型(int、long等等)都存在精度问题,而 Python 的整型是不受限制的,只要你的内存足够。解决办法就是确保不会溢出,或者将 long 换成double。

这是一个很普遍的问题,基本上所有的语言都是这样子,只有 Python 在表示整型的时候是没有限制的。有些时候,Python 数据和 C 数据并不能总是实现完美的映射,需要意识到 C 的局限性。这也是为什么 Cython 不会擅自把 Python 中的 int 变成 C 中的 int、long,因为这两者在极端情况下不是等价的。但是绝大多数情况下,使用 long 是足够的,甚至都不需要 long,int 也足够,至少我平时很少遇见 long 存不下的数字,或者实在不行就用double嘛。

使用 cpdef 结合 def、cdef

我们在 Cython 定义一个函数可以使用 def 和 cdef,但是还有第三种定义函数的方式,使用 cpdef 关键字声明。cpdef 是 def 和 cdef 的混合体,结合了这两种函数的特性,并解决了局限性。我们之前使用 cdef 定义了一个函数 _rec,但是它没法直接被外部访问,因此又定义了一个 Python 函数 rec 供外部调用,相当于提供了一个接口。所以我们需要定义两个函数,一个是用来执行逻辑的(C版本),另一个是让外部访问的(Python版本,一般这种函数我们称之为 Python 包装器。很形象,C 版本不能被外部访问,因为定义一个 Python 函数将其包起来)。

但是 cpdef 定义的函数会同时具备这两种身份,怎么理解呢?一个 cpdef 定义的函数会自动为我们提供上面那两个函数的功能,它们具备相同的名称。从 Cython 中调用函数时,会调用 C 的版本,在外部的 Python 中导入并访问时,会调用包装器。这样的话,cpdef 函数就可以将 cdef 函数的性能和 def 函数的可访问性结合起来了。

因此上面那个例子,我们就可以改写成如下:

cpdef long rec(long n):
    if n == 1:
        return 1
    return n * rec(n - 1)

如果定义两个函数,这两个函数还不能重名,但是使用 cpdef 就不需要关心了,这样可以更方便。

inline cdef and cpdef functions

在 C 和 C++ 中,定义函数时还可以使用一个可选的关键字 inline,这个 inline 是做什么的呢?我们知道函数调用是有开销的(话说你效率这么高了,还在乎这一点啊),而使用 inline 关键字定义的函数,那么代码会被放在符号表中,在使用时直接进行替换(像宏一样展开),没有了调用的开销,提高效率。

Cython 同样支持 inline 关键字,使用时只需要将 inline 放在 cdef 或者 cpdef 后面即可,但是不能放在 def 后面。

cpdef inline unsigned long rec(int n):
    if n == 1:
        return 1
    return rec(n - 1) * n

inline 如果使用得当,那么可以提高性能,特别是在深度嵌套循环中调用的小型内联函数。因为它们会被多次调用,这个时候通过 inline 可以省去函数调用的开销。

使用 cpdef 有一个局限性,那就是它要同时兼容 Python 和 C:意味着它的参数和返回值类型必须同时兼容 Python 类型和C类型。但我们知道,并非所有的 C 类型都可以用 Python 类型表示,比如:C 指针、void、C 数组等等,它们不可以作为 cpdef 定义的函数的参数类型和返回值类型。

cpdef 的局限性

我们说 cpdef 最方便的一点是在实现了高性能的纯 C 函数之外,还自带了一个包装器,虽然让我们变得更加轻松了,但也带来了一些局限性,那就是 cpdef 不支持闭包。

cpdef list func():

    # lam(3) -> 33
    # lam(11) -> 1111
    lam = lambda x: int(str(x) * 2)
    # return [11, 22, 33, 44, 55]
    return [lam(_) for _ in range(1, 5)]

显然函数里面的逻辑在 Python 中、或者说在 def 定义的函数中再正常不过了,但如果是 cpdef 的话,那么编译的时候会报错:

closures inside cpdef functions not yet supported

原因是函数(包括匿名函数)不能够定义在 cpdef 中,因此上面编译失败显然是由 lam = ... 这一行导致的。

解决办法就是换成列表解析式:[int(str(_) * 2) for _ in range(1, 5)],同时将匿名函数的定义给删掉。但如果在  cdef 中则是没有任何问题的,cdef 里面是可以定义函数的,当然 cdef 不能被外界访问,因此我们还需要使用 def 定义一个包装器。

cdef list wrapper():

    # lam(3) -> 33
    # lam(11) -> 1111
    lam = lambda x: int(str(x) * 2)
    # return [11, 22, 33, 44, 55]
    return [lam(_) for _ in range(1, 5)]


def wrapper_func():
    return wrapper()

上面是完全正常的,因此总结一下:

  • 1. cdef 和 def 是一样的,不会受到任何的语法限制,但 def 起不到加速效果,cdef 无法被外界访问
  • 2. cpdef 是两者的结合体,即能享受加速带来的收益,又能自动提供包装器给外界
  • 3. 但 cpdef 会受到一些语法层面的限制,比如内部无法定义函数,因此最完美的做法就是使用 cdef 定义函数之后再手动提供包装器。但是当不涉及到闭包的时候,还是推荐使用 cpdef 定义的

函数和异常处理

def 定义的函数在 C 的级别总是会返回一个 PyObject*,这个是恒定的不会改变,因为 Python 中的所有变量在底层都是一个 PyObject *。它允许 Cython 正确地从 def 函数中抛出异常,但是 cdef 和 cpdef 可能会返回一个非 Python 类型,因此此时则需要一些其它的异常提示机制。

cpdef int divide_ints(int i, int j):
	return i // j

如果这里的 j 我们传递了一个 0,会引发 ZeroDivisionError,但是这个异常却没有办法传递给它的调用方。

>>> import cython_test
>>> cython_test.divide_ints(1, 1)
1
>>> cython_test.divide_ints(1, 0)
ZeroDivisionError: integer division or modulo by zero
Exception ignored in: 'cython_test.divide_ints'
ZeroDivisionError: integer division or modulo by zero
0
>>> 

异常没法传递,换句话说就是异常没有办法向上抛,即使检测到了这个异常。最终会忽略警告信息,并且也会返回一个错误的值 0。当然不光是这里的除零错误,还有索引越界、访问不存在的属性等等,基本所有异常都是无法向上传递的。

而为了正确传递此异常,Cython 提供了一个 except 字句,允许 cdef、cpdef 函数和调用方通信,说明在执行中发生了、或可能发生了 Python 异常。

cpdef int divide_ints(int i, int j) except? -1:
	return i // j
>>> import cython_test
>>> cython_test.divide_ints(1, 0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cython_test.pyx", line 1, in cython_test.divide_ints
    cpdef int divide_ints(int i, int j)except?-1:
  File "cython_test.pyx", line 2, in cython_test.divide_ints
    return i // j
ZeroDivisionError: integer division or modulo by zero
>>> 

我们看到此时异常被正常的传递给调用方了,此时程序就崩溃了,而之前那种情况程序是没有崩溃的。

这里我们实现的方式是通过在结尾加上 except ? -1 来实现这一点,这个 except ? -1 允许返回值 -1 充当发生异常时的哨兵。事实上不仅是 -1,只要在返回值类型的范围内的任何数字都行,它们的作用就是传递异常。但是问题来了,如果函数恰好就返回了 -1 的时候该怎么办呢?看到 except ? -1 中的那个问号了吗,它就是用来做这个的,如果函数恰好返回了一个 -1,那么 Cython 会检测是否有异常回溯栈,有的话会自动展开堆栈。如果我们将那个问号去掉,看看会有什么结果吧。

cpdef int divide_ints(int i, int j) except -1:
	return i // j
>>> import cython_test
>>> cython_test.divide_ints(1, 1)
1
>>> 
>>> cython_test.divide_ints(1, 0)  # 依旧会引发异常,这没问题
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cython_test.pyx", line 1, in cython_test.divide_ints
    cpdef int divide_ints(int i, int j)except-1:
  File "cython_test.pyx", line 2, in cython_test.divide_ints
    return i // j
ZeroDivisionError: integer division or modulo by zero
>>>    
>>> cython_test.divide_ints(1, -1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
SystemError: <built-in function divide_ints> returned NULL without setting an error

如果你使用 C 编写过扩展模块的话,你应该会遇见过这个问题。Python 中的函数总会有一个返回值的,所以在 C 中一定会返回一个 PyObject *。如果 Python 中的函数出错了,那么在 C 一级就会返回一个 NULL,并且将发生异常设置进去。如果返回了 NULL 但是没有设置异常的话,就会抛出上面的那个错误。而我们这里的 except -1 表示返回了 -1 就代表发生异常了、底层会返回NULL,但是此时却没有异常,所以提示我们 returned NULL without setting an error。

所以我们看到 except ? -1 只是单纯为了在发生异常的时候能够往上抛罢了,这里可以是 -1、也可以是其它的什么值。而函数如果也返回了相同的值,那么就会检测异常回溯栈,没有报错就会正常返回。而触发检测的条件就是中的那个 ?,如果不指定 ?,那么当函数返回了和 except 指定的值相同的值,那么是会报错的,因此这个时候你应该确保函数不可能会返回 except 后面指定的值。所以尽管加上了 ? 会牺牲一些效率(因为涉及回溯栈的展开,但实际上是没有什么差别的),但如果你没有百分之百的把握确定函数不会返回相同的值,那么就使用 ? 做一层检测吧。或者还可以使用 except *,此时会对返回的任何值都进行检测,但没有什么必要、会产生开销,直接写上 except ? -1 即可。这样只对 -1 进行检测,因为我们的目的是能够在发生异常的时候进行传递。

另外只有返回值是C的类型,才需要指定 except ? -1

cpdef tuple divide_ints(int i, int j):
	a = i // j

这个时候即使给 j 传递了 0,异常也是会向上抛的,因为返回值不再是 C 中的类型,而是 Python 中的类型;如果将这里 tuple 改成 int、或者 long 之后异常还是会被忽略掉的。

因此,在不指定 except ? -1 的情况下,异常被忽略需要保证返回值必须是 C 中的类型,否则 Cython是可以检测到、并正确传递异常的。如果你使用 cdef、cpdef 定义了返回值是 C 类型的函数,那么建议你加上 except ? -1,这样报错了能够及时地抛出来。

关于扩展模块中的函数信息

一个函数可以有很多信息,我们可以通过函数的字节码进行获取。

def foo(a, b):
    pass


print(foo.__code__.co_varnames)  # ('a', 'b')

import inspect
print(inspect.signature(foo))  # (a, b)

但是对于扩展模块中的函数就不能这样获取了,我们把上面的 foo 函数定义在 cython_test.pyx 中,然后来看一下:

import pyximport
pyximport.install(language_level=3)

from cython_test import foo
print(foo.__code__)  # 123
"""
   ...
   ...
    print(foo.__code__)  # 123
AttributeError: 'builtin_function_or_method' object has no attribute '__code__'
"""

我们看到扩展模块内的函数变成 built-in 级别的了,所以一些动态信息已经没有了,即便有也是无法动态修改的,比如之前说的 __name__。因为信息的访问、动态修改都是在解释器解释执行的时候完成的,而扩展模块已经是不需要解释、直接拿来执行就可以,已经是终极形态,所以不像常规定义的 Python 函数,扩展模块内的函数的动态信息是不支持动态修改的,有的甚至无法访问。

既然这样的话,那我如何才能将函数信息体现出来呢?答案是通过 docstring。

cpdef int divide_ints(int i, int j) except ? -1:
    """
    :param i: 第一个整型i 
    :param j: 第二个整型j
    :return: i和j相除
    """
    return i // j
import pyximport
pyximport.install(language_level=3)


import cython_test
print(cython_test.divide_ints.__doc__)
"""

    :param i: 第一个整型i 
    :param j: 第二个整型j
    :return: i和j相除
    
"""

这是我们向外界进行描述的最好方式,甚至是唯一方式。

类型转换

C 和 Python 在数值类型上都有各自的成熟规则,但是这里我们介绍的是 C 类型,因为 Cython 使用的是 C 类型。

类型转换在 C 中很常见,尤其是指针,Cython 也提供了相似的操作。

# 这里是将其它类型的指针变量 v 转成了int *
cdef int *ptr_i = <int *>v
# 在 C 中, 类似于 int *ptr_i = (int *)v, 只不过小括号变成尖括号

显式的转换在 C 中是不被检测的,因此可以对类型进行完全的控制。

def print_address(a):
    # Python 中的变量本质上就是个指针, 所以这里转成 void *
    cdef void *v = <void*> a
    # 而指针存储的值是一个地址, 一串 16进制数, 我们将其转成 long long, 因为一个 long 存不下
    cdef long long addr = <long long> v
    # 然后再通过内置函数 id 获取地址, 因此两个地址是一样的
    print("Cython address:", addr)
    print("Python id :", id(a))
import pyximport
pyximport.install(language_level=3)


import cython_test
cython_test.print_address("古明地觉")
cython_test.print_address([])
"""
Cython address: 2230547577424
Python id : 2230547577424
Cython address: 2230548032896
Python id : 2230548032896
"""

这里传递的对象显然是一个 PyObject *,然后这里先转成 void *,然后再转成 long long,将地址使用十进制表示,这一点和内置函数 id 做的事情是相同的。

我们也可以对 Python 中的类型进行强制转换,转换之后的类型可以是内置的、也可以是我们自己定义的,来看比较做作的例子。

def func(a):
    cdef list lst1 = list(a)
    print(lst1)
    print(type(lst1))

    cdef list lst2 = <list> a
    print(lst2)
    print(type(lst2))
import pyximport
pyximport.install(language_level=3)


import cython_test
cython_test.func("123")
"""
['1', '2', '3']
<class 'list'>
123
<class 'str'>
"""
cython_test.func((1, 2, 3))
"""
[1, 2, 3]
<class 'list'>
(1, 2, 3)
<class 'tuple'>
"""

我们看到使用 list(a) 转换是正常的,但是 <list> a 则没有实现转换,还是原本的类型。这里的 <list> 作用是接收一个列表然后将其转化为静态的列表,换句话说就是将 PyObject * 转成 PyListObject *。如果接收的不是一个list,那么会转换失败。在早期的 Cython 中会引发一个SystemError,但目前不会了,尽管这里的 lst2 我们定义的时候使用的是 cdef list,但如果转化失败还保留原来的类型。

可如果我们希望在无法转化的时候报错,这个时候要怎么做呢?

def func(a):
    # 将 <list> 换成 <list?> 即可
    cdef list lst2 = <list?> a
    print(lst2)
    print(type(lst2))

此时传递其它对象就会报错了,比如我们传递了一个元组,会报出 TypeError: Expected list, got tuple。

如果我们处理一些基类或者派生类时,强制转换也会发生作用,有关强制转换我们会在后续介绍。

声明并使用结构体、共同体、枚举

Cython 也支持声明、创建、操作 C 中的结构体、共同体、枚举。先看一下 C 中没有使用 typedef的结构体、共同体的声明。

struct mycpx {
    float a;
    float b;
};

union uu {
    int a;
    short b, c;
};

如果使用 Cython 创建的话,那么是如下形式:

cdef struct mycpx:
    float real
    float imag
    
cdef union uu:
    int a
    short b, c

这里的 cdef 也可以写成 ctypedef 的形式。

ctypedef struct mycpx:
    float real
    float imag
    
ctypedef union uu:
    int a
    short b, c

# 此时我们相当于为结构体和共同体起了一个别名叫:mycpx、uu
cdef mycpx zz  # 此时的 zz 就是一个 mycpx 类型的变量
# 当然无论结构体是使用 cdef 声明的还是 ctypedef 声明的,变量 zz 的声明都是一样的

# 但是变量的赋值方式有以下几种
# 1. 创建的时候直接赋值
cdef mycpx a = mycpx(1, 2)
# 也可以支持关键字的方式,但是注意关键字参数要在位置参数之后
cdef mycpx b = mycpx(real=1, imag=2)

# 2. 声明之后,单独赋值
cdef mycpx c
c.real = 1
c.imag = 2
# 这种方式会麻烦一些,但是可以更新单个字段

# 3. 通过Python中的字典赋值
cdef mycpx d = {"real": 1, "imag": 2}
# 显然这是使用Cython的自动转换完成此任务,它涉及更多的开销,不建议用此种方式。

如果是嵌套结构体也是可以的,但是需要换种方式。

# 如果是C中我们创建一个嵌套结构体,可以使用下面这种方式
"""
struct girl{
    char *where;

    struct _info {
        char *name;
        int age;
        char *gender;
    } info;
};
"""
# 但是Cython中不可以这样,需要把内部的结构体单独拿出来才行
ctypedef struct _info:
    char *name
    int age
    char *gender

ctypedef struct girl:
    char *where
    _info info  # 创建一个info成员,类型是_info

cdef girl g = girl(where="sakura sou", info=_info("mashiro", 16, "female"))
print(g.where)
print(g.info.name)
print(g.info.age)
print(g.info.gender)

注意:如果是定义结构体,那么类型必须是 C 中的类型才可以。

定义枚举也很简单,我们可以在多行中定义,也可以在单行中定义然后用逗号隔开。

cdef enum my_enum1:
    RED = 1
    YELLOW = 3
    GREEN = 5

cdef enum my_enum2:
    PURPLE, BROWN

# 注意:即使是不同枚举中的成员,但也不能重复
# 比如my_enum1中出现了RED,那么在my_enum2中就不可以出现了
# 当然声明枚举除了cdef之外,同样也可以使用cdef

# 此外,如果我们不指定枚举名,那么它就是匿名枚举,匿名枚举用于声明全局整数常量

关于结构体、共同体、枚举,我们在后面的系列介绍和外部代码进行交互时会使用的更频繁,目前先知道即可,了解一下相关的语法即可。

使用 ctypedef 给类型起别名

Cython 支持的另一个 C 特性就是可以使用 ctypedef 给类型起一个别名,和 C 中的 typedef 非常类似。主要用在和外部代码进行交互上面,我们还是将在后续系列中重点使用,目前可以先看一下用法。

ctypedef list LIST  # 给list起一个别名

# 参数是一个LIST类型
def f(LIST v):
    print(v)
>>> import cython_test
>>> cython_test.f([])
[]
>>> cython_test.f(())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Argument 'v' has incorrect type (expected list, got tuple)
>>> cython_test.f(None)
None

我们看到接收的是 list,但是我们传了一个 tuple 进去,因为 LIST 是 list 的别名,当然不管什么 Python 类型,None 都是满足的。

ctypedef 可以作用于 C 的类型也可以作用于 Python类型,起别名之后这个别名可以像上面那样作用于函数参数、也可以用于声明一个变量 cdef LIST lst,但是不可以像这样:LIST("123")。起的别名用于声明变量,但是不能当成类型本身来用,否则会报错:'LIST' is not a constant, variable or function identifier。

ctypedef 对于 Cython 来说不是很常用,但是对于 C++ 来说则特别有用,使用 typedef 可以显著的缩短长模板类型,另外 ctypedef 必须出现在全局作用域中,不可以出现在函数内等局部作用域里。

泛型编程

Cython 有一个新的类型特性,称为融合类型,它允许我们用一个类型来引用多个类型。

Cython 目前提供了三种我们可以直接使用的混合类型,integral、floating、numeric,它们都是通过 cython 命名空间来访问的,这个命名空间必须是通过 cimport 导入的。

  • integral:代指C中的short、int、long
  • floating:代指C中的float、double
  • numeric:最通用的类型,包含上面的integral和floating以及复数
from cython cimport integral

cpdef integral integral_max(integral a, integral b):
    return a if a >= b else b 

上面这段代码,Cython 将会创建三个版本的函数:1. a 和 b 都是 short、2. a 和 b 都是 int、 3. a 和 b 都是 long。如果是在 Cython 内部使用的话,那么 Cython 在编译时会检查到底使用哪个版本;如果是从外部 Python 代码导入时,将使用 long 版本。也就是说,除非在调用的时候显式地指定了类型,否则会选择最大范围的类型。

可以看出,如果一个融合类型声明了多个参数,那么这些参数的类型都必须是融合类型中的同一种。

比如我们在 Cython 中调用一下,可以这么做。

cdef allowed():
    print(integral_max(<short> 1, <short> 2))
    print(integral_max(<int> 1, <int> 2))
    print(integral_max(<long> 1, <long> 2)   )

# 但是下面的方式不可以
cdef not_allowed():
    print(integral_max(<short> 1, <int> 2))
    print(integral_max(<int> 1, <long> 2))
# 里面的类型不能混合,否则产生编译时错误
# 因为 Cython 没生成对应的版本的函数

所以这里就要求了我们必须传递 integral,如果传递了其它类型,那么在 Cython 中会引发一个编译时错误,在 Python 中会引发一个 TypeError。

如果我们希望同时支持 integral 和 floating 呢?有人说可以使用 numeric,是的,但是它也支持复数,而我们不希望支持复数,所以可以定义一个混合类型。

from cython cimport int, float, short, long, double

# 通过 ctypedef fused 类型 即可定义一个混合类型,支持的类型可以写在块里面
ctypedef fused int_float:
    int 
    float
    short
    long
    double

# 不仅是C的类型,Python类型也是可以的    
ctypedef fused list_tuple:
    list
    tuple 
    
def f1(int_float a):
    pass

def f2(list_tuple b):
    pass 
>>> import cython_test
>>> cython_test.f1(123)
>>> cython_test.f2((1, 2, 3))
>>> cython_test.f1("xx")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cython_test.pyx", line 15, in cython_test.__pyx_fused_cpdef
    def f1(int_float a):
TypeError: No matching signature found
>>>
>>> cython_test.f2("xx")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cython_test.pyx", line 18, in cython_test.__pyx_fused_cpdef
    def f2(list_tuple b):
TypeError: No matching signature found

传递的时候会对参数进行检测,不符合条件会抛出 TypeError。

我们说对于融合类型而言,Cython 相当于定义了多个版本的函数,然后根据我们传递的参数类型来判断调用哪一种。但其实我们在调用的时候,也可以手动指定:

ctypedef fused list_tuple:
    list
    tuple

# 注意:a 和 b 要么都为列表、要么都为元组
# 不可以一个是列表、一个是元组
cdef func(list_tuple a, list_tuple b):
    print(a, b)

# Cython 会根据我们传递的参数来判断,调用哪一种函数
func([1, 2], [3, 4])  # [1, 2] [3, 4]

# 我们也可以显式指定要调用的函数版本
func[list]([11, 22], [33, 44])  # [11, 22] [33, 44]
func[tuple]((111, 222), (333, 444))  # (111, 222) (333, 444)

当然我们上面只出现了一种融合类型,我们还可以定义多种:

ctypedef fused list_tuple:
    list
    tuple

ctypedef fused dict_set:
    dict
    set


# 会生成如下四种版本的函数:
# 1. 参数 a、c 为列表,b、d 为字典
# 2. 参数 a、c 为列表,b、d 为集合
# 3. 参数 a、c 为元组,b、d 为字典
# 4. 参数 a、c 为元组,b、d 为集合
cdef func(list_tuple a, dict_set b, list_tuple c, dict_set d):
    print(a, b, c, d)


# 会根据我们传递参数来判断选择哪一个版本的函数
func([1], {"x": ""}, [], {})

# 我们依旧可以指定,不让 Cython 帮我们判断
# 但是注意:由于存在多种混合类型,一旦指定、那么每一个混合类型都要指定
# 表示类型为 list_tuple 的 a、c 接收的都是 list 对象、类型为 dict_set 的 b、d 接收的都是 dict 对象
func[list, dict]([1], {"x": ""}, [], {})
# 此外,我们必须写成 func[list, dict]([1], {"x": ""}, [], {}),如果写成 func[dict, list]([1], {"x": ""}, [], {}) 是不行的
# 因为类型为 list_tuple 的参数先出现,类型为 dict_set 的参数后出现
# 所以第一个出现的类型一定是 list_tuple 里面的类型(list 或 tuple),后面的是 dict_set 里面的类型(dict 或 set)
# 因此一旦指定版本,
# 那么只能是:func[list, dict](...)、func[list, set](...)、func[tuple, dict](...)、func[tuple, set](...) 之一
# 并且和传递的参数类型要匹配,否则报错,比如:func[list, dict]((1, 2, 3), ...) 肯定不行,因为需要 list 我们却传递了 tuple

当然有时候参数不一定只有融合类型,还可以有别的具体类型:

ctypedef fused list_tuple:
    list
    tuple

ctypedef fused dict_set:
    dict
    set


cdef func(list_tuple a, dict_set b, int xxx, list_tuple c, dict_set d):
    print(a, b, c, d, xxx)


# 但是对于指定版本调用是无影响的,因为在 func 后面的 [] 中只需要指定融合类型中的具体类型,其它的不需要管
func[list, dict]([1], {"x": ""}, 123, [], {})  # [1] {'x': ''} [] {} 123
func[list, set]([1], {1, 2, 3}, 456, [], {2})  # [1] {1, 2, 3} [] {2} 456

以上的函数都是使用 cdef 定义的,无法被外部访问,但是我们也可以使用 def 和 cpdef 定义,这样的话,函数就可以被外界访问了。

此外,我们还可以提前声明使用的函数版本:

ctypedef fused list_tuple:
    list
    tuple

ctypedef fused dict_set:
    dict
    set


cdef func(list_tuple a, dict_set b, int xxx, list_tuple c, dict_set d):
    print(a, b, c, d, xxx)


# 声明一个函数指针,指向的函数接收五个参数,类型分别是 list, set, int, list, set
# 此时必须将所有参数的类型全部指定,不能只指定混合类型,并且声明为同一种混合类型的参数的具体类型仍然要一致
cdef object (*func_with_list_set)(list, set, int, list, set)
# 赋值
func_with_list_set = func
func([], {1}, 123, [], {2})  # [] {1} [] {2} 123

# 或者这种方式也是可以的,将 func 转成 <object (*)(list, set, int, list, set)>
(<object (*)(list, set, int, list, set)> func)([], {1}, 123, [], {2})  # [] {1} [] {2} 123

# 还有就是之前的方式,只不过可以拆开使用
# [] 里面只能指定融合类型
cdef func_with_tuple_dict = func[tuple, dict]
func_with_tuple_dict((1, 2), {"a": "b"}, 456, (11, 22), {"b": "a"})  # (1, 2) {'a': 'b'} (11, 22) {'b': 'a'} 456

由于我们定义的是混合类型,那么究竟具体是哪一种我们根据参数的值进行判断,但其实除了通过参数判断之外,我们还可以通过混合类型去判断,举个栗子:

ctypedef fused list_tuple_dict:
    list
    tuple
    dict


cdef func(list_tuple_dict val):
    if list_tuple_dict is list:
        print("val 是 list 类型")

    elif list_tuple_dict is tuple:
        print("val 是 tuple 类型")

    else:
        print("val 是 dict 类型")


func([])
func(())
func({})
"""
val 是 list 类型
val 是 tuple 类型
val 是 dict 类型
"""

混合类型具体会是哪一种类型,在参数传递的时候便会得到确定。

因此 Cython 中的泛型编程还是很强大的,但是在工作中的使用频率其实并不是那么频繁。

Cython中的for循环和while循环

Python 中的 for 循环和 while 循环是灵活并且高级的,语法自然、读起来像伪代码。而 Cython 也是支持 for 和 while 的,无需修改,并且循环通常占据程序运行时的大部分时间,因此我们可以通过一些指针,确保 Cython 能够将 Python 中的循环转换为高效的 C 循环。

n = 100
for i in range(n):
    ...

上面是一个标准的 Python for 循环,如果这个 i 和 n 是静态类型,那么 Cython 就能生成更快的 C 代码。

cdef unsigned long i, n = 100
for i in range(n):
    ...
# 这段代码和下面的C代码是等效的
"""
for (i=0; i<n; ++i) {
 	/* ... */
}
"""

所以当通过 range 进行循环时,我们应该将 range 里面的参数以及循环变量换成 C 的整型。如果不显式地进行静态声明的话,Cython 就会采用最保守的策略:

cdef unsigned long n = 100
for i in range(n):
    ...

在循环的时候,这里的 i 也会被当成是 C 的整型,但前提是我们没有在循环体的表达式中使用 i 这个变量。如果我们使用了,那么 Cython 无法确定是否会发生溢出,因此会保守的选择 Python 中的类型。

cdef unsigned n = 100
for i in range(n):
    print(i + 2 ** 32)

我们看到我们在表达式中使用到了 i,如果这里的 i 是 C 中的整型,那么在和一个纯数字相加的时候,Cython 不知道是否会发生溢出,所以这里的 i 就不会变成 C 中的整型。

如果我们能保证表达式中一定不会发生溢出,那么我们可以显式地将 i 也声明为 C 中的整数类型。比如:cdef unsigned long i, n = 100

当我们遍历一个容器(list、tuple、dict等等)的时候,对于容器的高效循环,我们可以考虑将容器转化为 C++ 的有效容器、或者使用类型化的内存视图。当然这些我们也是在后面系列中说了,因为这些东西显然没办法一次说清(感觉埋了好多坑,欠了好多债)

目前只能在 range 中减少循环开销,我们将在后续系列中了解优化循环体的更多信息,包括 numpy 在 Cython 中的使用以及类型化内存视图。至于 while 循环的优化方式和 for 循环是类似的。

循环的另一种方式

但是对于 Cython 而言,循环还有另一种方式,不过已经过时了,不建议使用,了解一下即可:

cdef int i

# 不可以写成 for i from i >=0 and i < 5
for i from 0<= i < 5:  # 等价于 for i in range(0, 5)
    print(i)
"""
0
1
2
3
4
"""

for i from 0 <= i < 5 by 2:  # for i in range(0, 5, 2)
    print(i)
"""
0
2
4
"""

Cython 预处理器

我们知道在 C 中可以使用 #define 定义一个宏,在 Cython 中也是可以的,不过使用的是 DEF 关键字。

DEF pi = 3.14
print(pi * 2)

DEF 定义的宏在编译的时候就会被替换成我们指定的值,可以用于声明 C 的类型、也可以是 Python 的类型。比如这里的 pi,在编译的时候就会被换成 3.14,注意:这个宏只是简单的字符串替换,如果你了解 C 中的宏的话,那么 Cython 中的宏和 C 中的宏是类似的。

  • UNAME_SYSNAME:操作系统的名称
  • UNAME_RELEASE:操作系统的发行版
  • UNAME_VERSION:操作系统的版本
  • UNAME_MACHINE:操作系统的机型、或者硬件名称
  • UNAME_NODENAME:网络名称

除此之外,Cython 还允许我们像 C 一样使用 IF ELIF ELSE 进行条件编译。

IF UNAME_SYSNAME == "Windows":
    print("这是Windows系统")
ELIF UNAME_SYSNAME == "Linux":
    print("这是Linux系统")
ELSE:
    print("这是其它系统")
import pyximport
pyximport.install(language_level=3)


import cython_test
"""
这是Windows系统
"""

另外:操作系统这些内置的宏,需要搭配 IF ELIF ELSE 使用,单独使用是会报错的。

总结

这次深入介绍了 Cython 的语言特性,并且为了更好的理解,使用了很多 Python 解释器里面才出现的术语,比如:PyObject、PyFunctionObject 等等,在学习 Cython 的某些知识时相当于站在了解释器的角度上,当然也介绍了 Python 解释器的一些知识。所以看这一篇博客,需要你有 Python 解释器相关的知识、以及了解 C 语言,不然学习起来可能有点吃力。

我们后续将会以这些特性为基础,进行深入地使用。目前的话,有些知识并没有覆盖的那么详细,比如:结构体等等,因为循序渐进嘛,所以暂时先抛出来,后面系列再慢慢研究。

Cython 生态

Cython是一个辅助语言,它是建立在 Python 之上的,是为 Python 编写扩展模块的。所以很少有项目会完全使用 Cython 编写(uvloop 例外),但它确实是一个成熟的语言,有自己的语法(个人非常喜欢,觉得设计的真酷)。在 GitHub 上搜索,会发现大量的 Cython 源文件分布在众多的存储库中。

考虑到 numpy、pandas、scipy、sklearn 等知名模块内部都在使用,所以 Cython 也算是间接地被数百万的开发人员、分析师、工程师和科学家直接或者间接使用。

如果 Pareto 原理是可信的,程序中百分之 80 的运行时开销是由百分之 20 的代码引起的,那么对于一个 Python 项目来说,只需要将少部分 Python 代码转换成 Cython 代码即可。

一些用到 Cython 的顶尖项目都是与数据分析和科学计算有关的,这并非偶然。Cython 之所以会在这些领域大放异彩,有以下几个原因:

  • 1. Cython 可以高效且简便地封装现有的 C、C++、FORTRAN 库,从而对那些已经优化并调试过的功能进行访问。这里多提一句,FORTRAN算是一个上古的语言了,它的历史比 C 还要早,但是别看它出现的早、但速度是真的快,尤其是在数值计算方面甚至比 C 还要快。包括 numpy 使用的 blas 内部也用到了 FORTRAN,虽然 FORTRAN 编写代码异常的痛苦,但是它在一些学术界和工业界还是具有一席之地的。原因就是它内部的一些算法,都是经过大量的优化、并且久经考验的,直接拿来用就可以。而 Cython 也提供了相应的姿势来调用 FORTRAN 已经编写好的功能。
  • 2. 当转化为静态类型语言时,内存和 CPU 密集的 Python 计算会有更好的执行性能。
  • 3. 在处理大型的数据集时,与 Python 内置的数据结构相比,在低级别控制精确的数据类型和数据结构可以让存储更高效、执行性能更优秀。
  • 4. Cython 可以和 C、C++、FORTRAN 库共享同类型的连续数组,并通过 numpy 中的数组直接暴露给Python。

不过即便不是在数据分析和科学计算领域,Cython 也可以大放异彩,它也可以加速一般的 Python 代码,包括数据结构和密集型算法。例如:lxml 这个高性能的 xml 解析器内部就大量使用了 Cython。因此即使它不在科学计算和数据分析的保护伞下,也依旧有很大的用途。

感觉这一章内容有点多啊。。。。。

posted @ 2020-07-05 16:11  古明地盆  阅读(9202)  评论(5编辑  收藏  举报