Cython 学习和使用

Python

This article was last updated on <span id="expire-date"></span> days ago, the information described in the article may be outdated.

写在前面

长期更新

强烈建议亲手编写代码进行操作

最近想写一些用 Python 控制 wpa_supplicant 的程序,然而找到的轮子似乎都是通过 d-Bus 实现的,没有用官方提供的接口的 (也许是因为只提供了 C 的接口吧)。于是想自己通过 Cython 在 Python 中调用这些接口,无奈 Cython 水平太低,只好再学学 Cython。

通过 Google 搜到了一本英文的手册:《Cython: A Guide for Python Programmers》,在此记录学习过程做备忘。

这篇文章会一直更新,直到我看完这本书为止,至于什么时候更新嘛,咳咳….应该不会一直拖着不更,考完了研,人就是闲

为什么使用 Cython

虽然这个问题被回答了很多遍,但我还是提一嘴,以免读者不了解。

Cython 有两种使用方式:

  1. 将 Python 代码转换为 C 代码,并编译成 Python 可以调用的链接库 (在 Windows 上为 .pyd 文件,Linux 与 MacOS 上则是 .so 文件),通过这种方式加速代码的运行效率
  2. 包装 C 代码,为 Python 提供调用 C 函数的接口。

看本教程前要做的准备

  1. 会 Python
  2. 安装好 Cython
  3. 安装好 C++ 编译器

简单的示例

Cython 的语法尽量保留了 Python 的风格,同时又兼顾了 C 的形式。在需要准确定义类型的变量面前,只需加上 “cdef 类型” 即可。

1
cdef int a

考虑一个可以返回斐波那契数列中第 n 个数字的函数。

在 Python 中可以写作这样

1
2
3
4
5
def fib(n):
a, b = 0.0, 1.0
for i in range(n):
a, b = a + b, a
return a

保存在 .py 文件中即可运行。

而在 Cython 中,则写作这样,并保存在 .pyx 文件中。例如保存在 fib.pyx 中:

1
2
3
4
5
6
def fib(int n):
cdef int i
cdef double a=0.0, b=1.0
for i in range(n):
a, b = a + b, a
return a

然后还要写一个编译的脚本:setup.py

1
2
3
4
from setuptools import setup
from Cython.Build import cythonize

setup(ext_modules=cythonize('fib.pyx'))

这里 cythonize 会将 fib.pyx 转换成 C,然后再由 setup 编译链接。执行完脚本后在目录下看到的 C 文件就是 cythonize 转换的结果

最后执行命令和结果如图

然后就可以在 Python 里使用编译好的函数了

简单的示例二:包装 C 函数

考虑有如下 C 代码实现与上面的函数一样的功能

1
2
3
4
5
6
7
8
9
10
11
12
double cfib(int n)
{
int i;
double a=0.0, b=1.0, tmp;
for (i=0; i<n; ++i)
{
tmp = a;
a = a + b;
b = tmp;
}
return a;
}

我们需要为其写一个包装函数,c_fib.pyx

注意!!!包装函数不能与 C 文件同名,避免 Cython 生成的 C 代码由于同名而覆盖掉文件。血的教训

1
2
3
4
5
cdef extern from "cfib.c":
double cfib(int n)

def fib(n):
return cfib(n)

第一行的 extern 声明了函数来自文件 cfib.c,并为需要用到的函数做了声明

我们的 setup.py 如下

1
2
3
4
5
6
7
from setuptools import setup, Extension
from Cython.Build import cythonize

# 创建一个 Extension 对象,包含链接库名称以及源文件
# 这样写的好处是,当有多个源文件时可以一并添加,你也可以继续使用之前的写法
ext_module = Extension(name="c_fib", sources=["c_fib.pyx"])
setup(ext_modules=cythonize(ext_module))

最后编译运行即可

在交互窗口中使用 Cython

我对这个没有兴趣,所以简略说一下

使用 IPython

在窗口中输入下面的命令

1
%load_ext cythonmagic

现在你应该可以通过 %%cython 命令来定义一个 cython 的函数了

放个 PDF 上的截图

使用 pyximport

pyximport 既可以在 IPython 中用,也可以在普通的交互窗口中用

首先执行以下两行命令,然后你就可以直接 import 相应的 .pyx 文件了

1
2
import pyximport
pyximport.install()

使用其他编译方式进行编译

除了上面编译的方法之外,还可以使用 CMake, SCons 和 Make 进行编译,然而我并不考虑用其他方式编译,所以略过这里的内容

Cython 支持的 C 语言类型

C 语言 Cython
指针 cdef int *p
cdef void **buf
数组 cdef int arr[10]
cdef double pointers[20][30]
使用 typedef 重命名过的类型 cdef size_t len
复杂的类型 (如结构体和共用体) cdef tm time_struct
cdef int_short_union_t hi_lo_bytes
函数指针 cdef void (*f)(int, duoble)

Cython 也可以自动判断变量类型,在不影响程序运行前提下将某些变量的类型静态化。当然我还是偏向于手动指定

Cython 中的指针

在 C 中可以使用如下形式定义一次性多个指针

1
int* a, b

但是在 Cython 中,每个指针前都要加 * 号

1
2
cdef int *a, *b
cdef double **a, **b

在 Cython 中引用函数指针时,不能使用 *a 的形式,因为在 Python 中 *a 的形式代表可变参数。只能使用数组的形式来引用指针指向的变量

1
2
3
4
5
6
7
cdef double golden_ratio
cdef double *p_double
p_double = &golden_ratio
p_double[0] = 1.618 # 不能使用 *p_double = 1.618 形式的语句

print golden_ratio
print p_double[0]

Cython 中另一个与 C 有差异的地方在于结构体中变量的使用,C 可以通过箭头语法 “->” 或者点语法 “.” 来引用,在 Cython 中你只能使用 点语法 “.”

C 语言

1
2
st_t *p_st = make_struct()
int a_doubled = p_st->a + p_st->a

Cython

1
2
cdef st_t *p_st = make_struct()
cdef int a_doubled = p_st.a + p_st.a

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

对于可以互相转换的数据类型例如 整型,浮点型,Cython 可以自动的进行转换。例如

1
2
3
cdef int a, b, c
# ... 对 a,b,c 进行的运算 ...
tuple_of_ints = (a,b,c)

对于 C 独有的例如 指针 则不能这样使用

下表给出了 C 与 Python 之间可以互相进行转换的数据类型

Python C
bool bint
int
long
[unsigned] char
[unsigned] short
[unsigned] int
[unsigned] int
[unsigned] long
[unsigned] long long
float float
double
long double
complex float complex
double complex
bytes
str
unicode
char *
std::string (C++)
dict struct

表中有几个值得注意的地方

bint 类型

bint (Boolean integer) 型在 C 中其实为 int 型,并且可以由 Python 的 bool 型转换过来或转换为 Python 的bool 型。0 为 False,1 为 True

整型数据的转换和溢出

在 Python2 中,Python 的 int 实际上是 C 的 long,Python 的 long 没有精度限制。而在 Python3 中,所有的 int 类都没有精度限制。在将 Python 的 int 类转换为 C 的类型时,Cython 会对代码进行溢出检查。如果有溢出则会报错。

浮点型数据的转换

Python 的 float 对应 C 的 double,将 Python 的 float 转换为 C 的 float 可能会导致数据被截短为 0.0 或者变为 无穷大

复数类型

Python 的复数对应 C 中有着两个 double 变量的结构体。Cython 有 float complex 和 double complex 两种 C 语言的类型,对应 Python 的复数。

Cython 中 C 的复数类型使用起来和 Python 的完全相同,但是方法都是用 C 实现的。包括 real 和 imag 属性来访问实部和虚部,conjugate 方法来获取共轭复数,以及加减乘除算法

什么意思呢?

1
2
3
4
5
6
7
8
# 在 Cython 中,你可以这样创建一个 Python 的复数变量
a = 1+2j
# 也可以这样创建一个 C 的复数变量
cdef double complex b = 1+2j

# 而这两个变量的访问方式却是一样的
print a.real
print b.real

下面的代码更详细的展示了 Cython 中复数的使用,你可以试着自己编译运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def complext():
py_a = 1+2j
cdef double complex a=1+2j, b=3+4j
# Python 与 C 复数比较
print 'complex in python:', py_a
print 'complex in c:', a
# 加减乘除、共轭复数以及虚部、实部
print '+:', a + b
print '-:', a - b
print '*:', a * b
print '/:', a / b
print 'real:', a.real
print 'imag:', a.imag
cdef double complex c = 1+1j
print 'c.conjugate:', c.conjugate()
print 'c:', c

另外,Cython 中 C 的复数类型与 C99 标准中的 _Complex 类型还有 C++ 中的 std::complex 类兼容

bytes 类型

Python 的 bytes 类会自动与 C 中的字符指针 (char *) 还有 std::string 相互转换

字符串和 unicode 类型

需要设置 c_string_type 和 c_string_encoding 编译器指令才能使 Python 的字符串或者 unicode 类型与字符指针或者 std::string 相互转换

为 Python 的变量静态的指定类型

cdef 除了可以为变量指定 C 的数据类型,还可以指定 Python 的数据类型。这些类型包括:list,tuple,dict,NumPy 数组以及其他很多类型。

并不是所有的 Python 类型都可以用 cdef,但 Python 所有的内建类型是可以使用的

1
2
3
4
5
# 以下声明是合法的
cdef list particles, modified_particles
cdef dict names_from_particles
cdef str pname
cdef set unique_particles

在这种情况下,Cython 会把这些变量声明为指向 其类型的结构体的指针。这些变量用起来就像正常的 Python 变量一样,只不过都有着特定的声明好的类型,不能再随意更改了。

我们知道,Python 中的 list、tuple 等在底层其实对应的是定义好的 C 的结构体

1
2
3
4
5
# 你可以这样用 particles
particles = list(names_from_particles.key()) # 程序运行正确

# 但是给它赋值其他的变量类型是不允许的
particles = 1 # 程序报错

动态变量也可以从静态变量出生成,还是以 particles 为例

1
2
3
other_particles = particles
# 这样删除第 0 号元素也会删除 particles 中的第 0 号元素,因为 particles 和 other_particles 实际上指向同一个列表
del other_particles[0]

other_particles 与 particles 之间唯一的区别就是 other_particles 可以被赋值为其他类型的数据 (例如整型),而 particles 只能被赋值 list 型数据

注意:由于 Python 中也有 int 和 float 关键字,在这种情况下,C 中的 int 和 float 总是优先的。例如 cdef int 指的是 C 中的 int 而非 Python 中的 int

做加减乘运算时,得到的结果如果赋给静态变量,则是 C 类型 (这种情况下你需要注意是否会导致溢出);如果赋给动态变量,则是 Python 类型。但是在除法和取余运算中需要特别注意。在取余运算中,C 是以 0 为标准,而 Python 以无穷为标准

1
2
3
4
# Python
-1 % 5 = 4
# C
-1 % 5 = -1

在除法中,Python 会检查 0 / 0 的情况并报错 (ZeroDivisionError),而 C 却不会检查。因此在进行除法和取余运算时,Cython 会采用 Python 的方式进行运算,即使你要把结果赋给一个静态的 C 变量。如果你想使用 C 的方法做除法和取余运算,可以使用指令 cdivision。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 可以在开头加上下面这一句注释来全局启用 (带 # 号)
# cython: cdivision=True

# 或者以装饰器的方式来在一个函数中使用
cimport cython # 首先需要引入 cython 包
@cython.cdivision(True)
def divides(int a, int b):
return a / b

# 或者使用上下文管理器
cimport cython
def remainder(int a, int b):
with cython.cdivision(True):
return a % b

但是你需要注意,这样做可能会导致程序出现意外的行为。

Cython 也有一条 cdivision_warnings 指令,这条指令默认为 False。当启用时 (True),如果除法或者取余运算中有对负数进行操作 Cython 便会发出运行时警告

为什么为 Python 的变量静态的指定类型

很简单,为了速度。举个例子。下面的代码将定义相关的变量并将一个 Particle 添加到 dynamic_particles 中。

1
2
3
4
5
6
7
8
9
cdef list particles
cdef list make_particles(...):
# 返回一个 particles 的函数
...
return particles
cdef int Particle():
return 1
dynamic_particles = make_particles()
dynamic_particles.append(Particle())

在这个过程中,Cython 在运行时首先会测试 dynamic_particles 是否是一个 list。就算不是 list,只要有 append 这个方法并且能接受一个参数,代码就会继续向下运行。代码首先调用 PyObject_GetAttr 来获得 append 方法,然后才再通过 PyObject_Call API 来调用 append 方法。这就是在运行 Python 字节码时发生的事情。

而如果我们直接将 dynamic_particles 定义为一个静态类型的变量,Cython 生成的程序就能直接调用 append 方法,这样就节省了一些时间

Cython 目前支持的 Python 内置类型 (不包括 int,long,float) 包括

  • type,object
  • bool
  • complex
  • basestring,str,unicode,bytes,bytearray
  • list,tuple,dict,set,frozenset
  • array
  • slice
  • date,time,datetime,timedelta,tzinfo

未来 (现在是 2022年 01月 07日 星期五 11:04:07 CST) 将支持更多的类型

字符串的引用

Python 有着自动的垃圾回收机制,但是这个机制可能会造成某些问题

考虑下面的例子

1
2
3
b1 = b"All men are mortal"
b2 = b"Socrates is a man"
cdef char *buf = b1 + b2

这个例子中我们尝试获得字符串 b1 + b2 的地址,以方便通过指针引用。但是由于 Python 有自动垃圾回收,在 buf 获得 b1 + b2 的地址之后,b1 + b2 会立即被销毁并回收内存,这会导致指针引用错误。想要避免这种问题,需要事先将 b1 + b2 赋给一个中间变量以保证它不会被回收

1
2
3
4
5
b1 = b"All men are mortal"
b2 = b"Socrates is a man"
temp = b1 + b2
# 或者是 cdef bytes temp = b1 + b2
cdef char *buf = temp

Cython 的三种函数形式

许多前面讲到的关于动态变量和静态变量的概念都可以用到函数方面。C 的函数和 Python 有一些共同的特点 (这里就不详细说了),不过 Python 的函数更灵活强大。

Python 的函数有以下独有的特点:

  • 能够在被引入时就生成或者在运行时动态的生成
  • 能够利用 lambda 创建匿名函数
  • 能够在另一个函数内被定义
  • 能够被其他函数通过 return 返回
  • 能够被当作参数传递给其他函数
  • 能够接受可变的参数
  • 可以给函数参数设置默认值

以上大部分特性是 C 中的函数所不具有的,但是由于 C 函数简单,其运行速度比 Python 的函数快几个量级。而 Cython 将两者结合了起来,使其可以相互调用,就像使用一种语言一样

通过 def 定义 Python 函数

这一部分就基本跳过了。因为前面的代码已经举过了不少例子,我们很容易就能看出,在 Cython 中使用 def 定义函数时与在 Python 中定义时没什么不同。除此之外,你还可以使用 cdef 部分的将函数内的变量定义为 C 形式的来加快函数运行速度。

通过 cdef 定义 C 函数

使用 cdef 关键字你就可以定义一个完全的静态的 C 类型的函数了,当然,你也可以在 cdef 的函数中使用 Python 的动态变量,但是一般使用 cdef 都是为了使函数运行速度更加接近纯 C 函数,就没必要使用 Python 的变量了吧。

另外,在 Cython 中,你要通过 Cython 的类 Python 语法来定义 C 函数。

考虑一个返回 n! 的函数。其在 Cython 中的 Python 形式写成下面这样

1
2
3
4
5
6
7
8
9
10
11
# 纯 Python 函数
def py_fact(n):
if n <= 1:
return 1
return n * py_fact(n - 1)

# Python 和 C 混合函数
def typed_fact(long n):
if n <= 1:
return 1
return n * typed_fact(n - 1)

而 C 函数则写成下面这样

1
2
3
4
cdef long c_fact(long n):
if n <= 1:
return 1
return n * c_fact(n - 1)

它和上面的 Python 和 C 混合的函数十分类似,极少的不同的地方就是关键字不同和返回值类型是确定的 long。

由 cdef 定义的函数最终生成的 C 函数与我们想象中的形式还是有些区别的,因为 Cython 要对其进行进一步的加工。但是通过对比由 def 定义并生成的 C 函数,我们还是可以看到,cdef 定义的函数还是要简洁不少

图为 Cython 生成的 test.c 文件 (假设我们的函数写在 test.pyx 中)。左边是 Python 形式的 py_fact,右边是 C 形式的 c_fact。通过注释可以看出 c_fact 很快就进入了下一条语句,而 Python 却还要进行很多处理。

cdef 函数支持返回的类型包括前面所提到的,任何能够被静态声名的类型,还有 void 类型。但是如果你把类型省略的话,则默认返回 Python 的一个对象。cdef 定义的函数可以被一个源文件中的 def 或 cdef 定义的函数调用,但是不能被外部的 Python 代码调用。所以你是不能直接通过 import 来引入一个 cdef 定义的函数的,你只能用一个 def 函数包装一下,或者用 cpdef 代替 cdef 定义函数。

通过 cpdef 将 cdef 和 def 函数结合起来

cpdef 声明的函数会将 cdef 和 def 二者结合起来,提供 C 的函数和一个 Python 包装器,这个包装器包装 C 的部分使得我们可以在外部调用。当我们在 Cython 内调用函数时,会直接调用 C 的部分,而在外部 import 使用时,则会调用包装器。

继续考虑一个返回 n! 的函数,其 cpdef 的形式为

1
2
3
4
cpdef long cp_fact(long n):
if n <= 1:
return 1
return n * cp_fact(n - 1)

在 Cython 内部调用的时候,它的运行速度将和 cdef 定义的函数一样快,而且你还可以直接通过 import 来在外部引用。


另外,Cython 还支持 C 和 C++ 的 inline 关键字,直接将 inline 放在函数的定义里即可

1
2
cdef inline long c_fact(long a):
# ...

cpdef 也有一些缺点,因为它要同时兼顾 Python 和 C,同时能够返回 Python 和 C 类型的变量。对于 Python 来讲,Python 的任何类型都能够用 C 的类型表示,但是某些 C 的类型却不能用 Python 的类型表示,例如 void,指针,C 的数组。所以cpdef 是不能定义这些类型的返回值的函数的。你可以参考之前给出的 C 与 Python 之间能够互相转换的变量的表格,表格中的类型都能够用于 cpdef。

函数与异常捕获

由于 def 始终返回 Python 类型的指针,对于 def 的错误捕获和处理是比较简单的。你可以直接在外部使用 except 进行处理。但是对于 cdef 和 cpdef,情况却有些不同

考虑下面的除法函数

1
2
cpdef int cp_divide(int i, int j):
return i / j

当给 j 传 0 时,必然会有错误发生。但是实际情况却是这个错误会被 ignore,并返回一个错误的值,如下图所示。

这样我们就无法在外部设置 except 来捕获错误了。幸好,Cython 为我们提供了 except 子句

1
2
cpdef int cp_divide(int i, int j) except? -1:
return i / j

设置了 except 之后,就可以正常的抛出错误而不被忽略了。这里的 -1 并没有什么特殊的含义 (这也是有点让人迷惑的地方),写成 -2 或者 1 或者其他任意数字都可以。(至少笔者目前的测试是这样的)

函数与内嵌签名

现在感觉用处不是特别大,就先跳过了

类型强制转换

由于 Cython 中的静态类型是 C 类型,所以 C 的强制转换法则在 Cython 中是适用的。只不过在 C 中进行强制转换时使用的是 “()”,而 Cython 中用的是 “<>”。

以转换一个指针为例

1
2
cdef void *v
cdef int *ptr_i = <int*>v

由于 C 是不对强制转换进行检查的,而在 Cython 中 Python 的变量实际上是指向一个结构体的指针,所以我们有可能通过对 Python 变量进行强制转换来打印出 Python 变量的地址 (与 Python 中的 id 函数的功能类似)

1
2
3
4
5
6
def print_address(a):
cdef void *v = <void*>a
cdef long addr = <long>v
# 两个 print 结果一样
print "Cython address:", addr
print "Python id :", id(a)

不仅如此,强制转换还能用在 Python 的类型中,包括内置类型还有我们自己定义的 (详情后面会继续提到)。下面的例子接受一个 Python 的对象,将其指针类型强制转换并赋给一个静态类型变量。

请注意!!!由于 Python 的对象 a 在 C 的层面是一个指针,这样的代码实际上是将该指针的类型强制转换为 list,并非将 Python 对象 a 转换为一个 list。

读者可以实践一下,实际上两个 print 打印出来的类型仍然为 a 的最初的类型。

1
2
3
4
5
def cast_to_list(a):
cdef list cast_list = <list>a
print type(a)
print type(cast_list)
cast_list.append(1)

这样的写法在进行到 append 这一步之前是不会抛出任何错误的,因为前面讲过 C 不会对强制转换做检查。因此如果你带入一个整型数字 a,那么最终得到的 a 和 cast_list 仍为整型变量,使用 append 方法是就会报错 (SystemError)。

想要在进行类型转换时就提前抛出错误,需要加一个 “?”

1
2
3
4
5
def cast_to_list(a):
cdef list cast_list = <list?>a
print type(a)
print type(cast_list)
cast_list.append(1)

这样我们就可以在变量的类型不符合时就抛出错误,并且能正确的捕捉错误了

声明和使用结构体、组合体和枚举

在 Cython 中你也可以使用 C 中的结构体、组合体和枚举。

对于没有用 typedef 修饰的结构体、组合体,在 C 中写法如下

1
2
3
4
5
6
7
8
9
struct mycpx {
int a;
float b;
};

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

在 Cython 中则写作下面的形式

1
2
3
4
5
6
7
cdef struct mycpx:
float real
float imag

cdef union uu:
int a
short b, c

我们还可以使用 ctypedef 关键字来实现与 C 中 typedef 一样的功能

1
2
3
4
5
6
7
8
9
10
ctypedef struct mycpx:
float real
float imag

ctypedef union uu:
int a
short b, c

# 声明一个 mycpx 对象
cdef mycpx zz

我们有以下三种方法来初始化一个结构体

  • 声明时即初始化。(十分的具有 Python 的风格)
1
2
cdef mycpx a = mycpx(3.1415, -1.0)
cdef mycpx b = mycpx(real=2.718, imag=1.618034)
  • 通过点运算符赋值进行初始化
1
2
3
cdef mycpx zz
zz.real = 3.1415
zz.imag = -1.0
  • 通过 Python 的字典进行赋值
1
cdef mycpx zz = {'real': 3.1415, 'imag': -1.0}

嵌套和匿名内联的结构体还有组合体的声明是不支持的

1
2
3
4
5
6
7
// 在 C 中我们可以这样写,但是 Cython 并不支持这样的声明
struct nested {
int outer_a;
struct _inner {
int inner_a;
} inner;
};

想要在一个结构体里嵌套另一个结构体,只能先把里面的结构体声明。(组合体也一样)

1
2
3
4
5
6
cdef struct _inner:
int inner_a

cdef struct nested:
int outer_a
_inner inner

类型声明好了以后就可以通过字典的形式进行初始化 (这样方便一些)。

1
cdef nested n = {'outer_a': 1, 'inner': {'inner_a': 2}}

对于枚举的声明,有以下两种方式

1
2
3
4
5
6
7
8
9
# 数字分行声明
cdef enum PRIMARIES:
RED = 1
YELLOW = 3
BLUE = 5

# 或者在一行内声明
cdef enum SECONDARIES:
ORANGE, GREEN=2, PURPLE

枚举也可以使用 ctypedef,如同前面的结构体和组合体一样,这里就不再赘述了。

匿名的枚举是一种不错的声明全局整型常量的方式

1
2
cdef enum:
GLOBAL_SEED = 37

Cython 中还有融合类型 (fused types),类似于 Java 中的泛型。由于此功能仍处于试验阶段,在此不做介绍。

for 循环和 while 循环

Cython 可以帮助我们把 Python 中的循环优化成 C 中的循环。但是我们得注意把循环中的变量尽量都写成静态的类型,否则循环可能无法得到优化。

1
2
3
4
5
# Python 式的循环有可能不会被优化
n = 100
# ...
for i in range(n):
# ...
1
2
3
4
5
6
7
8
9
# 将变量定义为静态的,Cython 就可以将其优化成 C 的循环
cdef unsigned int i, n = 100
for i in range(n):
# ...

for (i=0; i<n; ++i)
{
/* ... */
}

Cython 通常情况下都能够自动对循环进行加速优化,但并不总是成功的。以下的建议可以帮助更好的优化。

使用 C 整型作为 range 参数

1
2
3
cdef int N
for i in range(N):
# ...

如果 i 在循环中不会被用到,Cython 会将 i 优化成静态类型。但是如果 i 有在循环中有到,Cython 就不会自动对 i 优化了,需要手动声明 i 为静态类型。(因为 Cython 无法判断是否会造成溢出,所以不会对 i 优化)

1
2
3
cdef int i, N
for i in range(N):
a[i] = i + 1

以上是对 for 的优化。对于 whlie 的话,相对于在 while 关键字后接着进行条件判断,使用 while true 然后用 break 跳出循环的写法 Cython 能够更好的自动进行优化,因此更推荐 while true 的写法。

宏定义

C 中有 #define 来进行宏定义,而 Cython 有 DEF 来实现类似的功能。

1
2
3
4
5
6
DEF E = 2.718281828459045
DEF PI = 3.141592653589793

def feynmans_jewel():
"""Return e ** (i * pi) + 1. Should be ~0.0"""
return E ** (1j * PI) + 1.0

DEF 与 C 中的 define 类似,但是并不完全相同。

  • DEF 只能用来表示简单的类型。(用 DEF 来写代码模板是不可能的了)
  • DEF 是在用在编译时的
  • 有些预定义好的变量作用范围有限

最后一条什么意思呢

1
2
3
4
5
6
7
8
9
# 这段代码根据 UNAME_SYSNAME 值的不同来为 flag 设置不同的值
# UNAME_SYSNAME 是与定义好的宏,其他预定义的宏见下表
# 这里的 IF ELSE 块也是类似于 C 的条件编译的语法,下面会讲到
IF UNAME_SYSNAME == "Linux":
DEF flag = 1
ELSE:
DEF flag = 0
def test():
print flag

这在我的电脑上是 1。根据上面的代码可以看出 UNAME_SYSNAME 是一个字符串。那么我们应该可以将其打印出来?

答案是不行,进行编译时会报错。

DEF 可以用于以下用途

  • 对整型的宏定义
  • 对浮点数的宏定义
  • 对字符串的宏定义
  • 可以包含另一个经过宏定义的变量

原文中还提到可以表示 calls to a set of predefined functions ,但是笔者没能正确理解其含义,希望看官帮忙指正。

另外还有一些列预先定义好的宏定义,他们的返回值取决于你的电脑,与 os.uname 的返回值相同

DEF 变量 含义
UNAME_SYSNAME 操作系统名称
UNAME_RELEASE 操作系统的版本
UNAME_VERSION 操作系统的版本号
UNAME_MACHINE 机器硬件名称 (Mac ?)
UNAME_NODENAME 机器在网络上的名称

下表还给出了可以用于 DEF 进行宏定义的常量、内建函数和变量类型。

类型 内容
常量 None, True, False
内建函数 abs, chr, cmp, divmod, enumerate, hash, hex, len, map, max, min, oct, ord, pow, range
reduce, repr, round, sum, xrange, zip
内建类型 bool, complex, dict, float, int, list, long, slice, str, tuple

注意!!!DEF 声明中,被代替的部分必须最终可以得到整型、浮点型或者字符串对象,否则 Cython 会报错。

就如同前面举的例子一样,Cython 也支持使用 IF…ELIF…ELSE 块进行条件编译。它可以出现在任何一个能够对 Python 变量进行声明的地方。假设我们想为不同平台的机器编写不同的代码,那么我们可以这样写

1
2
3
4
5
6
7
8
IF UNAME_SYSNAME == "Windows":
# Windows 专属代码
ELIF UNAME_SYSNAME == "Darwin":
# Mac 专属代码
ELSE UNAME_SYSNAME == "Linux":
# Linux 专属代码
ELSE:
# 其他平台代码

Cython 中的 Python2 与 Python3

由于平时基本不用到 Python2,所以这部分就跳过了。

Cython 中的扩展类

即自定义的 class,由于这个 class 在 Cython 中定义,因此会比纯 Python 的 class 运行速度更快。

考虑一个用来表示一个粒子的类,在 Python 可以定义如下

1
2
3
4
5
6
7
class Particle(object):
def __init__(self, m, p, v):
self.mass = m
self.position = p
self.velocity = v
def get_momentum(self):
return self.mass * self.velocity

我们也可以在 Cython 中进行一样的定义,然后 Cython 会将其翻译成 C 语言。但是即使是这样,其还是没有经过优化。(我们知道 Python 在底层对应的其实就是 C 的对象,而使用上面的写法的话 Cython 也是单纯将其翻译成了其原本就对应的 C 的代码)

将上面的类写成 Cython 的扩展类也非常的简单,只需要做两处小改动即可

1
2
3
4
5
6
7
8
9
cdef class Particle:
# 首先要声明变量
cdef double mass, position, velocity
def __init__(self, m, p, v):
self.mass = m
self.position = p
self.velocity = v
def get_momentum(self):
return self.mass * self.velocity

这样一个 Python 的类就变成了 Cython 的扩展类。扩展类与 Python 的类还是有一些小不同,比如不能随意的访问属性了,当尝试 particle.mass 时会报错,只能通过点运算符访问其拥有的方法。也不能为扩展类的对象添加新的属性,这比较好理解,因为扩展类经过编译已经是一种静态的类型了。

类的属性和权限控制

从前面举得例子我们已经可以看出,通过 cdef 定义的 Cython 扩展类,外部只能访问到类的方法,而对类的属性则不能访问,只有类的方法才能完全访问类的属性。如果我们想从外部访问类的属性呢?Cython 有一套较为全面的属性和权限控制来帮助我们。

还是以上面的 Particle 类为例子,我们做一下小修改,为 mass 添加 readonly 关键字。

1
2
3
4
5
6
7
8
9
10
cdef class Particle:
# 首先要声明变量
cdef double position, velocity
cdef readonly double mass
def __init__(self, m, p, v):
self.mass = m
self.position = p
self.velocity = v
def get_momentum(self):
return self.mass * self.velocity

编译并运行,我们就可以发现可以访问到 mass 的值了,但是尝试对 mass 赋值的操作将会报错。

如果想要可以对 mass 进行写操作,则需要将 readonly 改为 public。

在 C 的层面进行类的初始化和终止化

目前对于扩展类的初始化,我们都用的 __init__ 函数,使用 __init__ 的代价就是需要在运行函数时付出额外的代价,因此会拖慢运行时间。想要在 C 的层面快速对扩展类进行初始化,Cython 为我们准备了 __cinit__ 函数。cinit 与 init 的不同之处在于,init 在运行过程中有可能会调用很多次,也有可能会一次都不调用,这样就不能保证要用的变量被完全正确的初始化。cinit 在运行过程中只会被且一定会调用一次,并在将在 __init__ 和 __new__ 之前被调用。所有的初始化参数都将会被传给 __cinit__。

以下面的包含数组的扩展类举个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from libc.stdlib cimport malloc, free
# cimport 会导入 C 函数,Cython 有着内置的很多包装好的 C 函数,放在 libc 中
cdef class Matrix:
cdef:
unsigned int nrows, ncols
double *_matrix
def __cinit__(self, nr, nc):
self.nrows = nr
self.ncols = nc
self._matrix = <double*>malloc(nr * nc * sizeof(double))
if self._matrix == NULL:
raise MemoryError()
def __dealloc__(self):
if self._matrix != NULL:
free(self._matrix)

如果 self._matrix 的划分放在 __init__ 函数中的话:

  • 若 init 没有被运行,那么在尝试使用 self._matrix 时将会报错
  • 若 init 被运行多次,那么将会造成内存泄漏

对于类的终止化,Cython 也提供了 __dealloc__ 函数,并且此函数也只会运行一次。

cdef 和 cpdef 函数

cdef,cpdef 也可用于扩展类方法的定义。需要注意的是,这两个函数不能用于非 cdef 的扩展类。即用 def 定义的类中无法使用 cdef 和 cpdef 来定义函数。

容易想到,cdef 定义的函数只能在内部访问,而 cpdef 则可以在外部访问。使用 cpdef 定义方法的限制与前面提到的一致,不能传入例如 指针 等参数。

对于前面的 Particle 扩展类,我们就可以改写成使用 cpdef 来定义方法。

1
2
3
4
5
6
7
8
9
cdef class Particle:
# 首先要声明变量
cdef double position, velocity, mass
def __init__(self, m, p, v):
self.mass = m
self.position = p
self.velocity = v
cpdef get_momentum(self):
return self.mass * self.velocity

下面我们再定义一个函数 add_momentums,用来计算粒子的动量和。这个函数接受一个列表,并计算列表中所有粒子的动量和。

1
2
3
4
5
def add_momentums(particles):
total_mom = 0.0
for particle in particles:
total_mom += particle.get_momentum()
return total_mom

这个函数在外部定义或者是在 Cython 内部定义都可以,因为它完完全全是纯 Python 的函数,由于 Cython 并不知道 particles 是一个列表,所以无法对它加速。如果我们添加类型的描述,Cython 就可以生成更快速的代码了。

1
2
3
4
5
6
7
def add_momentums(list particles):
cdef:
double total_mom = 0.0
Particle particle
for particle in particles:
total_mom += particle.get_momentum()
return total_mom

注意到我们对循环过程中用到的 particle 的类型也进行了声明,这样在调用 particle 的 get_momentum 函数时调用的是 C 的部分,Python 的包装没有调用,所以循环将更快速。

一个有趣的事情

如果我们将 Particle 的 get_momentum 函数用 cdef 定义会怎么样?由于 add_momentums 写在 Cython 内,改为 cdef 其也可以调用 get_momentum 函数。在更改前,运行一次 add_momentums 大概需要 7 毫秒 (什么?你说我没说清楚 list 里面有几个元素?手册也没手清楚呀🐶),而更改为 cdef 以后,时间缩短到 4.6 毫秒。要理解为什么速度会进一步加快,就需要说一说扩展类的继承、子类和多态。

继承和子类

Cython 中的扩展类既可以从是一个单一基类的子类 (这个基类必须在 C 中声明),也可以是内建的类型或者是其他的扩展类的子类。基类不能是常规的 Python 类,子类也不能从多个类继承,否则会报错。

下面举个例子。

考虑 Particle 的一个子类 CParticle,该子类仅存储粒子的动量,而不是计算粒子的动量。由于我们不想再重复进行已经在 Particle 中做好的工作,因此直接使用继承的方式将 Particle 的属性和方法继承过来

1
2
3
4
5
6
7
cdef class CParticle(Particle):
cdef double momentum
def __init__(self, m, p, v):
super(CParticle, self).__init__(m, p, v)
self.momentum = self.mass * self.velocity
cpdef double get_momentum(self):
return self.momentum

由于 CParticle 具有 Particle 所有的属性,于是所有用到 Particle 的地方都可以替换成 CParticle。

对于前面提到的,声明了变量类型的函数 add_momentums,虽然其函数中静态定义了变量 particle 的类型为 Particle,但是仍然可以将 CParticle 类的列表传入,不会影响函数的运行。

Python 中的类也可以继承 Cython 的扩展类,但是不能访问私有的 C 变量或者 cdef 函数,def 和 cpdef 的函数可以访问及重写。例如下面的 PyParticle 类以 Particle 为父类。

1
2
3
4
5
class PyParticle(Particle):
def __init__(self, m, p, v):
super(PyParticle, self).__init__(m, p, v)
def get_momentum(self):
return super(PyParticle, self).get_momentum()

由于 cdef 函数不能被 Python 访问,它只在 Cython 的内部调用,也就是一直保持 C 的形式,相比 cpdef 函数,它的调用开销会小一些。(这很好理解,由于 cpdef 需要保证能被 Python 访问,它需要做额外的处理。) 因此当函数运行时间的数量级和调用开销的数量级差不多时,这样的差距会比较明显。这也就是为什么前面会出现 7 毫秒 和 4.6 毫秒的原因。

动态类型转换为静态

当我们使用一个动态对象时 (例如普通的 Python 对象),Cython 无法访问这个对象 C 层面的数据或者方法,只能通过速度较慢的 Python/C API 来访问。但是如果我们知道一个动态对象可能是一个扩展类或者内建的 Python 类型时,我们可以通过将其转换为静态类型来提升运行速度。

实现这个目的方法有两个:根据目标类型创建一个对应的静态类型,并将其显式的转换,或者使用 Cython 提供的转换工具。

Cython 转换工具

首先来看看 Cython 提供的转换工具,这个用起来方便一些。

考虑下列的函数

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

在不执行任何额外操作的时候,若我们将这个函数放到 .pyx 文件中并进行编译,Cython 只会对变量d做处理,将其视为C中的double变量,并不会将整型变量i视为C中的长整型,c视为C中的复数。因为 Cython 会尽量保守的进行优化。但是我们可以通过装饰器@cython.infer_types()来让 Cython 做更进一步的优化。

1
2
3
4
5
cimport cython

@cython.infer_types(True)
def automatic_inference():
.....

显式的转换

假设现在我们有一个对象p,这个p是我们之前定义的Particle或者是其子类型的一个实例,但此时 Cython 只知道p是一个 Python 对象。当我们尝试调用p的一个方法get_momentum时,Cython 会在 Python 的字典中寻找这个方法,如果找到了,就会通过 API PyObject_Call 来执行这个方法。但是如果我们显式的将其转换成 Particle 对象,调用方法 get_momentum 的速度将会更快。

1
2
3
cdef Particle static_p = p
print static_p.get_momentum()
print static_p.velocity

如果p不是Particle的实例,或者它是Particle子类的实例,上面的转换过程就会报TypeError,所以不用担心,一切都很安全。转换成static_p以后对方法和属性的访问都是直接访问的,所以此时就算是私有属性velocity也可以直接访问,而这在p中是做不到的。

除了上面的写法,还有强制转换的写法

1
2
print (<Particle>p).get_momentum()
print (<Particle>p).velocity

这种写法其实不太安全,因为不会对p进行类型检查,若p不能转换有可能会引发段错误。更安全的写法是加一个?

1
2
print (<Particle?>p).get_momentum()
print (<Particle?>p).velocity

这样如果不能转换则会触发TypeError

扩展类与 None

考虑下面的函数,他接受扩展类Particle的实例作为参数

1
2
3
def dispatch(Particle p):
print p.get_momentum()
print p.velocity

如果我们将一个非Particle对象传给函数,那么就会引发TypeError。但是如果我们将None传给函数,则会引发AttributeError

这里原书写的是会引发段错误,但是经测试,实际会引发AttributeError,应该是在某一个版本进行了修改。

有关 setup.py 中 Cython 编译的扩展存放位置不对的问题

打包 Python 包时,setup.py 中的 Cython 的编译块有一个地方需要注意。

以我写的一个项目 Cpywpa 的 setup.py 为例。其 setup.py 中,ext_modules 是这样写的

1
2
3
4
5
6
7
ext_modules = [ 
Extension(
name="Cpywpa.ccore._cpywpa_core",
sources=["./Cpywpa/ccore/_cpywpa_core.pyx"],
extra_compile_args=extra_compile_args
)
]

这里请注意 Extension 中 name 的写法。因为在绝大多数搜索引擎搜索到的结果中,setup.py 里 name 的写法就是单纯写成编译后的扩展的名字。但是这样写的坏处是,**生成的扩展存放的位置是 build 文件夹中,或者是 setup.py 的同级目录 (如果你用了 –inplace 参数)**。

如果你想将自己的源码上传到 pypi,方便别人使用自己写好的 Python 包,只给 name 赋值扩展的名字的写法是不对的。你可以使用 python setup.py install 将这个包安装到自己的本地,然后去包的安装目录看一下。你就会发现生成的扩展存放位置是错误的

想要避免出现这种问题,你就要在 name 中同时写出 扩展在整个包中所属的位置。在上面的 sources 中给出了 pyx 文件所在的位置,我想让编译后的扩展所在的目录和 pyx 文件相同,那么 name 就要根据 sources 写成相应的包的结构 (这个其实很好理解,在 Python 中,一个文件夹其实就是一个包)。

将 name 修改成上述代码所示后,再执行 python setup.py install 检验一下,你就会发现这一次在对应位置出现了想要的扩展。

Author: Syize

Permalink: https://blog.syize.cn/2021/12/26/read-cython-and-learn/

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Syizeのblog

Comments