通过 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
    # a 没有初始值,默认是零值,即 0.0
    cdef float a, b = 2.1
复制代码

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

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

所以使用 cdef 声明变量非常简单,格式:cdef 类型 变量名。当然啦,同时也可以赋上初始值。

一旦使用 cdef 静态声明,那么后续再给变量赋值的时候,就不能那么随心所欲了,举个例子:

# 如果是动态声明,以下都是合法的
# a 可以指向任意的对像,没有限制
a = 123
a = []

# 但如果是静态声明
# 那么 b 的类型必须是整型
cdef int b = 123
# 将一个列表赋值给 a 是会出现编译错误的
b = []  # compile error
复制代码

也正是因为在编译阶段就能检测出类型,并分配好内存,所以在执行的时候速度才会快。

static 和 const

如果你了解 C 的话,那么思考一下:假设要在函数中返回一个局部变量的指针、并且外部在接收这个指针之后,还能访问指针指向的值,这个时候该怎么办呢?我们知道 C 函数中的变量是分配在栈上的(不使用 malloc 函数,而是直接创建一个变量),函数结束之后变量对应的值就被销毁了,所以这个时候即使返回一个指针也是无意义的。

尽管有些时候,在返回指针之后还是能够访问指向的内存,但这只是当前使用的编译器比较笨,在编译时没有检测出来。如果是高级一点的编译器,那么在访问的时候会报出段错误或者打印出一个错误的值;而更高级的编译器甚至连指针都不让返回了,因为指针指向的内存已经被回收了,那还要这个指针做什么?因此指针都不让返回了。

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

但可惜的是,static 不是一个有效的 Cython 关键字,因此我们无法在 Cython 中声明一个 C 的 static 变量。

除了 static,在 C 中还有一个 const,用来声明常量。一旦使用 const声明,比如 const int i = 3,那么这个 i 在后续就不可以被修改了。而在 Cython 中,const 是支持的,但是它只能在定义函数参数的时候使用,在介绍函数的时候再聊。

所以 C 的 static 和 const 目前在 Cython 中就无需太关注了。

C 类型

我们上面声明变量的时候,指定的类型是 int 和 float,而在 Python 和 C 里面都有 int 和 float,那么用的到底是谁的呢?其实上面已经说了,用的是 C 的 int 和 float,至于原因,我们后面再聊。

而 Cython 可以使用的 C 类型不仅有 int 和 float,像 short, int, long, unsigned short, long long, size_t, ssize_t 等基础类型都是支持的,声明变量的方式均为 cdef 类型 变量名。声明的时候可以赋初始值,也可以不赋初始值。

而除了基础类型,还有指针、数组、定义类型别名、结构体、共同体、函数指针等等也是支持的,我们后面细说。

Cython 的自动类型推断

Cython 还会对函数体中没有进行类型声明的变量自动执行类型推断,比如:for 循环里面全部都是浮点数相加,没有涉及到其它类型的变量,那么 Cython 在自动对变量进行推断的时候会发现这个变量可以被优化为静态类型的 double。

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

看一个简单的函数:

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

在这个例子中,Cython 会将赋给变量 i、c、r 的值标记为通用的 Python 对象。尽管这些对象的类型和 C 的类型具有高度的相似性,但 Cython 会保守地推断 i 可能无法用 C 的整型表示(C 的整数有范围,而 Python 没有、可以无限大),因此会将其作为符合 Python 代码语义的 Python 对象。

而对于 d = 2.0,则可以自动推断为 C 的 double,因为 Python 的浮点数对应的值在底层就是使用一个 double 来存储的。所以最终对于开发者来讲,变量 d 看似是一个 Python 的对象,但是 Cython 在执行的时候会将其视为 C 的 double 以提高性能。

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

当然如果非要 Cython 编译器去猜,也是可以的,而且还可以通过 infer_types 编译器指令,在一些可能会改变 Python 代码语义的情况下给 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 的整型;d 和之前一样是 double,而 c 和 r 都是复数变量,复数则依旧使用 Python 的复数类型。

但是注意:并不代表启用 infer_types 时,就万事大吉了;我们知道在不指定 infer_types 的时候,Cython 推断类型显然是采用最最保险的方法、在保证程序正确执行的情况下进行优化,不能为了优化而导致程序出现错误,显然正确性和效率之间,正确性是第一位的。

而 C 的整型由于存在溢出的问题,所以 Cython 不会擅自使用。但是我们通过 infer_types 启动了更多的类型推断,让 Cython 在不改变语义的情况下使用 C 的类型。但是溢出的问题它不知道,所以在这种情况下是需要我们来负责确保不会出现溢出。

对于一个函数来说,如果启动这样的类型推断的话,我们可以使用 infer_types 装饰器的方式。不过还是那句话,我们应该手动指定类型,而不是让 Cython 编译器去猜,因为我们是代码的编写者,类型什么的我们自己最清楚。因此 infer_types 这个装饰器,在工作中并不常用,而且想提高速度,就必须事先显式地规定好变量的类型是什么。

小结

以上就是在 Cython 中如何静态声明一个变量,方法是使用 cdef 关键字。事先规定好类型是非常重要的,一旦类型确定了,那么生成的机器码的数量会少很多,从而实现速度的提升。

而 C 类型的变量的运算速度比 Python 要快很多,这也是为什么 int 和 float 会选择 C 的类型。而除了 int 和 float,C 的其它类型在 Cython 中也是支持的,包括指针、结构体、共同体这样的复杂结构。

但是 C 的整型有一个问题,就是它是有范围的,在使用的时候我们要确保不会溢出。所以 Cython 在自动进行类型推断的时候,只要有可能改变语义,就不会擅自使用 C 的整型,哪怕赋的整数非常小。这个时候可以通过 infer_types 装饰器,留给 Cython 更多的猜测空间。

不过还是那句话,我们不应该让 Cython 编译器去猜,是否溢出是由我们来确定的。如果能保证整数不会超过 int 所能表示的最大范围,那么就将变量声明为 int;如果 int 无法表示,那么就使用 long long;如果还无法表示,那就没办法了,只能使用 Python 的整型了。而使用 Python 整型的方式就是不使用 cdef,直接动态声明即可。

所以如果要将变量声明为整型,那么直接使用 ssize_t 即可,等价于 long long。而在工作中,能超过 ssize_t 最大表示范围的整数还是极少的。

# 需要确保赋给 a 的整数
# 不会超过 ssize_t 所能表示的最大范围
cdef ssize_t a

# b 可能会非常非常大,也有可能是负数
# 甚至连 ssize_t 都无法表示
# 此时就需要动态声明了,但很少会遇到这么大的整数
b = ...
复制代码

再次强调,事先规定好类型对速度的提升起着非常重要的作用。因此在声明变量的时候,一定将类型指定好,特别是涉及到数值计算的时候。只不过此时使用的是 C 的类型,需要额外考虑整数溢出的情况,但如果将类型声明为 ssize_t 的话,还是很少会发生溢出的。

以上就是 cdef 的用法,以上就是本次分享的所有内容,想要了解更多 python 知识欢迎前往公众号:Python 编程学习圈 ,发送 “J” 即可免费获取,每日干货分享

猜你喜欢

转载自juejin.im/post/7109666171602862093