楔子
扩展类还提供了很多动态类不具备的特性,可以让我们对类进行更细粒度的控制。而这些特性是动态类所不具备的,或者说解释器没有将这些特性的修改权暴露给动态类。
那么下面就来看一看这些特性。
创建不可被继承的类
我们创建扩展类的时候,可以让该类不能被其它类继承。
cimport cython # 通过 cython.final 进行装饰 # 那么这个类就不可被继承了 @cython.final cdef class NotInheritable: pass
通过 cython.final,那么被装饰的类就是一个不可继承类,不光是外界普通的 Python 类,内部的扩展类也是不可继承的。
文件名为 cython_test.pyx,我们导入测试一下:
import pyximport pyximport.install(language_level=3) import cython_test class A(cython_test.NotInheritable): pass """ TypeError: type 'cython_test.NotInheritable' is not an acceptable base type """
告诉我们 NotInheritable 不是一个可以被继承的基类。
另外,动态类其实也可以实现这一点,但它需要实现一个魔法函数。
class NotInheritable: def __init_subclass__(cls, **kwargs): raise TypeError("NotInheritable 不可被继承") class A(NotInheritable): pass """ Traceback (most recent call last): File "...", line 6, in <module> class A(NotInheritable): File "...", line 4, in __init_subclass__ raise TypeError("NotInheritable 不可被继承") TypeError: NotInheritable 不可被继承 """
__init_subclass__ 是一个钩子函数,如果所在的类被继承时,就会触发该函数的调用,然后我们在内部手动 raise 一个异常。这种做法也能实现不可被继承的类,只不过此时的逻辑是我们手动实现的。
其实 __init_subclass__ 最大的用处还是元编程,因为在一些简单的场景下,它可以替代元类,举个例子:
class Base: def __init_subclass__(cls, **kwargs): print(cls) print(kwargs) for k, v in kwargs.items(): type.__setattr__(cls, k, v) class A(Base, a=1, b=2): pass """ <class '__main__.A'> {'a': 1, 'b': 2} """ # A 继承了 Base,所以会立即触发 __init_subclass__ # 参数 cls 就是继承 Base 的类,这里是 A # 然后 **kwargs,就是继承时指定的关键字参数 print(A.a) print(A.b) """ 1 2 """ # 在 __init_subclass__ 里面 # 我们将相关属性绑定在了类 A 上面 # 当然也可以绑定其它属性
所以当元编程的场景不复杂时,我们可以使用 __init_subclass__ 来代替元类。
让扩展类的实例可以被弱引用
Python 的每一个对象都会有一个引用计数,当一个变量引用它时,引用计数会增加一。但我们可以对一个对象进行弱引用,弱引用的特点就是不会使对象的引用计数增加,举个例子:
import sys import weakref class Girl: def __init__(self): self.name = "古明地觉" g = Girl() # 因为 g 作为 sys.getrefcount 的参数 # 所以引用计数会多 1 print(sys.getrefcount(g)) # 2 g2 = g # 又有一个变量引用 # 所以引用计数增加 1,结果是 3 print(sys.getrefcount(g)) # 3 # 注意:这里是一个弱引用,不会增加引用计数 g3 = weakref.ref(g) print(sys.getrefcount(g)) # 3 print(g3) # <weakref at 0x00...; to 'Girl' at 0x000...> # 删除 g、g2,对象的引用计数会变为 0 # 此时再打印 g3,会发现引用的对象已经被销毁了 del g, g2 print(g3) # <weakref at 0x00000222F0ED13B0; dead>
默认情况下,动态类实例对象都是可以被弱引用的。
那么问题来了,扩展类的实例对象可不可以被弱引用呢?我们拿内置类型来试试吧,因为 Cython 中定义的扩展类和内置类是等价的,它们同属于静态类。如果内置类的实例对象不可以被弱引用的话,那么 Cython 中定义的扩展类也是一样的结果。
import weakref try: weakref.ref(123) except TypeError as e: print(e) """ cannot create weak reference to 'int' object """ try: weakref.ref("") except TypeError as e: print(e) """ cannot create weak reference to 'str' object """ try: weakref.ref(()) except TypeError as e: print(e) """ cannot create weak reference to 'tuple' object """
我们看到内置类的实例是不可以被弱引用的,那么扩展类必然也是如此。其实也很好理解,因为要保证速度,自然会丧失一些 "花里胡哨" 的功能。但是问题来了,扩展类是我们自己实现的,我们就是要让其实例可以被弱引用该怎么办呢?
cdef class A: # 类似于动态类中的 __slots__ # 只需要声明一个 __weakref__ 即可 cdef object __weakref__ cdef class B: pass
测试一下:
import weakref import pyximport pyximport.install(language_level=3) import cython_test # A 实例是可以被引用的 # 因为声明了 __weakref__ print(weakref.ref(cython_test.A())) """ <weakref at 0x0000016E962D2220; dead> """ # 但是 B 实例默认则是不允许的 # 因为它没有声明 __weakref__ try: print(weakref.ref(cython_test.B())) except TypeError as e: print(e) """ cannot create weak reference to 'cython_test.B' object """
以上就是让扩展类实例支持弱引用的方式。
扩展类实例对象的销毁以及垃圾回收
当对象的引用计数为 0 时,会被销毁,这个销毁可以是放入缓存池中、也可以是交还给系统堆,当然不管哪一种,我们的程序都不能再用了。
name = "古明地觉" name = "古明地恋"
在执行完第二行的时候,由于 name 指向了别的字符串,因此第一个字符串对象的引用计数为 0、会被销毁。而这个过程在底层会调用 tp_dealloc,Python 的类对象在底层对应的都是一个 PyTypeObject 结构体实例,其内部有一个 tp_dealloc 成员专门负责其实例对象的销毁。
因此判断一个 Python 对象是否会被销毁非常简单,就看它的引用计数,只要引用计数为 0,就会被销毁,不为 0,就不会被销毁,就这么简单。但是引用计数最大的硬伤就是它解决不了循环引用,所以 Python 才会有垃圾回收机制,专门负责解决循环引用。
class Object: pass def make_cycle_ref(): x = Object() y = [x] x.attr = y
当我们调用 make_cycle_ref 函数时,就会出现循环引用,y 内部引用了 x 指向的对象、x 内部又引用了 y 指向的对象。如果我们将垃圾回收机制关闭的话,即使函数退出,对象也不会被回收。
而如果想解决这一点,那么就必须在销毁对象之前先将对象内部引用的其它对象的引用计数减一,也就是打破循环引用,这便是 Python 底层的垃圾回收器所做的事情。也就是说,对于出现循环引用的对象,垃圾回收器会再次将它们的引用计数减一。
而如果想做到这一点,那么就必须在 tp_traverse 中指定垃圾回收器要跟踪的属性。PyTypeObject 内部有一个 tp_traverse 成员,它接收一个函数,在内部指定要跟踪的属性(x 的话就是 attr,y 由于是一个列表,它里面的每一个元素都要跟踪)。
垃圾回收器根据 tp_traverse 指定的要跟踪的属性,找到这些属性引用的其它对象(循环引用);然后 PyTypeObject 内部还有一个 tp_clear,在这里面会将循环引用的其它对象的引用计数减 1,所以寻找(tp_traverse)和清除(tp_clear)是在两个函数中实现的。
而当引用计数为 0 了,那么再由引用计数机制负责销毁。所以引用计数是判断对象是否被销毁的唯一准则,为 0 则调用 tp_dealloc 销毁掉,不为 0 则保留。至于垃圾回收只是为了弥补引用计数机制的不足而引入的,负责将循环引用带来的影响给规避掉,而对象的回收还是由引用计数机制负责的。
- tp_traverse:指定垃圾回收器要跟踪的属性,垃圾回收器会找到这些属性引用的对象;
- tp_clear:将 tp_traverse 中指定属性引用的对象的引用计数减 1;
- tp_dealloc:负责对象本身被销毁时的工作,在扩展类中可以用 __dealloc__ 实现;
禁用 tp_clear
对于扩展类而言,默认是支持垃圾回收的,底层会自动生成 tp_traverse 和 tp_clear,显然这也是我们期待的结果。但在某些场景下,就不一定是我们期待的了,比如你需要在 __dealloc__ 中清理某些外部资源,但是你的对象又恰好在循环引用当中,举个例子:
cdef class DBCursor: cdef DBConnection conn cdef DBAPI_Cursor *raw_cursor # ... def __dealloc__(self): DBAPI_close_cursor(self.conn.raw_conn, self.raw_cursor)
当我们在销毁对象时,想要通过数据库连接来关闭游标,但如果游标碰巧处于循环引用当中,那么垃圾回收器可能会删除数据库连接,从而无法对游标进行清理。所以解决办法就是禁用该扩展类的 tp_clear,而实现方式可以通过 no_gc_clear 装饰器。
cimport cython @cython.no_gc_clear cdef class DBCursor: cdef DBConnection conn cdef DBAPI_Cursor *raw_cursor # ... def __dealloc__(self): DBAPI_close_cursor(self.conn.raw_conn, self.raw_cursor)
如果使用 no_gc_clear,那么多个引用当中至少有一个没有 no_gc_clear 的对象,否则循环引用无法被打破,从而引发内存泄露。但是说实话,这种情况很少见,因此 no_gc_clear 这个装饰器不常用。
禁用垃圾回收
垃圾回收是为了解决循环引用而存在的,解释器会将那些可以产生循环引用的对象放在可收集对象链表(零代、一代、二代)上,然后从根节点出发进行遍历,而显然链表上的对象越少,垃圾回收的耗时就越短。
默认情况下,对于解释器而言,只要一个对象可以发生循环引用、或者说有能力发生循环引用,都会被挂到可收集对象链表上。至于实际上到底有没有发生,需要检测的时候才知道。
但如果一个对象虽然可以发生循环引用,但是我们能保证实际情况中它不会发生,那么就可以让这个对象不参与垃圾回收(减少链表上的对象个数),从而减少垃圾回收的开销,特别是程序中存在大量这种对象时。
比如我们可以定义一个不可能发生循环引用的扩展类:
cdef class Girl: cdef public str name cdef public int age
扩展类的实例是可以发生循环引用的,所以它默认会被挂到链表上,但是很明显,对于我们当前这个扩展类而言,它的实例对象不会发生循环引用。因为内部只有两个属性,分别是字符串和整数,都是不可变对象,再加上扩展类无法动态添加属性,所以实际情况下 Girl 的实例不可能产生循环引用。
但是解释器不会做这种假设,只要有能力产生循环引用,都会将它挂到链表上,因此我们可以使用 no_gc 装饰器来阻止解释器这么做。
cimport cython @cython.no_gc cdef class Girl: cdef public str name cdef public int age def __init__(self, name, age): self.name = name self.age = age
此时 Girl 的实例对象就不会参与垃圾回收了,特别当程序中要创建大量的 Girl 的实例对象,程序的运行效率也会得到提升。但是注意:使用 no_gc 一定要确保不会发生循环引用,如果给上面的类再添加一个声明。
cimport cython @cython.no_gc cdef class Girl: cdef public str name cdef public int age cdef public list hobby def __init__(self, name, age, hobby): self.name = name self.age = age self.hobby = hobby
这个时候就必须要小心了,因为实例对象的 hobby 属性是列表、也就是可变对象,而列表是可以发生循环引用的。
虽然我们很少会写出产生循环引用的代码,但是为了保险起见,如果出现了类型为可变对象的属性,那么还是建议将 no_gc 这个装饰器给去掉。
启用 trashcan
在 Python 中,我们可以创建具有深度递归的对象,比如:
L = None for i in range(2 ** 20): L = [L] del L
此时的 L 就是一个嵌套了 2 ** 20 层的列表,当我们删除 L 的时候,会先销毁 L[0]、然后销毁 L[0][0],以此类推,直到递归深度为 2 ** 20。
而这样的深度毫无疑问会溢出 C 的调用栈,导致解释器崩溃。但事实上我们在 del L 的时候解释器并没有崩溃,原因就是 CPython 发明了一种名为 trashcan 的机制,它通过延迟销毁的方式来限制销毁的递归深度。
比如我们可以通过 CPython 源代码查看列表销毁时的动作,由 Object/listobject.c 的 list_dealloc 函数负责。
static void list_dealloc(PyListObject *op) { Py_ssize_t i; PyObject_GC_UnTrack(op); // 限制销毁的递归深度 Py_TRASHCAN_BEGIN(op, list_dealloc) /* ... ... ... */ Py_TRASHCAN_END }
但是对于早期的 Cython 而言,扩展类默认是没有开启 trashcan 机制的:
cdef class A: def __init__(self): cdef list L = None cdef Py_ssize_t i for i in range(2 ** 20): L = [L] del L
如果你导入 A 这个类并实例化,那么你的内存占用率会越来越高,最终程序崩溃。如果希望扩展类实例对象也能开启 trashcan 机制,同样可以使用装饰器:
cimport cython @cython.trashcan(True) cdef class A: def __init__(self): cdef list L = None cdef Py_ssize_t i for i in range(2 ** 20): L = [L] del L
如果一个类开启了 trashcan 机制,那么继承它的子类也会开启,如果不想开启,则需要通过 trashcan(False) 显式关闭。
但这是对早期的 Cython 而言,目前的话,Cython 的扩展类会自动开启 trashcan 机制。所以这个特性我们了解一下就好,在工作中不需要使用它。
扩展类实例的 freelist
有些时候我们需要多次对某个类执行实例化和销毁操作,这也意味着会有多次内存的创建与销毁。那么为了减少开销,我们能不能像解释器底层采用的缓存池策略一样,每次销毁的时候不释放内存,而是放入一个链表(freelist)中呢,这样申请的时候直接从链表中获取即可。
cimport cython # 声明一个可以容纳 8 个实例的链表 # 每当销毁的时候就会放入到链表中,最多可以放 8 个 # 如果销毁第 9 个实例,那么就不会再放到 freelist 里了 @cython.freelist(8) cdef class Girl: cdef str name cdef int age def __init__(self, name, age): self.name = name self.age = age girl1 = Girl("satori", 17) girl2 = Girl("koishi", 16) girl3 = Girl("marisa", 15) # 查看地址 print(<Py_ssize_t> <void *> girl1) print(<Py_ssize_t> <void *> girl2) print(<Py_ssize_t> <void *> girl3) """ 3000841100144 3000841099952 3000841100048 """ # 会放入到 freelist 中,里面的元素个数加 1 del girl1, girl2, girl3 # 从 freelist 中获取,里面元素个数减 1 # 此时无需重新申请内存 girl1 = Girl("satori", 17) girl2 = Girl("koishi", 16) girl3 = Girl("marisa", 15) print(<Py_ssize_t> <void *> girl1) print(<Py_ssize_t> <void *> girl2) print(<Py_ssize_t> <void *> girl3) """ 3000841100048 3000841099952 3000841100144 """ # freelist 放入元素和获取元素的顺序是相反的 # 因此两次打印的地址正好也是相反的
所以对于哪些数量不多,但是需要频繁创建和销毁的实例对象,可以使用 freelist 保存起来。
关于扩展类的一些附加特性,我们就暂时说到这里。
E N D