《Cython系列》4. Cython 中扩展类的定义以及使用方式,分析它和 Python 中普通的类的差异

楔子

上一篇博客中,我们介绍 Cython 给 Python 赋予的一些额外特性,以及这些特性的使用方式,但那主要是基本的数据类型和函数。Cython 也可以增强 Python 的类,不过在了解细节之前,我们必须首先了解 Python 类和扩展类之间的区别,这样我们才能明白 Cython 增强 Python 类的做法是什么,以及它为什么要这么做。

Python 类和扩展类之间的差异

首先 Python 中 "一切皆对象",怎么理解呢?首先在最基本的层次上,一个对象有三样东西:地址、值、类型,我们通过 id 函数可以获取地址并将每一个对象都区分开来,使用 type 获取类型。Python 中对象有很多属性,这些属性都放在自身的属性字典里面,这个字典可以通过 __dict__ 获取。我们调用对象的某一个属性的时候,可以通过 . 的方式来调用,Python 也允许我们通过 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 中定义类,还可以直接使用 Python/C API 在 C 级别创建自己的类型,这样的类型称之为扩展类、或者扩展类型(说白了在 C 中实现的类就叫做扩展类)。

Python 解释器本来就是 C 写的,所以我们可以在 C 的层面上面实现 Python 的任何对象,类也是如此。Python 中自定义的类和内置的类在 C 一级的结构是一致的,所以我们只需要按照 Python/C API 提供的标准来编写即可。但还是那句话,使用 C 来编写会比较麻烦,因为本质上就是写 C 语言。

当我们操作扩展类的时候,我们操作的是编译好的静态代码,因此在访问内部属性的时候,可以实现快速的 C 一级的访问,这种访问可以显著的提高性能。但是在扩展类的实现、以及处理相应的实例对象和在纯 Python 中定义类是完全不同的,需要有专业的 Python/C API 的知识,不适合新手。

这也是 Cython 要增强 Python 类的原因:Cython 使得我们创建和操作扩展类就像操作 Python 中的类一样。在Cython中定义一个扩展类通过 cdef class 的形式,和 Python 中的常规类保持了高度的相似性。

尽管在语法上有着相似之处,但是 cdef class 定义的类对所有方法和数据都有快速的 C 级别的访问,这也是和扩展类和 Python 中的普通类之间的一个最显著的区别。而且扩展类和 int、str、list 等内置的类都属于静态类,它们的属性是不可修改的。

Cython 中的扩展类

写一个 Python 中的类吧。

class Rectangle:
    
    def __init__(self, width, height):
        self.width = width
        self.height = height
        
    def get_area(self):
        return self.width * self.height

这个类是在 Python 级别定义的,可以被 CPython 编译的。我们定义了矩形的宽和高,并且提供了一个方法,计算面积。这个类是可以动态修改的,我们可以指定任意的属性。

如果我们是对这个 Python 类编译的话,那么得到的类依旧是一个纯 Python 类,而不是扩展类。所有的操作,仍然是通过动态调度通用的 Python 对象来实现的。只不过由于解释器的开销省去了,因此效率上会提升一点点,但是它无法从静态类型上获益,因为此时的 Python 代码仍然需要在运行时动态调度来解析类型。

改成扩展类的话,我们需要这么做。

# cython_test.pyx
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 来说,定义的类是可以被外部访问的,虽然函数不行、但类可以。

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
import pyximport
pyximport.install(language_level=3)

import cython_test

rect = cython_test.Rectangle(3, 4)
"""
File "cython_test.pyx", line 7, in cython_test.Rectangle.__init__
    self.height = h
AttributeError: 'cython_test.Rectangle' object has no attribute 'height'
"""

凡是在没有在 cdef 中声明的,都不可以赋值给 self,可能有人发现了这不是访问,而是添加呀。我添加一个属性咋啦,没咋,无论是获取还是赋值,self 中的属性必须使用 cdef 在类中声明。我们举一个Python 内置类型的例子吧:

a = 1
try:
    a.xx = 123
except Exception as e:
    print(e)  # 'int' object has no attribute 'xx'

一样等价,我们的扩展类和内建的类是同级别的,一个属性如果想通过 self. 的方式来调用,那么一定要在类里面通过 cdef 声明。

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.a = "xx"
except AttributeError as e:
    print(e)  # 'cython_test.Rectangle' object has no attribute 'a'
"""
如果想动态修改、添加属性,那么需要解释器在解释的时候来动态操作
但扩展类和内置的类是等价的,直接指向了C一级的结构,不需要解释器解释这一步,因此也失去了动态修改的能力
也正因为如此,才能提高效率。因为很多时候,我们不需要动态修改。
当一个类实例化之后,会给实例对象一个属性字典,通过__dict__获取,它的所有属性以及相关的值都会存储在这里
其实获取一个实例对象的属性,本质上是从属性字典里面获取,instance.attr 等价于instance.__dict__["attr"],同理修改、创建也是。
但是注意:这只是针对普通的 Python 类而言,但扩展类的实例对象内部是没有 __dict__ 的。
"""

try:
    rect.__dict__
except AttributeError as e:
    print(e)  # 'cython_test.Rectangle' object has no attribute '__dict__'

# 不光 __dict__, 你连 self 本身的属性都无法访问
try:
    rect.width
except AttributeError as e:
    print(e)  # 'cython_test.Rectangle' object has no attribute 'width'
# 提示我们 self 没有 width 属性,所以我们实例化之后再想修改是不行的,连获取都获取不到
# 只能调用它的一些方法罢了。

所以内建的类和扩展类是完全类似的,其实例对象都没有属性字典,至于类本身是有属性字典的,但是这个字典不可修改。因为虽然叫属性字典,但它的类型实际上是一个 mappingproxy。

import pyximport
pyximport.install(language_level=3)

import cython_test

try:
    int.__dict__["a"] = 123
except TypeError as e:
    print(e)  # 'mappingproxy' object does not support item assignment
 
try:
    cython_test.Rectangle.__dict__["a"] = 123
except TypeError as e:
    print(e)  # 'mappingproxy' object does not support item assignment

还是那句话,动态设置、修改、获取、删除属性,这些都是在解释器解释字节码的时候动态操作的,在解释的时候允许你做一些这样的骚操作。但是内置的类和扩展类不需要解释这一步,它们是彪悍的人生,直接指向了 C 一级的数据结构,因此也就丧失了这种动态的能力。

但是扩展类毕竟是我们自己指定的,如果我们就是想修改 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.width)  # 3
print(rect.get_area())  # 12

rect.width = 123
print(rect.get_area())  # 492

try:
    rect.__dict__
except AttributeError as e:
    print(e)  # 'cython_test.Rectangle' object has no attribute '__dict__'
# 属性字典依旧是没有的

通过 cdef public 声明的属性,是可以被外界获取并修改的,除了 cdef public 之外还有 cdef readonly,同样会将属性暴露给外界,但是只能访问不能修改。

cdef class Rectangle:

    cdef readonly 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.width)  # 3

try:
    rect.width = 123
except AttributeError as e:
    print(e)  # attribute 'width' of 'cython_test.Rectangle' objects is not writable
  • 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>

答案是可以访问,不过类访问没有太大意义,打印的结果只是告诉你这是实例的一个属性。如果想设置类属性,就不需要使用 cdef,而是像动态类一样去定义类属性。并且在类里面使用 cdef 的声明属性的时候不可以赋初始值,否则报错,赋值这一步应该在初始化函数中完成。但不使用 cdef、而是像动态类一样定义常规类属性的话,是可以赋初始值的(这是显然的,否则就出现 NameError了)。

C 一级的构造函数和析构函数

每一个实例对象都对应了一个 C 结构体,其指针就是 Python 调用 __init__ 函数里面的 self 参数。当 __init__ 参数被调用时,会初始化 self 参数上的属性,而且 __init__ 参数是自动调用的。但是我们知道在 __init__ 参数调用之前,会先调用 __new__ 方法, __new__ 方法的作用就是为创建的实例对象开辟一份内存,然后返回其指针并交给 self。在 C 级别就是,在调用 __init__ 之前,实例对象指向的结构体必须已经分配好内存,并且所有结构字段都处于可以接收初始值的有效状态。

Cython 扩充了一个名为 __cinit__ 的特殊方法,用于执行 C 级别的内存分配和初始化。不过对于之前定义的 Rectangle 类的 __init__ 方法,因为内部的字段接收的值是两个 double,不需要 C 级别的内存分配。但如果需要 C 级别的内存分配,那么就不可以使用 __init__ 了,而是需要使用 __cinit__。

# 导入相关函数,malloc,free
# 如果不熟悉的话,建议去了解一下C语言
from libc.stdlib cimport malloc, free


cdef class A:
    cdef:
        unsigned int n
        double *array  # 一个数组,存储了double类型的变量

    def __cinit__(self, n):
        self.n = n
        # 在C一级进行动态分配内存
        self.array = <double *>malloc(n * sizeof(double))
        if self.array == NULL:
            raise MemoryError()

    def __dealloc__(self):
        """如果进行了动态内存分配,也就是定义了 __cinit__,那么必须要定义 __dealloc__
        否则在编译的时候会抛出异常:Storing unsafe C derivative of temporary Python reference
        然后我们释放掉指针指向的内存
        """
        if self.array != NULL:
            free(self.array)

    def set_value(self):
        cdef long i
        for i in range(self.n):
            self.array[i] = (i + 1) * 2

    def get_value(self):
        cdef long i
        for i in range(self.n):
            print(self.array[i])
import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A(5)
a.set_value()
a.get_value()
"""
2.0
4.0
6.0
8.0
10.0
"""

所以 __cinit__ 是用来进行 C 一级内存的动态分配的,另外我们说如果在 __cinit__ 通过 malloc 进行了内存分配,那么必须要定义 __dealloc__ 函数将指针指向的内存释放掉。当然即使我们不释放也没关系,只不过可能发生内存泄露(雾),但是 __dealloc__ 这个函数是必须要被定义,它会在实例对象回收时被调用。

这个时候可能有人好奇了,那么 __cinit__ 和 __init__ 函数有什么区别呢?区别还是蛮多的,我们细细道来。

首先它们只能通过 def 来定义,另外在不涉及 malloc 动态分配内存的时候, __cinit__ 和 __init__ 是等价的。然而一旦涉及到 malloc,那么动态分配内存只能在 __cinit__ 中进行,如果这个过程写在了 __init__ 函数中,比如将我们上面例子的 __cinit__ 改为 __init__ 的话,你会发现 self 的所有变量都没有设置进去、或者说设置失败,并且其它的方法若是引用了 self.array,那么还会导致丑陋的段错误。

还有一点就是,__cinit__ 函数会在 __init__ 函数之前调用,我们实例化一个扩展类的时候,参数会先传递给 __cinit__,然后 __cinit__ 再将接收到的参数原封不动的传递给 __init__。

cdef class A:
    cdef public:
        unsigned int a, b

    def __cinit__(self, a, b):
        print("__cinit__")
        self.a = a
        self.b = b
        print(self.a, self.b)

    def __init__(self, c, d):
        """__cinit__ 中接收两个参数
        然后会将参数原封不动的传递到这里,所以这里也要接收两个参数
        参数名可以不一致,但是个数要匹配
        """
        print("__init__")
        print(c, d)
import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A(111, 222)
"""
__cinit__
111 222
__init__
111 222
"""
print(a.a)  # 111
print(a.b)  # 222

注意:__cinit__ 只有在涉及 C 级别内存分配的时候才会出现,如果没有涉及那么使用 __init__ 就可以,虽然在不涉及 malloc 的时候这两者是等价的,但是 __cinit__ 会比 __init__ 的开销要大一些。而如果涉及 C 级别内存分配,那么建议 __cinit__ 只负责内存的动态分配,__init__ 负责属性的创建。

from libc.stdlib cimport malloc, free


cdef class A:

    cdef public:
        unsigned int a, b, c
    # 这里的 array 不可以使用 public 或者 readonly
    # 原因很简单,因为一旦指定了 public 和 readonly,就意味着这些属性是可以被 Python 访问的
    # 所以需要其能够转化为 Python 中的对象,而 C 中的指针,除了 char *, 都是不能转化为 Python 对象的
    # 因此这里的 array 一定不能暴露给外界,否则编译出错,提示我们:double * 无法转为 Python 对象
    cdef double *array

    def __cinit__(self, *args, **kwargs):
        # 这里面只做内存分配,设置属性交给__init__
        self.array = <int *>malloc(3 * sizeof(int))

    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c 
    
    def __dealloc__(self):
        free(self.array)

我们上面使用了 malloc 函数进行内存动态申请、free 函数进行内存释放,但是相比 malloc、free 这种 C 级别的函数,Python 提供了更受欢迎的用于内存管理的函数,这些函数对较小的内存块进行了优化,通过避免昂贵的操作系统调用来加快分配速度。

from cpython.mem cimport PyMem_Malloc, PyMem_Realloc, PyMem_Free


cdef class AllocMemory:

    cdef double *data

    def __cinit__(self, size_t number):
        # 等价于 C 的 malloc
        self.data = <double *> PyMem_Malloc(sizeof(double) * number)
        if self.data == NULL:
            raise MemoryError("内存不足,分配失败")
        print(f"分配了 {sizeof(double) * number} 字节的内存")

    def resize(self, size_t new_number):
        # 等价于 C 的 realloc,一般是容量不够了才会使用
        # 相当于是申请一份更大的内存,然后将原来的 self.data 里面的内容拷过去
        # 如果申请的内存比之前还小,那么内容会发生截断
        mem = <double *> PyMem_Realloc(self.data, sizeof(double) * new_number)
        if mem == NULL:
            raise MemoryError("内存不足,分配失败")
        self.data = mem
        print(f"重新分配了 {sizeof(double) * new_number} 字节的内存")

    def __dealloc__(self):
        """定义了 __cinit__,那么必须定义 __dealloc__"""
        if self.data != NULL:
            PyMem_Free(self.data)
        print("内存被释放")

Python 提供的这些内存分配、释放的函数和 C 提供的原生函数,两者的使用方式是一致的,事实上 PyMem_* 系列函数只是在 C 的 malloc、realloc、free 基础上做了一些简单的封装。但不管是哪种,一旦分配了,那么就必须要进行释放,否则只有等到 Python 进程退出之后它们才会被释放,这种情况便称之为内存泄漏。

import pyximport
pyximport.install(language_level=3)

import cython_test

alloc_memory = cython_test.AllocMemory(50)
alloc_memory.resize(60)
del alloc_memory
print("--------------------")
"""
分配了 400 字节的内存
重新分配了 480 字节的内存
内存被释放
--------------------
"""

我们看到是没有任何问题的,因此以后在涉及动态内存分配的时候,建议以后在工作中使用 PyMem_* 系列函数。当然后面为了演示方便,我们还是使用 malloc 和 free。

cdef和cpdef方法

我们之前使用了 cdef 和 cpdef,我们说:cdef 可以定义变量和函数,但是不能被 Python 直接访问;可以定义一个类,能直接被外界访问。而 cpdef 专门用于定义函数,cpdef 定义的函数既可以在 Cython 内部访问,也可以被外界访问,因为它定义了两个版本的函数:一个是高性能的纯C版本(此时等价于 cdef,至于为什么高效,因为它是 C 一级的,直接指向了具体数据结构,当然还有其它原因,我们之前都说过的),另一个是 Python 包装器(相当于我们手动定义的 Python 函数),所以我们还要求使用cpdef定义的函数的参数和返回值类型必须是 Python 可以表示的,像 char * 之外的指针就不行。

那么同理它们也可以作用于方法,当然方法也是实例对象在获取函数的时候进行封装得到的,所以一样的道理。但是注意:cdef 和 cpdef 只能修饰 cdef class 定义的静态类里面的方法,如果是 class 定义的纯 Python 类,那么内部是不可以出现 cdef 或者 cpdef 的。

cdef class A:

    cdef public:
        long a, b

    def __init__(self, a, b):
        self.a = a
        self.b = b

    cdef long f1(self):
        return self.a * self.b

    cpdef long f2(self):
        return self.a * self.b
import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A(11, 22)
print(a.f2())  # 242
a.f1()
"""
    a.f1()
AttributeError: 'cython_test.A' object has no attribute 'f1'
"""

cdef 和 cpdef 之间在函数上的差异,在方法中得到了同样的体现。

此外,这个类的实例也可以作为函数的参数,这个是肯定的。

cdef class A:

    cdef public:
        long a, b

    def __init__(self, a, b):
        self.a = a
        self.b = b

    cpdef long f2(self):
        return self.a * self.b
    

def func(self_lst):
    s = 0
    for self in self_lst:
        s += self.f2()
    return s
import pyximport
pyximport.install(language_level=3)

import cython_test

a1 = cython_test.A(1, 2)
a2 = cython_test.A(2, 4)
a3 = cython_test.A(2, 3)
print(cython_test.func([a1, a2, a3]))  # 16

这是 Python 的特性,一切都是对象,尽管没有指明 self_lst 是什么类型,但只要它可以被 for 循环即可;尽管没有指明 self_lst 里面的元素是什么类型,只要它有 f2 方法即可。并且这里的 func 可以在 Cython 中定义,同样可以在 Python 中定义,这两者是没有差别的,因为都是 Python 中的函数。另外在遍历的时候仍然需要确定这个列表里面的元素是什么,意味着列表里面的元素仍然是 PyObject *,它需要获取类型、转化、属性查找,因为 Cython 不知道类型是什么、导致其无法优化。但如果我们规定了类型,那么再调用 f2 的时候,那么会直接指向 C 一级的数据结构,因此不需要那些无用的检测。

cdef class A:

    cdef public:
        long a, b

    def __init__(self, a, b):
        self.a = a
        self.b = b

    cpdef long f2(self):
        return self.a * self.b
    

# 规定接收一个 list,返回一个 long, 它们都是静态的,总之静态类型定义越多速度会越快
cpdef long func(list self_lst):
    # 声明 long 类型的 s,A 类型的 self
    # 我们下面使用的是 s = s + self.f2(), 所以这里的s要赋一个初始值0
    cdef long s = 0
    cdef A self
    for self in self_lst:
        s += self.f2()
    return s

调用得到的结果是一样的,可以自己尝试一下。这样的话速度会变快很多,因为我们在循环的时候,规定了变量类型,并且求和也是一个只使用 C 的操作,因为 s 是一个 double。

这个版本的速度比之前快了 10 倍,这表明类型化比非类型化要快了 10 倍。如果我们删除了 cdef A self,也就是不规定其类型,而还是按照 Python 的语义来调用,那么速度仍然和之前一样,即便使用 cpdef 定义。所以重点在于指定类型为静态类型,只要规定好类型,那么就可以提升速度;而 Cython 是为 Python 服务的,肯定要经常使用 Python 的类型,那么提前规定好、让其指向 C 一级的数据结构,速度会提升很多。如果是 int 和 float,那么会自动采用 C 中的 int 和 float,当然怕溢出的话就使用 long、size_t、ssize_t、double,这样速度就更加快速了。因此重点是一定要静态定义类型,只要类型明确那么就能进行大量的优化。

Python 慢有很多原因,其中一个原因就是它无法对类型进行优化,以及对象分配在堆上。无法基于类型进行优化,就意味着每次都要进行大量的检测,当然这些我们前面已经说过了,如果规定好类型,那么就不用兜那么大圈子了;而对象分配在堆上这是无法避免的,只要你用 Python 的对象,都是分配在堆上,所以对于整型和浮点型,我们通过定义为 C 的类型使其分配在栈上,能够更加的提升速度。总之记住一句话:Cython 加速的关键就在于,类型的静态声明,以及对整数和浮点使用 C 中 long 和 double。

方法中给参数指定类型

无论是 def、cdef、cpdef,都可以给参数规定类型,如果类型传递的不对就会报错。比如:上面的 func 函数如果是普通的 Python 函数,那么内部的参数对于 Python 而言只要能够被 for 循环即可,所以它可以是列表、元组、集合。但是我们上面的 func 规定了类型,参数只能传递 list 对象或者其子类的实例对象,如果传递 tuple 对象就会报错。

然后我们来看看 __init__。

cdef class A:

    cdef public:
        long a, b

    def __init__(self, float a, float b):
        self.a = a
        self.b = b

这里我们规定了类型,但是有没有发现什么问题呢?这里我们的参数 a 和 b 必须是一个 float,如果传递的是其它类型会报错,但是赋值的时候 self.a 和 self.b 又需要接收一个 long,所以这是一个自相矛盾的死结,在编译的时候就会报错。所以给 __init__ 参数传递的值的类型要和类中 cdef 声明的类型保持一致。

即使在类里面,cpdef 仍然不支持闭包。

然后为了更好地解释 Cython 带来的性能改进,我们需要了解关于继承、子类化、和扩展类型的多态性的基础知识。

类的继承与装饰

扩展类型只能继承单个基类,并且继承的基类必须是直接指向 C 实现的类型(可以是使用 cdef class 定义的扩展类型,也可以是内置类型,因为内置类型也是直接指向 C 一级的结构)。如果基类是常规的 Python 类(需要在运行时经过解释器动态解释才能指向 C 一级的结构),或者继承了多个基类,那么 Cython 在编译时会抛出异常。

cdef class Girl:
    cdef public:
        str name
        long age

    def __init__(self, name, age):
        self.name = name
        self.age = age

    cpdef str get_info(self):
        return f"name: {self.name}, age: {self.age}"


cdef class CGirl(Girl):

    cdef public str where

    def __init__(self, name, age, where):
        self.where = where
        super().__init__(name, age)


class PyGirl(Girl):

    def __init__(self, name, age, where):
        self.where = where
        super().__init__(name, age)

我们定义了一个扩展类(Girl),然后让另一个扩展类(CGirl)和普通的 Python 类(PyGirl)都去继承它。我们说扩展类不可以继承 Python 类,但 Python 类是可以继承扩展类的。

import pyximport
pyximport.install(language_level=3)

import cython_test

c_girl = cython_test.CGirl("古明地觉", 17, "东方地灵殿")
print(c_girl.get_info())  # name: 古明地觉, age: 17

py_girl  = cython_test.PyGirl("古明地觉", 17, "东方地灵殿")
print(py_girl .get_info())  # name: 古明地觉, age: 17

print(c_girl.where)  # 东方地灵殿
print(py_girl.where)  # 东方地灵殿

我们看到,对于扩展类和普通的 Python 类,它们都是可以继承扩展类的。

私有属性和私有方法

但是继承的话,子类是否可以访问父类的所有属性或方法呢?我们说cdef定义的方法和函数一样,无法被外部的Python访问,那么内部的 Python 类在继承的时候可不可以访问呢?以及私有属性(方法)的访问又是什么情况呢?

我们先来看看 Python 中关于私有属性的例子。

import pyximport
pyximport.install(language_level=3)

class A:

    def __init__(self):
        self.__name = "xxx"

    def __foo(self):
        return self.__name


try:
    A().__name
except Exception as e:
    print(e)

try:
    A().__foo()
except Exception as e:
    print(e)
"""
'A' object has no attribute '__name'
'A' object has no attribute '__foo'
"""

print(A()._A__name)  # xxx
print(A()._A__foo())  # xxx

我们说定义的私有属性只能在当前类里面使用,一旦出去了就不能够再访问了。其实私有属性(方法)本质上只是 Python 给你改了个名字,在原来的名字前面加上一个 _类名,所以 __name 和 __foo 其实相当于是 _A__name 和 _A__foo。但是当我们在外部用实例去获取 __name 和 __foo 的时候,获取的就是 __name 和 __foo,而显然 A 里面没有这两个属性或方法,因此报错。解决的办法就是使用 _A__name 和 _A__foo,但是不建议这么做,因为这是私有变量,如果非要访问的话,那就不要定义成私有的。如果是在 A 这个类里面获取的话,那么 Python 解释器也会自动为我们加上 _类名 这个前缀,比如我们在类里面获取 self.__name 的时候,实际上获取的也是 self._A__name,但是在外部就不会了。

_A__name = "古明地觉"


class A:

    def __init__(self):
        self.name = __name

# 是不是很神奇呢? 因为在类里面, __name 等价于 _A__name
print(A().name)  # 古明地觉

如果是继承的话,会有什么结果呢?

class A:

    def __init__(self):
        self.__name = "xxx"

    def __foo(self):
        return self.__name


class B(A):

    def test(self):
        try:
            self.__name
        except Exception as e:
            print(e)

        try:
            self.__foo()
        except Exception as e:
            print(e)


B().test()
"""
'B' object has no attribute '_B__name'
'B' object has no attribute '_B__foo'
"""

通过报错信息我们即可得知原因,B 也是一个类,那么在 B 里面调用私有属性,同样会加上 _类名 这个前缀,但是这个类名显然是 B 的类名,不是 A 的类名,因此找不到 _B__name 和 _B__foo,当然我们强制通过 _A__name 和 _A__foo 也是可以访问的,只是不建议这么做。

因此 Python 中不存在绝对的私有,只不过是解释器内部偷梁换柱将你的私有属性换了个名字罢了,但是我们可以认为它是私有的,因为按照原本的逻辑没有办法访问。同理继承的子类,也没有办法使用父类的私有属性。

但是在 Cython 中是不是这样子呢?

cdef class Person:
    cdef public:
        long __age
        str __name
        long length

    def __init__(self, name, age, length):
        self.__age = age
        self.__name = name
        self.length = length

    cdef str __get_info(self):
        return f"name: {self.__name}, age: {self.__age}, length: {self.length}"

    cdef str get_info(self):
        return f"name: {self.__name}, age: {self.__age}, length: {self.length}"

cdef class CGirl(Person):

    cpdef test1(self):
        return self.__name, self.__age, self.length

    cpdef test2(self):
        return self.__get_info()

    cpdef test3(self):
        return self.get_info()

静态类 CGirl 继承静态类 Person,那么 CGirl 对象能否使用 Person 里面的私有属性或方法呢?

import pyximport
pyximport.install(language_level=3)


import cython_test

c_g = cython_test.CGirl("古明地觉", 17, 156)
print(c_g.__name, c_g.__age, c_g.length)  # 古明地觉 17 156
print(c_g.test1())  # ('古明地觉', 17, 156)
print(c_g.test2())  # name: 古明地觉, age: 17, length: 156
print(c_g.test3())  # name: 古明地觉, age: 17, length: 156

我们看到没有任何问题,对于静态类而言,子类可以使用父类中 cdef 定义的方法。除此之外,私有属性和私有方法也是可以使用的,就仿佛这些方法定义在自身内部一样。其实根本原因就在于对于静态类而言,里面的所有属性名称、方法名称都是所见即所得,比如我们设置了 self.__name,那么它的属性名就叫做 __name,不会在属性名的前面加上 "_类名",获取的时候也是一样。所以对于静态类而言,属性(方法)名称是否以双下划线开头根本无关紧要。

然后我们再来看看 Python 类继承静态类之后会有什么表现呢?

cdef class Person:
    cdef public:
        long __age
        str __name
        long length

    def __init__(self, name, age, length):
        self.__age = age
        self.__name = name
        self.length = length

    cdef str __get_info(self):
        return f"name: {self.__name}, age: {self.__age}, length: {self.length}"

    cdef str get_info(self):
        return f"name: {self.__name}, age: {self.__age}, length: {self.length}"

class PyGirl(Person):

    def __init__(self, name, age, length, where):
        self.__where = where
        super(PyGirl, self).__init__(name, age, length)

    def test1(self):
        return self.__name, self.__age, self.length

    def test2(self):
        return self.__get_info()

    def test3(self):
        return self.get_info()

我们来测试一下:

import pyximport
pyximport.install(language_level=3)


import cython_test

py_g = cython_test.PyGirl("古明地觉", 17, 156, "东方地灵殿")
# 首先 __name、__age、length 都是在 Person 里面设置的,Person 是一个静态类
# 而我们说静态类里面没有那么多花里胡哨的,不会在以双下划线开头的成员变量前面加上 "_类名" 的
# 所以直接获取是没有问题的
print(py_g.__name)  # 古明地觉
print(py_g.__age)  # 17
print(py_g.length)  # 156

# 但是 __where 不一样,它不是在静态类中设置的,所以它是会加上 "_类名" 的
try:
    py_g.__where
except AttributeError as e:
    print(e)  # 'PyGirl' object has no attribute '__where'
print(py_g._PyGirl__where)  # 东方地灵殿

try:
    py_g.test1()
except AttributeError as e:
    print(e)  # 'PyGirl' object has no attribute '_PyGirl__name'
# 我们看到调用 test1 的时候报错了
# 原因就在于对于动态类而言,在类里面调用以双下划线开头的属性,会自动加上 "_类名",所以此时反而不正确了

try:
    py_g.test2()
except AttributeError as e:
    print(e)  # 'PyGirl' object has no attribute '_PyGirl__get_info'
# 对于调用方法也是如此,因为解释器 "自作聪明" 的加上了 "_类名",导致方法名错了
# 但此刻我们还无法判断动态类实例对象是否能够调用静态类内部使用 cdef 定义的方法,因为方法名就不对
# 所以我们再执行一下 py_g.test3() 就能真相大白了


try:
    py_g.test3()
except AttributeError as e:
    print(e)  # 'PyGirl' object has no attribute 'get_info'

因此结论很清晰了,静态类很单纯,里面的属性(方法)名称所见即所得,双下划线开头的属性(方法)对于静态类而言并没有什么特殊含义,动态类之所以不能调用是因为"多此一举"的在前面加上了 "_类名",导致方法名指定错了。然后是 cdef 定义的方法,即使是在 Cython 中,动态类也是不可以调用的,因为我们说 cdef 定义的是 C 一级的方法,它既不是 Python 的方法、也不像 cpdef 定义的时候自带 Python 包装器,因此它无法被子类继承,因此也就没有跨语言的边界。

如果将 cdef 改成 def 或者 cpdef,那么动态类就可以调用了。

在类里面使用 cpdef 定义的方法和在外部使用 cpdef 定义的函数一样,内部都不能出现闭包,换言之就是里面不能再定义函数。但是 cdef 和 def 是可以的,它们里面可以继续定义函数从而构建闭包。

但是注意:闭包对应的内层函数不可以是 cdef、cpdef 定义的函数,换言之只能是 def 定义的函数或者匿名函数。cdef、cpdef 在定义函数时只能出现在全局、或者类里面。

真正的私有

我们一直说双下划线开头的属性在静态类里面没有任何特殊的含义,因为是否私有不是通过名称前面是否有双下划线决定的,而是通过是否在类里面使用 cdef public 或者 cdef readonly 进行了声明所决定的,而且此时的私有是真正意义上的私有。如果不想让外界访问,那么外界是无论如何都访问不到的。

cdef class Person:
    # 因为 __init__ 中有 self.where = ... 这行赋值语句,其表示要给 self 加一个名为 where 的属性
    # 所以在类里面需要使用 cdef 进行声明,任何要绑定在 self 上面的属性都必须事先通过 cdef 声明好
    # 并且声明的同时还可以指定类型,比如这里是 str,表示绑定在 self 上的 where 是 str 类型
    # 那么 __init__ 中的参数 where 也必须要接收一个 str,否则 self.where = where 就是矛盾的
    cdef public:
        str where
    # 此外这里在 cdef 后面还指定了 public,如果不指定的话,那么只能是 Cython 中创建的实例对象才可以访问
    # 如果是在 Python 中导入这个类,那么实例化之后,是无法访问访问 where 属性的
    # 也就是说,如果你不希望这里的 where 属性被外界的 Python 代码所访问,那么直接通过 cdef str where 进行声明即可
    # 对于静态类而言,私有是通过这种方式实现的,而且此时的私有是真正的私有。
    # 如果不想私有、想在 Python 中被访问的话,那么就是用 cdef public 声明
    # 当然 cdef public 是外界既可以访问也可以修改,但如果只希望外界可以访问而不可以修改,那么就使用 cdef readonly
    # 上面这些之前说过了,这里再重复一下

    def __init__(self, where):
        self.where = where


cdef class CGirl(Person):

    # 这里也是同理,只不过是私有的
    cdef:
        str name
        int age
        int length

    def __init__(self, name, age, length, where):
        self.name = name
        self.age = age
        self.length = length
        super(CGirl, self).__init__(where)

但是注意:对于 CGirl 而言,我们不需要声明 where,因为 self.where 的绑定是在 Person 中发生的,只要在 Person 中声明即可。由于 CGirl 继承 Person,如果 CGirl 中也声明了 where 那么返而报错,提示 where 重复声明了。

import pyximport
pyximport.install(language_level=3)

import cython_test


c_g = cython_test.CGirl("古明地觉", 16, 157, "东方地灵殿")
# where 是使用 cdef public 声明的,所以不是私有的
# name、age、length 是使用 cdef 声明的,所以是私有的
print(c_g.where)  # 东方地灵殿
print(hasattr(c_g, "where"))  # True
print(hasattr(c_g, "name"))  # False
print(hasattr(c_g, "age"))  # False
print(hasattr(c_g, "length"))  # False

创建不可被继承的类

但是问题来了,如果我们希望自定义的扩展类不可以被其它类继承的话该怎么做呢?

cimport cython

# 通过 cython.final 进行装饰,那么这个类就不可被继承了
@cython.final
cdef class NotInheritable:
    pass

通过 cython.final,那么被装饰的类就是一个不可继承类,不光是外界普通的 Python 类,内部的扩展类也是不可继承的。

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 不是一个合法的基类。

让扩展类实例可以被弱引用

我们知道 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 0x000001BFE84EF400; to 'Girl' at 0x000001BFCF9BA5B0>

# 删除 g、g2,对象的引用计数会变为 0,此时再打印 g3,会发现引用的对象已经被销毁了
del g, g2
print(g3)  # <weakref at 0x00000222F0ED13B0; dead>

默认情况下,动态类实例对象都是可以被弱引用的,我们可以查看类的属性字典:

print(Girl.__dict__["__weakref__"])  # <attribute '__weakref__' of 'Girl' objects>

但是也有一个特例:

import weakref

class Girl:

    __slots__ = ("name",)
    def __init__(self):
        self.name = "古明地觉"

# 一旦当定义了 __slots__ 的时候,这个类的实例对象就不可以被引用了
try:
    weakref.ref(Girl())
except TypeError as e:
    print(e)  # cannot create weak reference to 'Girl' object

# 如果希望在定义 __slots__ 的时候也希望能被引用,那么就把 "__weakref__" 也加进去
class Girl:

    __slots__ = ("name", "__weakref__")
    def __init__(self):
        self.name = "古明地觉"

# 此时就可以被弱引用了,只不过此时引用的对象也已经被销毁了,因为我们没有用变量指向它
print(weakref.ref(Girl()))  # <weakref at 0x000002244C8CF450; 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

try:
    weakref.ref({})
except TypeError as e:
    print(e)  # cannot create weak reference to 'dict' 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)

以上就是让扩展类实例支持弱引用的方式。

扩展类实例对象的销毁以及垃圾回收

我们说当对象的引用计数为 0 时,会被销毁,这个销毁可以是放入缓存池中、也可以是交还给系统堆,当然不管哪一种,我们都不能再用了。

x = "古明地觉"
x = "古明地恋"

在执行完第二行的时候,由于 x 指向了别的字符串,因此第一个字符串对象引用计数为 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 函数时,就会出现循环引用,x 内部引用了 y 指向的对象、y 内部又引用了 x 指向的对象,如果我们将垃圾回收机制关闭的话,即使函数退出,对象也不会被回收。而如果想解决这一点,那么就必须在销毁对象之前先将对象内部引用的其它对象的引用计数减一才行,也就是打破循环引用,这便是 Python 底层的垃圾回收器所做的事情。

所以上面在销毁 x 时(x 是一个变量,这里为了描述方便,就用 x 代指其指向的对象,后面的 y 同理),先将内部的属性 attr 引用的 y 的引用计数减 1,然后再将 x 的引用计数减 1,但是此时 x 还没有销毁,因为它的引用计数还是 1;然后销毁 y,在销毁 y 之前,先将内部引用的 x 的引用计数减 1,此时 x 的引用计数就变成 0 了。接着再将 y 的引用计数减 1,由于在销毁 x 的时候,已经将 y 的引用计数减 1 了,所以此时 y 的引用计数也为 0。所以最终 x 和 y 的引用计数都会变成 0,最终都会被销毁,所以垃圾回收只是将发生循环引用的对象的引用计数减去 1,而对象是否被销毁还是由引用计数是否为 0 所决定的。

而如果想做到这一点,那么就必须在 tp_traverse 中指定垃圾回收器要跟踪的属性,PyTypeObject 内部有一个 tp_traverse 成员,它接收一个函数,在内部指定要跟踪的属性(x 的话就是 attr,y 由于是一个列表,它里面的每一个元素都要跟踪)。垃圾回收器根据 tp_traverse 指定的要跟踪的属性,找到这些属性引用的其它对象;然后 PyTypeObject 内部还有一个 tp_clear,在这里面再将引用的其它对象的引用计数减 1,所以寻找(tp_traverse)和清除(tp_clear)是在两个函数中实现的。

  • 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 的对象,否则循环引用无法被打破,从而引发内存泄露。

禁用垃圾回收

我们说垃圾回收是为了解决循环引用而存在的,解释器会将那些能产生循环引用的对象放在链表(零代、一代、二代)上,然后从根节点出发进行遍历,而显然链表上的对象越少,垃圾回收的耗时就越短。所以如果一个对象可以发生循环引用,但是我们能保证实际情况中它不会发生,那么我们就可以让这个对象不参与垃圾回收,从而提升效率。

比如我们可以定义一个不可能发生循环引用的扩展类:

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

此时程序的运行效率会得到提升,当然要注意:使用 no_gc 一定要确保不会发生循环引用,如果给上面的类再添加一个声明:

cimport cython

@cython.no_gc
cdef class Girl:
    
    cdef public str name
    cdef public int age
    cdef public list hobby

这个时候就必须要小心了,因为里面出现了列表,列表是可以发生循环引用的。

启动 trashcan

在 Python 中,我们可以创建具有深度递归的对象,比如:

L = None

for i in range(2 ** 20):
    L = [L]
    
del L

当我们删除 L 的时候,会先销毁打印 L[0]、然后销毁 L[0][0],以此类推,直到递归深度为 2 ** 20。而这样的深度很容易溢出 C 的调用栈,导致 Python 解释器崩溃,但事实上我们上面在 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
}

但是对于扩展类而言,则并非如此,它默认是没有开启 transcan 机制的:

cdef class A:

    def __init__(self):
        cdef list L = None
        cdef unsigned long i
        for i from 0 <= i < 2 ** 30:
            L = [L]

如果你导入 A 这个类并实例化,那么你的内存占用率会越来越高,最终程序崩溃。如果希望扩展类实例对象也能开启 transcan 机制,同样可以使用装饰器:

cimport cython

@cython.transcan(True)
cdef class A:

    def __init__(self):
        cdef list L = None
        cdef unsigned long i
        for i from 0 <= i < 2 ** 30:
            L = [L]

如果一个类开启了 transcan 机制,那么继承它的子类也会开启,如果不想开启,则需要通过 transcan(False) 显式关闭。

说实话以上这些其实不是特别常用,一旦使用就意味着你要格外小心,因为涉及内存都是有危险的,所以如果无法百分百确认自己是否需要这么做,那就不要做。而且像 transcan 这种,个人觉得真正写项目的时候很少会出现,了解一下即可。

扩展类实例的 freelist

有些时候我们需要多次对某个类执行实例化和销毁操作,这也意味着会有多次内存的创建与销毁。那么我们能不能像Python 底层采用的缓存池策略一样,每次销毁的时候不释放内存,而是放入一个链表(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 


girl = Girl("古明地觉", 16)
del girl  # 会放入到 freelist 中,里面的元素个数加 1
girl = Girl("雾雨魔理沙", 17)  # 从 freelist 中获取,里面元素个数减 1,此时无需重新申请内存

扩展类实例的序列化和反序列化

然后说一下序列化和反序列化,像内置 pickle、json 库都可以将对象序列化和反序列化,这里我们说的是 pickle。pickle 和 json 不同,json 序列化之后是人类可阅读的,但是能序列化的对象是有限的,因为序列化的结果可以在不同语言之间传递;但是 pickle 序列化之后是二进制格式,只有 Python 才认识,因此它可以序列化 Python 的绝大部分对象。

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age


girl = Girl("古明地觉", 16)
# 这便是序列化话的结果
dumps_obj = pickle.dumps(girl)
# 显然这是什么东西我们不认识,但是 Python 解释器认识,我们可以再进行反序列化
print(dumps_obj[: 20])  # b'\x80\x04\x95;\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main_'

loads_obj = pickle.loads(dumps_obj)
print(loads_obj.name)  # 古明地觉
print(loads_obj.age)  # 16

这里我们不探究 pickle 的实现原理,我们来说一下如何自定制序列化和返序列化的过程,想要自定制的话,需要实现 __getstate__ 和 __setstate__ 两个魔法方法:

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __getstate__(self):
        """序列化的时候会调用"""
        # 对 Girl 的实例对象进行序列化的时候,默认会返回其属性字典
        # 这里我们多添加一个属性
        print("被序列化了")
        return {**self.__dict__, "gender": "女"}

    def __setstate__(self, state):
        """反序列化时会调用"""
        # 对 Girl 的实例对象进行序列化的时候,会将 __getstate__ 返回的字典传递给这里的 state 参数
        # 我们再设置到 self 当中,如果不设置,那么序列化之后是无法获取属性的
        print("被反序列化了")
        self.__dict__.update(**state)


girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
"""
被序列化了
"""

loads_obj = pickle.loads(dumps_obj)
print(loads_obj.name)  
print(loads_obj.age)  
print(loads_obj.gender) 
"""
被反序列化了
古明地觉
16
女
"""

虽然反序列化的时候会调用 __setstate__,但实际上会先调用 __reduce__,__reduce__ 必须返回一个字符串或元组。

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __reduce__(self):
        print("__recude__")
        # 当返回字符串时,这里是 "girl",那么在反序列化之后就会返回 eval("girl")
        return "girl"

girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
# 反序列化
loads_obj = pickle.loads(dumps_obj)
print(loads_obj.name)
print(loads_obj.age)
"""
__recude__
古明地觉
16
"""

如果我们返回一个别的字符串是会报错的,假设返回的是 "xxx",那么反序列化的时候会提示找不到 xxx。那如果我们在外面再定义一个变量 xxx 呢?比如 xxx = 123,这样做也是不可以的,因为 pickle 要求序列化的对象和反序列化得到的对象必须是同一个对象,除非你定义 xxx = girl 让 xxx 和 girl 都指向同一个对象,否则也会报错。

因此返回一个字符串几乎不怎么用,更常用的是返回一个元组,并且元组里面的元素是 2 到 6 个,每个含义都不同,我们分别举栗说明。

__reduce__ 返回的元组包含两个元素

当只有两个元素时,第一个元素必须是一个可调用对象,第二个元素表示可调用对象的参数(必须也是一个元组),相信你已经猜到会返回什么了:

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __reduce__(self):
        # 反序列化时会返回 range(1, 10, 2),
        return range, (1, 10, 2)
        # return int, ("123",)  反序列化时会返回 int("123")
        # 所以此时返回的可以是任意的对象


girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
loads_obj = pickle.loads(dumps_obj)
print(loads_obj)  # range(1, 10, 2)
print(list(loads_obj))  # [1, 3, 5, 7, 9]

__reduce__ 返回的元组包含三个元素

包含三个元素时,那么第三个元素是一个字典,会将该字典设置到返回的对象的属性字典中。

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __reduce__(self):
        return Girl, ("雾雨魔理沙", 17), {"gender": "女", "where": "魔法森林"}


girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
loads_obj = pickle.loads(dumps_obj)
print(loads_obj.__dict__)  # {'name': '雾雨魔理沙', 'age': 17, 'gender': '女', 'where': '魔法森林'}

如果定义了 __reduce__ 的同时还定义了 __setstate__,那么就不会设置到返回的对象的属性字典中了,而是会作为参数传递到 __setstate__ 中进行调用:

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __setstate__(self, state):
        print(state["gender"], state["where"])
        # 这里我们手动设置
        # 注意这个 self 是 Girl("雾雨魔理沙", 17) 不是 Girl("古明地觉", 16)
        self.__dict__.update(state)

    def __reduce__(self):
        return Girl, ("雾雨魔理沙", 17), {"gender": "女", "where": "魔法森林"}


girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
loads_obj = pickle.loads(dumps_obj)
"""
女 魔法森林
"""
print(loads_obj.__dict__)  # {'name': '雾雨魔理沙', 'age': 17, 'gender': '女', 'where': '魔法森林'}

注意:如果定义了 __reduce__ 的同时还定义了 __setstate__,那么第三个元素就可以不是字典了。如果没有 __setstate__,那么第三个元素必须是一个字典(或者指定为 None 相当于没指定)。

__reduce__ 返回的元组包含四个元素

当包含四个元素时,那么第四个元素必须是一个迭代器,然后返回的对象内部必须有 append 方法。会遍历迭代器的每一个元素,并作为参数传递的 append 中进行调用。

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __reduce__(self):
        # 从第三个元素开始,如果指定为 None,那么相当于什么也不做
        # 比如第三个元素我们指定了 None,那么是不会有 "往属性字典添加属性" 这一步的
        # 即使定义了 __setstate__,该方法也不会调用,但是前两个元素必须指定、且不可以为 None
        return Girl, ("雾雨魔理沙", 17), None, iter(["雾雨魔理沙", "雾雨魔法店", "魔法森林"])

    def append(self, item):
        print(f"append被调用了, 传递的元素是: {item!r}")


girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
loads_obj = pickle.loads(dumps_obj)
"""
append被调用了, 传递的元素是: '雾雨魔理沙'
append被调用了, 传递的元素是: '雾雨魔法店'
append被调用了, 传递的元素是: '魔法森林'
"""

因此这种方式非常适合列表:

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __reduce__(self):
        return list, (), None, iter(["雾雨魔理沙", "雾雨魔法店", "魔法森林"])

    def append(self, item):
        self.append(item)


girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
loads_obj = pickle.loads(dumps_obj)
print(loads_obj)  # ['雾雨魔理沙', '雾雨魔法店', '魔法森林']

所以还是有点神奇的,我们明明是对 Girl 的实例序列化之后的结果进行反序列化,理论上也应该得到 Girl 的实例才对,现在却得到了一个列表,原因就是里面指定了 __reduce__。并且此时第三个元素就不能指定了,如果指定为字典,那么会加入到返回的对象的属性字典中,但我们返回的对象是一个列表,列表没有自己的属性字典,并且它也没有 __setstate__。

__reduce__ 返回的元组包含五个元素

当包含五个元素时,那么第五个元素必须也是一个迭代器,并且内部的每一个元素都是一个 2-tuple。同时要求返回的对象必须有 __setitem__ 方法,举个栗子:

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __reduce__(self):
        # 依旧会遍历可迭代对象,得到的是一个 2-tuple,然后传递到 __setitem__ 中
        return Girl, ("古明地觉", 16), None, None, iter([("name", "雾雨魔理沙"), ("age", "17")])

    def __setitem__(self, key, value):
        print(f"key = {key!r}, value = {value!r}")
        self.__dict__[key] = value


girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
loads_obj = pickle.loads(dumps_obj)
"""
key = 'name', value = '雾雨魔理沙'
key = 'age', value = '17'
"""
# 在 __setitem__ 中我们将 name 和 age 属性给换掉了
print(loads_obj.__dict__)  # {'name': '雾雨魔理沙', 'age': '17'}

__reduce__ 返回的元组包含六个元素

当包含六个元素时,那么第六个元素必须是一个可调用对象,但是在测试的时候发现这个可调用对象始终没被调用。因为 pickle 底层实际上是 C 写的,位于 Modules/_pickle.c 中,所以试着查看了一下,没想到发现了玄机。

我们说在没有 __setstate__ 的时候,__reduce__ 返回的元组的第三个元素应该是一个字典,会将字典加入到返回的对象的属性字典中;但如果定义了,那么就不会加入到返回对象的属性字典中了,而是会作为参数传递给 __setstate__(此时第三个元素就可以不是字典了);而第六个元素和 __setstate__ 的作用是相同的,举个栗子:

import pickle


class Girl:

    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    def __setstate__(self, state):
        print("__setstate__ 被调用了")

    def sixth_element(self, cls, val):
        print(f"sixth_element 被调用了,cls: {cls}, val: {val}")
        self.__dict__["name"] = val

    def __reduce__(self):
        # 我们指定的第六个元素需要是一个可调用对象,如果指定了,那么此时 __setstate__ 会无效化
        return Girl, ("古明地觉", 16), "古明地恋", None, None, self.sixth_element


girl = Girl("古明地觉", 16)
dumps_obj = pickle.dumps(girl)
# 反序列化的时候,会将返回的对象,这里是 Girl("古明地觉", 16) 和 第三个元素作为参数,传递到  self.sixth_element 进行调用
loads_obj = pickle.loads(dumps_obj)
"""
sixth_element 被调用了,cls: <__main__.Girl object at 0x7fb121b1ef10>, val: 古明地恋
"""
# 这里我们将 name 属性的值给换掉了
print(loads_obj.__dict__)  # {'name': '古明地恋', 'age': 16}

我们看到当指定了第六个元素的时候,__setstate__ 就不会被调用了,但是需要注意的是:self.sixth_element 的这个 self 指的是返回的对象。假设返回的不是 Girl 实例,而是一个列表,那么就会报错,因为列表没有 sixth_element 方法。当然第六个元素比较特殊,我们也可以不指定成方法,指定为普通的函数也是可以的,只要它是一个接收两个参数的可调用对象即可。

以上就是 __reduce__ 的相关内容,除了 __reduce__ 之外还有一个 __reduce_ex__,用法类似只不过在调用的时候会传递协议的版本。

关于 pickle 底层的原理其实也是蛮有意思的的,这里就不展开了,总之 pickle 不是安全的,它在反序列化的时候不会对数据进行检测。这个特点可以被坏蛋们用来攻击别人,因此建议在反序列化的时候,只对那些受信任的数据尽心反序列化。

最后是扩展类实例的序列化和反序列化,终于到我们的主角了。默认情况下 cython 编译器也会为扩展类生成 __reduce__ 方法,和动态类一样,扩展类实例在反序列化之后和序列化之前的表现也是一致,但是仅当所有成员都可以转成 Python 对象并且没有 __cinit__ 方法时才可以序列化。

cdef class Girl:
    cdef int *p
    
"""
对 Girl 实例序列化的时候会报错:self.p cannot be converted to a Python object for pickling
"""

如果我们希望禁止扩展类被 pickle 的话,那么可以通过装饰器 @cython.auto_pickle(False) 来实现,此时 cython 编译器不会再为该扩展类生成 __reduce__ 方法。

我们编写两个类,其中一个的实例对象不可以被序列化、另一个可以:

cimport cython

@cython.auto_pickle(False)
cdef class Girl1:

    cdef readonly str name
    cdef int age

    def __init__(self):
        self.name = "古明地觉"
        self.age = 17


cdef class Girl2:

    cdef readonly str name
    cdef int age

    def __init__(self):
        self.name = "古明地觉"
        self.age = 17

然后我们来测试一下:

import pyximport
pyximport.install(language_level=3)
import pickle
import cython_test

girl1 = cython_test.Girl1()
try:
    pickle.dumps(girl1)
except Exception as e:
    print(e)  # cannot pickle 'cython_test.Girl1' object


girl2 = cython_test.Girl2()
loads_obj = pickle.loads(pickle.dumps(girl2))
print(loads_obj.name)  # 古明地觉
try:
    loads_obj.age
except AttributeError as e:
    print(e)  # 'cython_test.Girl2' object has no attribute 'age'

# 因为 age 没有对外暴露,所以访问不到,因此序列化之前的 girl2 和反序列化之后的 loads_obj 是一致的

以上就是扩展类实例相关 pickle 操作,说实话一般用 __getstate__ 和 __setstate__ 就够了。

类型转化

我们知道动态类在继承扩展类、或者说静态类的时候,无法继承其内部的 cdef 方法,但我们可以进行类型转换:

cdef class A:

    cdef funcA(self):
        return 123


class B(A):
    # 显然 func1 内部无法访问扩展类A的funcA
    def func1(self):
        return self.funcA()
	
    # 但是我们在使用的时候将其类型转化一下
    def func2(self):
        return (<A> self).funcA()
>>> import cython_test
>>> b = cython_test.B()
>>> b.func1()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cython_test.pyx", line 10, in cython_test.B.func1
    return self.funcA()
AttributeError: 'B' object has no attribute 'funcA'
>>> b.func2()
123
>>> 

我们看到 b.func2 是可以调用成功的,但我们知道使用 <> 这种方式如果转化不成功,那么也不会有任何影响,会保留原来值(C中的整型和浮点除外),这可能会有点危险。因此我们可以通过 (<A?> self),这样 self 必须是 A 或者其子类的实例对象,否则报错。

另外,如果使用 <> 进行转化的话,那么即使调用的是以双下划线开头的方法也是可行的。

cdef class A:
 
    cdef __funcA(self):
        return 123
 
 
class B(A):
    # 这里 func1 内部仍无法访问扩展类 A 的 __funcA
    # 虽然我们知道动态类实例不能访问父类中 cdef 定义的方法,但这里的原因却不是这个
    # 真正的原因上面已经解释了,self.__funcA() 实际上会执行 self._B__funcA(),而这个方法没有
    def func1(self):
        return self.__funcA()
	
    # 但是我们在使用的时候将其类型转化一下,这样就可以了,此时调用的就是 __funcA()
    # 我们之前说静态类内部设置和获取属性(方法)时,不会在双下划线开头的名称前面加上 "_类名",其实说的还不够完善
    # 如果一个对象是静态类实例对象,即使不在静态类内部,其设置和获取属性(方法)时也不会在双下划线开头的名称前面加上 "_类名"
    # 比如这里虽然是在动态类内部,但我们将其类型转成了静态类,所以在调用双下划线开头的方法时,是不会自动加上 "_类名" 的,所以此时可以调用
    def func2(self):
        return (<A> self).__funcA()

但是注意:即使我将 self 转成的 A 类型,但是它仍然可以调用 B 的属性,举个栗子:

cdef class A:
    
    def __init__(self):
        self.gender = "女"
        
    cdef __funcA(self):
        return f"name: {self.name}, age: {self.age}, gender: {self.gender}"


class B(A):

    def __init__(self):
        self.name = "古明地觉"
        self.age = 16
        super(B, self).__init__()
        
    def func2(self):
        return (<A> self).__funcA()
import pyximport

pyximport.install(language_level=3)

import cython_test

b = cython_test.B()
print(b.func2())  # name: 古明地觉, age: 16, gender: 女

在创建 B 的实例对象时,name、age、gender 已经绑定在上面了,所以即使转成了 A 类型,在 __funcA 中仍然是可以访问的。

当然方法也是一样的,举个栗子:

cdef class A:
    
    def __init__(self):
        self.gender = "女"

    cdef func3(self):
        return "A_func3"

    cdef __funcA(self):
        return self.func3(), self.func4()


class B(A):

    def func2(self):
        return (<A> self).__funcA()

    def func3(self):
        return "B_func3"

    def func4(self):
        return "B_func4"
import pyximport

pyximport.install(language_level=3)

import cython_test

b = cython_test.B()
print(b.func2())  # ('A_func3', 'B_func4')

<A> self 已经将 self 的类型转成 A 了,然后调用 __funcA 的时候,在内部又调用了 func3,那么会优先去 A 里面的找,即使 func3 是 cdef 定义的也没关系,因为在 __funcA 内部的 self 是 A 类型。然后神奇的是,当 func4 找不到的时候,又会到 B 里面找。

扩展类型对象和 None

看一个简单的函数。

cdef class Girl:
    cdef public:
        str name
        long age

    def __init__(self, name, age):
        self.name = name
        self.age = age


cpdef tuple dispatch(Girl g):
    return g.name, g.age
import pyximport
pyximport.install(language_level=3)

import cython_test

print(cython_test.dispatch(cython_test.Girl("古明地觉", 17)))  # ('古明地觉', 17)
print(cython_test.dispatch(cython_test.Girl("椎名真白", 16)))  # ('椎名真白', 16)

class B(cython_test.Girl):
    pass

print(cython_test.dispatch(B("mashiro", 16)))  # ('mashiro', 16)

cython_test.dispatch(object())
"""
    cython_test.dispatch(object())
TypeError: Argument 'g' has incorrect type (expected cython_test.Girl, got object)
"""

我们传递一个 Girl 或者其子类的实例对象的话是没有问题的,但是传递一个其它的则不行。

但是在 Cython 中 None 是一个例外,即使它不是 Girl 的实例对象,但也是可以传递的,除了 C 规定的类型之外,只要是 Python 的类型,不管什么,传递一个 None 都是可以的。这就类似于 C 中的空指针,任何指针都可以传递给空指针,但是没有办法做什么操作。

所以这里可以传递一个 None,但是执行逻辑的时候显然会报错。

import pyximport
pyximport.install(language_level=3)

import cython_test
cython_test.dispatch(None)

然而报错还是轻的,上面代码执行的时候会发生段错误,解释器直接异常退出了。原因就在于不安全地访问了 Girl 实例对象的成员属性,属性和方法都是 C 接口的一部分,而 Python 中 None 本质上没有 C 接口,因此访问属性或者调用方法都是无效的。为了确保这些操作的安全,最好加上一层检测。

cpdef tuple dispatch(Girl g):
    if g is None:
        raise TypeError("...")
    return g.name, g.age

但是除了上面那种做法,Cython 还提供了一种特殊的语法。

def dispatch(Girl g not None):
    return g.name, g.age

此时如果我们传递了 None,那么就会报错。不过这个版本由于要预先进行类型检查,判断是否为 None,从而会牺牲一些效率。不过虽说如此,但是传递 None 所造成的段错误是非常致命的,因此我们是非常有必要防范这一点的。当然还是那句话,虽然效率会牺牲一点点,但还是那句话,与 Cython 带来的效率提升相比,这点牺牲是非常小的,况且这也是必要的。但是注意:not None 只能出现在 def 定义的函数中,cdef 和 cpdef 是不合法的。

import pyximport
pyximport.install(language_level=3)

import cython_test
cython_test.dispatch(None)
"""
    cython_test.dispatch(None)
TypeError: Argument 'g' has incorrect type (expected cython_test.Girl, got NoneType)
"""

此时对 None 也是一视同仁的,传递一个 None 也是不符合类型的。这里我们设置的是 not None,但是除了 None 还能设置别的吗?答案是不行的,只能设置 None,因为 Cython 只有对 None 不会进行检测。

def dispatch(Girl g not 123):
                       ^
------------------------------------------------------------

cython_test.pyx:11:24: Expected 'None'

许多人认为需要 not None 字句是不方便的,这个特性经常被争论,但幸运的是,在函数的参数声明中使用 not None 是非常方便的。

个人觉得 Cython 的语法设计的真酷,笔者本人非常喜欢。

为了更高的性能,Cython 还提供了一个默认的 nonecheck 编译器指令,可以对整个扩展模块不进行检查,通过在文件的开头加上一个注释:# cython: nonecheck=True

Cython 中扩展类的 property

Python 中的 property 非常的易用且强大,可以让我们精确地控制某个属性的修改,而 Cython 也是支持 property 描述符的,但是方式有些不一样。不过在介绍 Cython 的 property 之前,我们先来看看 Python 中的 property。

class Girl:

    def __init__(self):
        self.name = None

    @property
    def x(self):
        # 不需要我们对x进行调用,直接通过self.x即可获取返回值
        # 让函数像属性一样直接获取
        return self.name

    @x.setter
    def x(self, value):
        # 当我们self.x = "古明地觉"的时候,会调用这个函数
        # "古明地觉"就会传递给这里的value
        self.name = value

    @x.deleter
    def x(self):
        # 执行del self.x的时候,就会调用这个函数
        print("被调用了")
        del self.name


girl = Girl()
print(girl.x)  # None
girl.x = "古明地觉"
print(girl.x)  # 古明地觉
del girl.x  # 被调用了

这里是通过装饰器的方式实现的,三个函数都是一样的名字,除了使用装饰器,我们还可以这么做。

class Girl:

    def __init__(self):
        self.name = None

    def fget(self):
        return self.name

    def fset(self, value):
        self.name = value

    def fdel(self):
        print("被调用了")
        del self.name

    # 传递三个函数即可,除此之外还有一个doc属性
    x = property(fget, fset, fdel, doc="这是property")

girl = Girl()
print(girl.x)  # None
girl.x = "古明地觉"
print(girl.x)  # 古明地觉
del girl.x  # 被调用了

所以 property 就是让我们像访问属性一样访问函数,那么它内部是怎么做到的呢?不用想,肯定是通过描述符。

class MyProperty:  # 模仿类property,实现与其一样的功能
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        self.doc = doc

    def __get__(self, instance, owner):
        return self.fget(instance)

    def __set__(self, instance, value):
        return self.fset(instance, value)

    def __delete__(self, instance):
        return self.fdel(instance)

    def setter(self, func):
        return type(self)(self.fget, func, self.fdel, self.doc)

    def deleter(self, func):
        return type(self)(self.fget, self.fset, func, self.doc)


class Girl1:

    def __init__(self):
        self.name = None

    @MyProperty
    def x(self):
        return self.name

    @x.setter
    def x(self, value):
        self.name = value

    @x.deleter
    def x(self):
        print("被调用了")
        del self.name


class Girl2:

    def __init__(self):
        self.name = None

    def fget(self):
        return self.name

    def fset(self, value):
        self.name = value

    def fdel(self):
        print("被调用了")
        del self.name

    x = MyProperty(fget, fset, fdel)


girl1 = Girl1()
print(girl1.x)  # None
girl1.x = "古明地觉"
print(girl1.x)  # 古明地觉
del girl1.x  # 被调用了


girl2 = Girl2()
print(girl2.x)  # None
girl2.x = "古明地觉"
print(girl2.x)  # 古明地觉
del girl2.x  # 被调用了

我们通过描述符的方式手动实现了一个 property 的功能,描述符事实上在 Python 解释器的层面也用的非常多,我们说实例调用方法的时候,第一个参数 self 会自动传递也是通过描述符实现的。所以描述符不光我们在 Python 的层面用,在解释器的层面上也大量使用描述符。同理字典也是如此,我们定义的类的实例对象的属性都是存在一个字典里面的,我们称之为属性字典,所以字典在 Python 中是经过高度优化的,原因就是不仅我们在用,底层也在大量使用。

下面来看看Cython中的property

针对扩展类的 property,Cython 有着不同的语法,但是实现了相同的结果。

cdef class Girl:
    cdef str name

    def __init__(self):
        self.name = None

    property x:
        def __get__(self):
            return self.name

        def __set__(self, value):
            self.name = value

        # __del__ 在 Cython 中不表示析构,析构是 __dealloc__
        # 但是在 __del__ 里面我们并没有 del self.name,原因就是扩展类的属性不可以删除
        # 因此 __del__ 很少会用,一般都是 __get__ 和 __set__
        def __del__(self):
            print("__del___")
import pyximport
pyximport.install(language_level=3)

import cython_test

g = cython_test.Girl()
print(g.x)  # None
g.x = "古明地觉"
print(g.x)  # 古明地觉

我们看到 Cython 是将 property 和描述符结合在一起了,但是实现起来感觉更方便了,当然我们在动态中使用 property 的方式在扩展类中也是支持的。

不过最重要的还是魔法方法,魔法方法算是 Python 中非常强大的一个特性, Python 将每一个操作符都抽象成了对应的魔法方法,也正因为如此 numpy 也得以很好的实现。那么在 Cython 中,魔法方法是如何体现的呢?

魔法方法在 Cython 中更加魔法

通过魔法方法可以对运算符进行重载,魔法方法的特点就是它的函数名以双下划线开头、并以双下划线结尾。我们之前讨论了 __cinit__、__init__、__dealloc__,并了解了它们分别用于 C 一级的初始化、Python 一级的初始化、对象的释放(特指 C 中的指针)。除了那三个,Cython 中也支持其它的魔法方法,但是注意:Cython 的析构不是 __del__,__del__ 由 __dealloc__ 负责实现。

算术魔法方法

假设在 Python 中定义了一个类 class A,如果希望 A 的实例对象可以进行加法运算,那么内部需要定义 __add__ 或 __radd__ 方法。关于 __add__ 和 __radd__ 的区别就在于该实例对象是在加号的左边还是右边。我们以 A() + B() 为例,A 和 B 是我们自定义的类:

  • 首先尝试寻找 A 的 __add__ 方法, 如果有直接调用
  • 如果 A 中不存在 __add__ 方法, 那么会去寻找 B 的 __radd__ 方法

但如果是一个整数和我们自定义的类的实例对象相加呢?

  • 123 + A(): 先寻找 A 的 __radd__
  • A() + 123: 先寻找 A 的 __add__

代码演示一下:

class A:

    def __add__(self, other):
        return "A add"

    def __radd__(self, other):
        return "A radd"


class B:

    def __add__(self, other):
        return "B add"

    def __radd__(self, other):
        return "B radd"


print(A() + B())  # A add
print(B() + A())  # B add
print(123 + B())  # B radd
print(A() + 123)  # A add

除了类似于 __add__ 这种实例对象放在左边、__radd__ 这种实例对象放在右边,还有 __iadd__,它用于 += 这种形式。

class A:

    def __iadd__(self, other):
        print("__iadd__ is called")
        return 1 + other


a = A()
a += 123
print(a)
"""
__iadd__ is called
124
"""
# 如果没定义__iadd__,也是可以使用这种形式,会转化成a = a + 123,所以会调用__add__方法

当然这都比较简单,其它的算数魔法方法也是类似的。并且里面的 self 就是对应类的实例对象,有人会觉得这不是废话吗?之所以要提这一点,是为了给下面的Cython做铺垫。

但是对于 Cython 中的扩展类来说,不使用类似于 __radd__ 这种实现方式,我们只需要定义一个 __add__ 即可同时实现 __add__ 和 __radd__。对于 Cython 中的扩展类型 A,a 是 A 的实例对象,如果是 a + 123,那么会调用 __add__ 方法,然后第一个参数是 a、第二个参数是123;但如果是 123 + a,那么依旧会调用 __add__,不过此时 __add__ 的第一个参数是 123、第二个参数才是 a。所以不像 Python 中的魔法方法,第一个参数 self 永远是实例本身,第一个参数是谁取决于谁在前面。所以将第一个参数叫做 self 容易产生误解,官方也不建议将第一个参数使用 self 作为参数名。

但是说实话,用了 Python 这么些年,将第一个参数不写成 self 感觉有点别扭。

cdef class Girl:

    def __add__(x, y):
        return x, y
import pyximport
pyximport.install(language_level=3)

import cython_test

g = cython_test.Girl()
print(g + 123)  # (<cython_test.Girl object at 0x0000028752477940>, 123)
print(123 + g)  # (123, <cython_test.Girl object at 0x0000028752477940>)

我们看到,__add__ 中的参数确实是由位置决定的,那么再来看一个例子。

cdef class Girl:
    cdef long a

    def __init__(self, a):
        self.a = a

    def __add__(x, y):
        if isinstance(x, Girl):
            # 这里为什么需要转化呢?直接 x.a + y 不行吗?
            # 答案是不行的,因为这个 x 是我们外部传过来的 Girl 对象
            # 但是我们这里的 a 不是一个 public 或者 readonly,直接访问是得不到的,所以需要转化一下才可以访问
            return (<Girl> x).a + y
        return (<Girl> y).a + x
import pyximport
pyximport.install(language_level=3)

import cython_test

g = cython_test.Girl(3)
print(g + 2)  # 5
print(2 + g)  # 5

# 和浮点数运算也是可以的
print(g + 2.1)  # 5.1
print(2.1 + g)  # 5.1

g += 4
print(g)  # 7

除了 __add__,Cython 也是支持 __iadd__ 的,此时的第一个参数是 self,因为 += 这种形式,第一种参数永远是实例对象。

另外我们这里说的 __add__ 和 __iadd__ 只是举例,其它的算术操作也是可以的。

富比较

Cython 的扩展类可以使用 __eq、__ne__ 等等,和 Python 一致的富比较魔法方法。

cdef class A:

    # 这里比较操作符两边的值的位置依旧会影响这里的x、y
    # 但是对于Python中的比较来说则不会,self永远是实例对象
    def __eq__(self, other):
        print(self, other)
        return "=="
import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A()
print(a == 3)
"""
<cython_test.A object at 0x0000015D792C7940> 3
==
"""
print(3 == a)
"""
<cython_test.A object at 0x0000015D792C7940> 3
==
"""

和算术魔法方法不一样,比较操作没有 __req__ 或者 __ieq__,并且比较的时候第一个参数永远是实例对象。

cdef class A:

    def __eq__(self, other):
        print(self, other)
        return "A =="


class B:

    def __eq__(self, other):
        print(self, other)
        return "B =="
import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A()
b = cython_test.B()
# 调用 a 的 __eq__
print(a == 123)
"""
<cython_test.A object at 0x00000223641631C0> 123
A ==
"""
# 调用 b 的 __eq__
print(b == 123)
"""
<cython_test.B object at 0x00000223641E71F0> 123
B ==
"""
# 调用 a 的 __eq__, 第一个参数还是 a
print(a == 123)
"""
<cython_test.A object at 0x00000223641631C0> 123
A ==
"""
# 调用 b 的 __eq__, 第一个参数还是 b
print(b == 123)
"""
<cython_test.B object at 0x00000223641E71F0> 123
B ==
"""
# 调用 a 的 __eq__, 第一个参数是 a, 第二个参数是 b
print(a == b)
"""
<cython_test.A object at 0x00000223641631C0> <cython_test.B object at 0x00000223641E71F0>
A ==
"""
# 调用 b 的 __eq__, 第一个参数是 b, 第二个参数是 a
print(b == a)
"""
<cython_test.B object at 0x00000223641E71F0> <cython_test.A object at 0x00000223641631C0>
B ==
"""

链式比较也是可以的,比如:a == b == 123 等价于 a == b and b == 123。

import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A()
b = cython_test.B()
print(a == b == 123)
"""
<cython_test.A object at 0x000001817F1D31C0> <cython_test.B object at 0x000001817630E0D0>
<cython_test.B object at 0x000001817630E0D0> 123
B ==
"""

先执行 a == b 返回 "A ==",再执行 b == 3 返回 "B ==",然后 "A ==" 和 "B ==" 进行 and,前面为真,所以返回后面的 "B =="。

迭代器支持

Cython 中的扩展类也是支持迭代器协议的,而且定义的方法和纯 Python 之间是一样的。

cdef class A:

    cdef public:
        list values
        long __index

    def __init__(self, values):
        self.values = values
        self.__index = 0

    def __iter__(self):
        return self

    def __next__(self):
        try:
            ret = self.values[self.__index]
            self.__index += 1
            return ret
        except IndexError:
            raise StopIteration
import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A(['椎名真白', '古明地觉', '雾雨魔理沙'])
for _ in a:
    print(_)
"""
椎名真白
古明地觉
雾雨魔理沙
"""

我们知道在 Python 中,for 循环会先去寻找 __iter__,但如果找不到会退而求其次去找 __getitem__,那么在 Cython 中是不是也是如此呢。

cdef class A:

    cdef public:
        list values
        long __index

    def __init__(self, values):
        self.values = values
        self.__index = 0

    def __getitem__(self, item):
        return self.values[item]
import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A(['椎名真白', '古明地觉', '雾雨魔理沙'])
for _ in a:
    print(_)
"""
椎名真白
古明地觉
雾雨魔理沙
"""

我们看到,也是一样的。

当然上面只是介绍了魔法方法的一部分,Python 中的魔法方法(比如__getattr__、__call__、__hash__ 等等等等)在 Cython 中基本上都支持,并且 Cython 还提供了一些 Python 所没有的魔法方法。当然这些我们就不说了,如果你熟悉 Python 的话,那么在 Cython 中也是按照相同的方式进行使用即可。总之,用久了就孰能生巧了。

注意:魔法方法只能用def定义,不可以使用cdef或者cpdef。

对了,还有上下文管理器,在Cython中也是一样的用法。Python中基本上所有的魔法方法在Cython都可以直接用。

import pyximport
pyximport.install(language_level=3)

import cython_test

a = cython_test.A()
with a:
    pass
"""
__enter__
__exit__
"""

这一次我们说了一下 Cython 中的扩展类,它和 Python 中内置类是等价的,都是直接指向了 C 一级的数据结构,不需要字节码的翻译过程。也正因为如此,它失去一些动态特性,但同时也获得了效率,因为这两者本来就是不可兼得的。

Cython 的类有点复杂,还是需要多使用,不过它毕竟在各方面都和 Python 保持接近,因此学习来也不是那么费劲。

虽然创建扩展类的最简单的方式是通过 Cython,但是通过 Python/C API 直接在 C 中实现的话,则是最有用的练习,但还是那句话,它需要我们对 Python/C API 有一个很深的了解,而这是一个非常难得的事情,因此使用 Cython 就变成了我们最佳的选择。

总结

如果你能够掌握 Python/C API 的话,那么你一定是一个了不起的人。总之一句话:如果你用 C 编写扩展模块的时候,能够像写 Python 一样轻松,或者说 Python 语言的高级用法,比如魔方方法、描述符、元类、装饰器等等等等,你可以迅速用 C 实现的话。那么我可以负责任的告诉你,要是只论 Python 的话(当然相信你的C水平也是极高的),你可以轻松地进入任何一家公司。

posted @ 2020-07-09 23:48  古明地盆  阅读(4781)  评论(0编辑  收藏  举报