接上篇:https://developer.aliyun.com/article/1617410?spm=a2c6h.13148508.setting.14.72964f0eiSAdMs
放宽对装饰器的语法限制
在 Python 3.9 之前,我们像下面这种方式使用装饰器是不允许的。
from functools import wraps class Button: def __init__(self, n): self.n = n def deco(self, func): @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper buttons = [Button(i) for i in range(1, 10)] @buttons[0].deco def foo(): pass
这么做会出现语法错误:
我们必须这么做:
@eval("buttons[0].deco") def foo(): pass # 或者 button = buttons[0] @button.deco def bar(): pass
@buttons[0].deco 这种方式从 Python3.9 才开始支持,同样的,Cython 0.29 版本也不支持在 .pyx 中使用这种语法,但 Cython 3.0 开始则支持了。
如果使用 Cython 3.0,那么即使 Python 版本低于 3.9 也是可以的。因为 pyx 文件的语法解析是由 Cython 编译器完成的,和 Python 解释器无关。
异常传播
在介绍 Cython 异常处理的时候说过,如果函数的返回值类型是 C 类型,那么函数里面的异常会被无视掉。
cpdef Py_ssize_t foo(): raise ValueError("抛个异常")
在函数中我们手动引发了一个 ValueError。
import pyximport pyximport.install(language_level=3) import cython_test cython_test.foo() print("正常执行") """ ValueError: 抛个异常 Exception ignored in: 'cython_test.foo' Traceback (most recent call last): File "...", line 5, in <module> cython_test.foo() ValueError: 抛个异常 正常执行 """
但是在调用的时候,异常并没有中止程序。
补充一点:所谓的异常,本质上就是解释器发现程序在某一步出错了,然后将异常信息写到 stderr 当中,并中止程序,比如索引越界。
但对于 Cython 而言,当返回值是 C 的类型时,异常会被忽略掉。如果希望它能像 Python 一样,将异常抛出来,那么需要通过 except 子句。
cpdef Py_ssize_t foo() except ? -1: raise ValueError("抛个异常")
此时异常就可以正确抛出来了,这也是我们希望的结果。但在 Cython3.0 的时候,即使不使用 except 子句,异常也会正常抛出。
不过注意:except 子句只能用 cdef、cpdef 定义的函数中,并且只有返回值是 C 的类型时,才需要使用 except 子句。如果返回值是 Python 类型,那么是不需要使用 except 子句的(异常依旧会正常抛出),使用了反而会编译错误。
比如将上面的 Py_ssize_t 改成 list 重新编译一下,看看报的什么错。
告诉我们使用 except 子句的函数返回了一个 Python 对象。
可能有人好奇这是什么原因呢?首先底层的 C 函数如果返回的是 PyObject *,那么正常执行时,返回值一定会指向一个合法的 Python 对象。如果执行出错,那么返回值就是 NULL。所以解释器在看到返回值是 NULL 时,就知道一定出错了,于是将 stderr 中的异常输出出来提示开发者即可。
如果返回的是 C 的类型,比如返回一个索引。如果函数执行出错,那么会返回 -1(作为哨兵)。但解释器在看到 -1 时,它并不知道这个 -1 是函数执行出错时返回的,还是正常执行、返回值本身就是 -1。所以此时需要检测异常回溯栈,看看里面有没有异常发生。
对于 Cython 而言也是如此,如果返回值是 Python 类型,那么根据返回值即可判断有没有出现异常。但如果返回的是 C 类型,那么通过返回值就无法判断是否有异常发生,而通过 except ? -1 就是告诉 Cython:返回值是 C 类型时,你还要去检测一下异常回溯栈,如果有异常,你要抛出来。
以上是 Cython0.29 版本时的做法,但从 Cython3.0 开始,我们就不再需要 except 子句了。即使返回值是 C 类型,Cython 也会检测回溯栈。
不过问题来了,如果我们能确保一个函数不会发生异常,那么可不可以不去检测是否有异常发生呢?答案是可以的。
cpdef Py_ssize_t foo() noexcept: pass
通过 noexcept 子句告诉 Cython,这个函数不会出现异常,所以生成的 C 代码不要做任何有关异常的处理。但如果出现异常了,那么异常会被忽略掉。
再次强调:无论是 except 还是 noexcept,针对的都是返回值为 C 类型的函数,如果返回值是 Python 类型,那么不需要这两个子句。因为对于 Python 类型而言,通过返回值(是否为 NULL)即可判断是否有异常发生,而不需要花费额外的代价去检测异常回溯栈。
自定义 Numpy ufunc
Numpy 的 ufunc(通用函数)指的是能够对数组进行元素级别操作的函数,ufunc 是向量化操作的核心,它能够使我们在不编写循环的情况下对数组的每个元素进行计算。
而在 Cython3.0 的时候新增了一个装饰器 cython.ufunc,可以很轻松地自定义 Numpy 的 ufunc。
cimport cython import numpy as np # 装饰成 Numpy 函数时,必须使用 cdef 定义 @cython.ufunc cdef int add(x, y): return x + y arr1 = np.array([1, 2, 3]) arr2 = np.array([1, 2, 3]) print(add(arr1, arr2)) print(np.add(arr1, arr2)) """ [2 4 6] [2 4 6] """ # 因为规定了返回值是 int 类型,所以浮点数会被强制转化为整数 # 如果希望返回值和输入的 Numpy 数组保持一致,那么将返回值类型指定为 object 即可 arr1 = np.array([1.1, 2.2, 3.3]) arr2 = np.array([1.1, 2.2, 3.3]) print(add(arr1, arr2)) print(np.add(arr1, arr2)) """ [2 4 6] [2.2 4.4 6.6] """ @cython.ufunc cdef str to_string(x): return str(x) * 3 print(to_string(np.array([1, 2, 3]))) """ ['111' '222' '333'] """
当前逻辑都比较简单,直接使用 Numpy 提供的函数即可,但如果需求比较复杂,自定义 ufunc 就方便多了。
cimport cython import re import numpy as np @cython.ufunc cdef tuple find_ymd(x): if (match := re.search(r"(\d{4})-(\d{2})-(\d{2})", x)) is not None: y, m, d = match.groups() else: y, m, d = -1, -1, -1 return int(y), int(m), int(d) dates = np.array([ "2021-01-01", "2022-02-02", "2023-03-03", "0123456789" ]) print(find_ymd(dates)) """ [(2021, 1, 1) (2022, 2, 2) (2023, 3, 3) (-1, -1, -1)] """
怎么样,是不是很简单呢?定义的函数只需要处理在元素级别的操作,通过 ufunc 装饰之后调用,会自动作用在数组的每个元素上。
注意:cython.ufunc 只能装饰 cdef 定义的 C 函数,但是装饰之后可以被外界的 Python 访问。当然啦, Numpy 本身也提供了定义 ufunc 的操作,可以了解一下。
另外 Numpy 底层是 C 实现的,在 Cython 使用 Numpy 同样依赖它提供的 C API,但有一部分 API 在现如今的 Numpy 中已经被废弃了(但还可以用)。
而 Cython3.0 默认仍在使用老的 API,只是编译的时候会抛出警告。
可以通过 NPY_NO_DEPRECATED_API 宏,来消除这些警告,并让 Cython 不再使用那些在 Numpy 1.7+ 版本中已经被废弃的 API。
变量私有化
在 Python 的类中,对于以双下划线开头、非双下划线结尾的属性,解释器会自动在开头加上 _类名,表示它是一个私有属性(尽管不是真正的私有)。但对于 Cython 定义的静态类而言却不是这样,它定义的名称都是所见即所得,不会在背后做一些花里胡哨的工作。
cdef class A: # 外界可以访问、也可以修改 cdef public str __name # 外界只能访问、但无法修改 cdef readonly int __age # 外界即无法访问、也无法修改 cdef str address def __init__(self): self.__name = "古明地觉" self.__age = 17 self.address = "地灵殿"
对于静态类而言,属性是否私有,是通过 public 和 readonly 控制的,我们测试一下。
import pyximport pyximport.install(language_level=3) import cython_test a = cython_test.A() print(a.__name) """ 古明地觉 """ a.__name = "古明地恋" print(a.__name) """ 古明地恋 """ print(a.__age) """ 17 """ try: a.__age = 18 except AttributeError as e: print(e) """ attribute '__age' of 'cython_test.A' objects is not writable """ # __name 和 __age 虽然以双下划线开头 # 但对于静态类而言,名称是所见即所得 try: a.address except AttributeError as e: print(e) """ 'cython_test.A' object has no attribute 'address' """ # address 属性因为没有使用 public 或 readonly 对外暴露 # 所以它是绝对的私有,如果不想让外界访问,那么外界是绝对访问不到的
以上是 0.29 版本的 Cython,但如果是 Cython3.0,对于那些双下划线开头的属性,会像 Python 一样,将名字偷偷给你换掉。
比如我们将上面的文件用 3.0 版本的 Cython 编译一下,然后进行测试。
import pyximport pyximport.install(language_level=3) import cython_test a = cython_test.A() # 在 Cython3.0 的时候,和 Python 表现一样 # 这里需要通过 _A__name 和 _A_age 访问 print(a._A__name) """ 古明地觉 """ print(a._A__age) """ 17 """ # 真正的私有,依旧无法访问 try: a.address except AttributeError as e: print(e) """ 'cython_test.A' object has no attribute 'address' """
个人觉得这一点变化虽然比较大,但对我们的影响并不大,因为我们不会在外界使用双下划线开头(非双下划线结尾)的属性或方法。
volatile 修饰符
在 C 中有一个变量修饰符 volatile,它负责在多线程的时候保证变量可见性,什么意思呢?
我们知道 CPU 会将数据从内存读到自身的寄存器中,但相对于寄存器来说,CPU 从内存读取数据的速度还是不够快。所以在寄存器和内存之间还存在着缓存,也就是 L1、L2、L3 Cache。
数据会按照从内存、L3 缓存、L2 缓存、L1 缓存、寄存器的顺序进行传输,同理 CPU 在读数据的时候也会先从 L1 缓存当中找,没有则去 L2 缓存,还没有则去 L3 缓存,最后是内存。
每次从更低级的缓存或内存中读取数据时,都会将数据复制到更高级的缓存中,以便下次能够更快地访问。并且每个核心独有一个 L1 和 L2 缓存,所有核心共用一个 L3 缓存,但这样就会存在一些变量可见性问题。
假设有一个变量 n,它会被两个线程同时读取,这两个线程在两个核上并行执行。因为缓存原理,变量 n 可能分别在两个核的 L2 或 L1 缓存,这样读取速度最快,但会存在一个线程修改之后另一个线程不知道情况,因为 L1 和 L2 缓存是每个核心独有的。
所以 volatile 关键字就是负责预防这种情况,对于被 volatile 修饰的的变量,每次 CPU 需要读取时,都至少要从 L3 读取,并且 CPU 计算结束后,也立刻回写到 L3 当中。这样读写速度虽然减慢了一些,但是避免了变量在每个 core 的 L1、L2 缓存中被单独操作而其它 core 不知道的情况。
而从 Cython 3.0 开始,已经支持 volatile 修饰符了,在 0.29 以及之前是不支持的。
# volatile 修饰的必须是 C 类型的变量 cdef volatile int a = 123
如果不和 C/C++ 的线程进行交互,那么 volatile 基本用不上。
小结
以上就是 Cython3.0 的一些比较重大的变化,我觉得有必要单独说一说。至于 Cython 内部还有一些微小变化,但由于不影响我们的实际使用,这里就不说了,感兴趣可以查阅官网。
在编码层面 Cython0.29 和 Cython3.0 差别并不是太大,升级之后不需要做过多调整。当然啦,如果新特性你用不上的话,那么也可以不升级。
总之赋值表达式、自定义 Numpy ufunc,以及 volatile 修饰符的支持,还是值得体验的。