分享

Cython 的扩展类

 古明地觉O_o 2022-12-08 发布于北京

楔子


前面我们介绍了 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(34)
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(34)
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(34)
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(34)
# 可以访问属性
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了)。

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多