楔子
前面我们介绍了 Cython 的语法,主要是一些基本的数据结构和函数,通过将静态类型引入到 Python 中,提升 Python 的执行效率。但 Cython 能做的事情还不仅如此,它还可以增强 Python 的类。
不过在了解细节之前,我们必须先了解动态类和静态类之间的区别,这样我们才能明白 Cython 增强 Python 类的做法是什么,以及它为什么要这么做。
动态类和静态类
我们知道 Python 一切皆对象,怎么理解呢?首先在最基本的层次上,一个对象有三样东西:地址、值、类型,通过 id 函数可以获取地址并将每一个对象都区分开来,通过 type 获取类型。至于对象的属性则放在自身的属性字典里面,这个字典可以通过 __dict__ 获取。而获取对象的某一个属性的时候,既可以通过 . 的方式来获取,也可以直接操作属性字典。
每一个对象都由一个类实例化得到,Python 也允许我们使用 class 关键字自定义一个类。使用 class 关键字定义的类,就叫做动态类。
class A: pass print(A.__name__) # A A.__name__ = "B" print(A.__name__) # B
动态类的属性可以被动态修改,解释器允许我们这么做,但是内置的类、和扩展类不行。
try: int.__name__ = "INT" except Exception as e: # 内置类型 和 扩展类型 不允许修改属性 print(e) """ can't set attributes of built-in/extension type 'int' """
内置类和扩展类,统称为静态类,当然这两者本质上一样的,它们都是用 Python/C API 实现的。只不过前者已经由官方实现好了,内嵌在解释器里,比如 int, str, dict 等等,所以称之为内置类;而后者是我们根据业务逻辑,编写 C 扩展时手动实现的,所以叫扩展类,但它们没有什么本质上的区别,所以后面就用扩展类来描述了。
当操作扩展类的时候,操作的是编译好的静态代码,因此在访问内部属性的时候,可以实现快速的 C 一级的访问,这种访问可以显著的提高性能,这就是 Cython 要增强 Python 类的原因。
因为扩展类必须使用 Python/C API 在 C 的级别进行定义,但在 C 里面实现一个类、以及相关方法等等,这个过程很复杂,需要有专业的 Python/C API 知识。而麻烦的好处就是,扩展类的操作要比动态类高效很多。
而 Cython 则允许我们像实现动态类一样,去实现扩展类,这样既能拥有动态类的开发效率,又能有扩展类的运行效率。当然我们心里很清楚,用 Cython 实现的扩展类,和在 C 里面手动使用 Python/C API 实现的扩展类,效果上是一样的,因为 Cython 代码也是要被翻译成使用标准 Python/C API 的 C 代码,只不过这一步不需要我们手动做了。
下面来看看如何在 Cython 里面定义一个扩展类。
扩展类的定义
在 Cython 中定义一个扩展类通过 cdef class 的形式,和 Python 的动态类保持了高度的相似性。
尽管在语法上有着相似之处,但 cdef class 定义的扩展类对所有方法和数据都有快速的 C 级别的访问,这也是和扩展类和动态类之间的一个最显著的区别。而且扩展类和 int, str, list 等内置类都属于静态类,它们的属性默认不可修改。
我们先来写一个 Python 的类(动态类):
class Rectangle: def __init__(self, width, height): self.width = width self.height = height def get_area(self): return self.width * self.height
如果我们是对这个动态类编译的话,那么得到的类依旧是一个动态类,而不是扩展类。所有的操作,仍然是通过动态调度通用的 Python 对象来实现的。只不过由于解释器的开销省去了,因此效率上会提升一点点,但是它无法从静态类型上获益,因为此时的 Python 代码仍然需要在运行时动态调度来解析类型。
改成扩展类的话,我们需要这么做。
cdef class Rectangle: cdef long width, height def __init__(self, w, h): self.width = w self.height = h def get_area(self): return self.width * self.height
此时的关键字我们使用的是 cdef class,意思表示这个类不是一个普通的 Python 动态类,而是一个扩展类。并且在内部,我们还多了一个 cdef long width, height,它负责指定实例 self 所拥有的属性,因为扩展类实例不像动态类实例一样可以自由添加属性,静态类实例有哪些属性需要在类中使用 cdef 事先指定好。
这里的 cdef long width, height 就表示 Rectangle 实例只能有 width 和 height 两个属性、并且类型是 long,因此我们在实例化的时候,参数 w、h 只能传递整数。另外对于 cdef 来说,定义的类是可以被外部访问的,虽然函数不行、但类可以。
文件名叫 cython_test.pyx,我们编译测试一下:
import pyximport pyximport.install(language_level=3) import cython_test rect = cython_test.Rectangle(3, 4) print(rect.get_area()) # 12 try: rect = cython_test.Rectangle("3", "4") except TypeError as e: print(e) # an integer is required
注意:我们在 __init__ 中给实例绑定的属性,都必须在类中使用 cdef 声明,举个例子。
cdef class Rectangle: # 这里我们只声明了width, 没有声明height # 那么是不是意味着这个height可以接收任意类型的对象呢? cdef long width def __init__(self, w, h): self.width = w self.height = h def get_area(self): return self.width * self.height
导入该文件,然后实例化的时候会报错:AttributeError: 'cython_test.Rectangle' object has no attribute 'height'。
凡是没有在类里面使用 cdef 声明的属性,都不可以访问,即使是赋值操作。也就是说,无论是获取还是赋值,self 的属性必须使用 cdef 在类里面声明。我们举一个Python 内置类型的例子:
a = 1 try: a.xx = 123 except Exception as e: print(e) """ 'int' object has no attribute 'xx' """
扩展类和内置类是同级别的,无论是获取属性还是绑定属性,如果想通过 self. 的方式访问,那么一定要在类里面使用 cdef 声明。
所以扩展类无法动态绑定属性,扩展类有哪些属性在定义的时候就已经确定了。因为动态修改、添加属性,都是解释器在解释执行的时候动态操作的。而扩展类直接指向了 C 一级的结构,不需要解释器解释这一步,因此也失去了动态修改的能力。也正因为如此,才能提高效率,很多时候我们不需要动态修改。
另外当一个类实例化后,会给实例对象一个属性字典,通过 __dict__ 获取,它的所有属性以及相关的值都会存储在这里。其实获取一个实例对象的属性,本质上是从属性字典里面获取,instance.attr 等价于 instance.__dict__["attr"],同理修改、创建也是。但是注意:这只是针对动态类而言,而扩展类的实例对象是没有属性字典的。
class A: pass cdef class B: pass print( hasattr(A(), "__dict__"), hasattr(B(), "__dict__") ) # True False
原因很好想,因为动态类的实例可以自由添加属性,最合适的办法就是使用一个字典来存储。而扩展类的实例有哪些属性都是写死的,所以内部会使用数组保存,每个属性一个萝卜一个坑,按照顺序排好,在访问的时候是基于索引访问的,因此效率会更高,也更节省空间。
print(A().__sizeof__()) # 32 print(B().__sizeof__()) # 16
还是那句话,动态添加、删除属性,这些都是解释器在解释字节码的时候动态操作的,在解释的时候允许开发者做一些动态操作。但扩展类不需要解释这一步,它是彪悍的人生,编译之后直接指向了 C 一级的数据结构,因此也就丧失了这种动态的能力。
所以扩展类的实例没有属性字典,无法动态添加和删除属性。当然啦,虽然扩展类的实例没有属性字典,但是扩展类本身是有属性字典的,这一点和动态类一样。只是这个字典不允许修改,因为虽然叫属性字典,但它的类型实际上一个 mappingproxy。
mappingproxy 对象在底层就是对字典进行了一层封装,在字典的基础上移除了增删改操作,只保留了查询,查询 mappingproxy 对象本质上也是在查询内部的字典。
此外,默认情况下,扩展类实例的已有属性,外界也是不可访问的。
cdef class Rectangle: cdef long width, height def __init__(self, w, h): self.width = w self.height = h def get_area(self): return self.width * self.height
和之前的逻辑一样,我们测试一下。
import pyximport pyximport.install(language_level=3) import cython_test rect = cython_test.Rectangle(3, 4) try: rect.width except AttributeError as e: print(e) """ 'cython_test.Rectangle' object has no attribute 'width' """
我们看到没有 width 属性,height 也是同理,默认情况下,已有属性也不可被外界访问。但如果我们就是想修改 self 的已有属性呢?答案是将其暴露给外界即可。
cdef class Rectangle: # 通过cdef public的方式进行声明即可 # 这样的话就会暴露给外界了 cdef public long width, height def __init__(self, w, h): self.width = w self.height = h def get_area(self): return self.width * self.height
import pyximport pyximport.install(language_level=3) import cython_test rect = cython_test.Rectangle(3, 4) print(rect.get_area()) # 12 rect.width = 10 print(rect.get_area()) # 40
通过 cdef public 声明的属性,是可以被外界获取并修改的,但是实例依旧没有属性字典,此时修改属性等价于修改数组元素。因为扩展类的实例有哪些属性是确定的,是通过数组静态存储的。
另外除了 cdef public 之外还有 cdef readonly,同样会将属性暴露给外界,但是只能访问不能修改。我们将代码中的 public 改成 readonly,然后再测试一下。
import pyximport pyximport.install(language_level=3) import cython_test rect = cython_test.Rectangle(3, 4) # 可以访问属性 print(rect.width * rect.height) # 12 try: rect.width = 10 except AttributeError as e: print(e) """ attribute 'width' of 'cython_test.Rectangle' objects is not writable """
我们看到修改属性的时候报错了,告诉我们属性不可写。
所以扩展类的实例有哪些属性,需要在扩展类里面使用 cdef 提前声明好,实例对象在创建之后,这些属性就会顺序存储在数组中,不可以动态添加和删除。另外,即便是已有属性,根据声明方式的不同,也会有不同的表现。
- cdef readonly 类型 变量名:实例属性可以被外界访问,但是不可以被修改;
- cdef public 类型 变量名:实例属性既可以被外界访问,也可以被修改;
- cdef 类型 变量名:实例属性既不可以被外界访问,更不可以被修改;
当然创建实例对象无论是使用 cdef public 还是 cdef readonly,如果是在 Cython 里面创建的话,那么实例属性在任何情况下都是可以自由访问和修改的。因为 Cython 内部会屏蔽扩展类中的 readonly 和 public 声明,它们存在的目的只是为了控制来自外界(Python)的访问。
这里还有一点需要注意,当在类里面使用 cdef 声明变量的时候,其属性就已经绑定在 self 中了。我们举个栗子:
cdef class Rectangle: cdef public long width, height cdef public float area cdef public list lst cdef public tuple tpl cdef public dict d
测试一下:
import pyximport pyximport.install(language_level=3) import cython_test rect = cython_test.Rectangle() print(rect.width) # 0 print(rect.height) # 0 print(rect.area) # 0.0 print(rect.lst) # None print(rect.tpl) # None print(rect.d) # None
即便我们没有定义初始化函数,这些属性也是可以访问的,因为在使用 cdef 声明的时候,它们就已经绑定在上面了,只不过这些属性对应的值都是零值。
所以 self.xxx = ... 相当于是为绑定在 self 上的属性重新赋值,但赋值的前提是 xxx 必须已经是 self 的一个属性,否则是没办法赋值的。而 xxx 如果想成为 self 的一个属性,那么就必须在类里面使用 cdef 进行声明。
但是问题来了,这毕竟是在类里面声明的,那么类是否可以访问呢?
import pyximport pyximport.install(language_level=3) import cython_test print(cython_test.Rectangle.width) """ <attribute 'width' of 'cython_test.Rectangle' objects> """ # 内置的类也是如此 print(int.numerator) """ <attribute 'numerator' of 'int' objects> """
答案是可以访问,不过类访问没有太大意义,打印的结果只是告诉你这是实例的一个属性。
如果想设置类属性,不需要使用 cdef,而是像动态类一样去定义类属性。
在类里面使用 cdef 声明属性的时候不可以赋初始值(会有一个零值),否则编译时会报错,赋值这一步应该在初始化函数中完成。但不使用 cdef、而是像动态类一样定义常规类属性的话,是需要赋初始值的(这是显然的,否则就出现 NameError了)。