《Cython系列》5. 组织你的Cython代码

楔子

我们之前在介绍Cython语法的时候,一直永远都是一个pyx文件,如果是多个pyx文件改怎么办?怎么像Python那样进行导入呢?

Python提供了modules和packages来帮助我们组织项目,这允许我们将函数、类、变量等等,按照各自的功能或者实现的业务,分组到各自的逻辑单元中,从而使项目更容易理解和定位。并且模块和包也使得代码重用变得容易,在Python中我们使用import语句访问其它module和package中的函数。

而Cython也支持我们将项目分成多个模块,首先它完全支持import语句,并且含义与Python中的含义完全相同。这就允许我们在运行时访问外部纯Python模块中定义的Python对象,或者其它扩展模块中定义的可以被访问的Python对象。

但故事显然没有到此为止,因为只有import的话,Cython是不允许两个模块访问彼此的cdef、cpdef定义的函数、ctypedef、struct等等,也不允许访问对其它的扩展类型进行C一级的访问。

而为了解决这一问题,Cython提供了三种类型的文件来组织Cython文件一级C文件。到目前为止,我们一直使用扩展名为pyx的Cython源文件,它是包含代码的逻辑的实现文件。但是还有两种类型的文件,分别是扩展名为pxd的文件和扩展名为pxi的文件。

pxd文件你可以想象成类似于C中的头文件,用于存放一些声明之类的,而Cython的cimport就是从pxd文件中进行属性导入。

这一节我们就来介绍cimport语句的详细信息,以及pyx、pxd、pxi文件之间的相互联系,我们如何使用它们来构建更大的Cython项目。有了cimport和这三种类型的文件,我们就可以有效地组织Cython项目,而不会影响性能。

Cython的实现文件(pxd文件)和声明文件(pxi文件)

我们目前一直在处理pyx文件,它是我们编写具体Cython代码的文件,当然它和py文件是等价的。如果的Cython项目非常小,并且没有其它代码需要访问里面的C级结构,那么一个pyx文件足够了。但如果我们想要共享pyx文件中的C级结构,那么就需要pxd文件了。

假设我们有这样一个Cython文件:lover.pyx

# lover.pyx
from libc.stdlib cimport malloc, free

ctypedef double real

cdef class Girl:

    cdef public :
        str name  # 姓名
        long age  # 年龄
        str gender  # 性别
    cdef real *scores  # 分数,这里我们的double数组长度为3,但是real *不能被访问,所以它不可以使用public

    def __cinit__(self, *args, **kwargs):
        self.scores = <real *> malloc(3 * sizeof(real))

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

    def __dealloc__(self):
        if self.scores != NULL:
            free(self.scores)

    cpdef str get_info(self):
        return f"name: {self.name}, age: {self.age}, gender: {self.gender}"
	
    cpdef set_score(self, list scores): 
        # 虽然not None也可以写在参数后面,但是它只适用于Python函数
        assert scores is not None and len(scores) == 3
        cdef real score
        cdef long idx
        for idx, score in enumerate(scores):
            self.scores[idx] = score

    cpdef list get_score(self):
        cpdef list res = [self.scores[0], self.scores[1], self.scores[2]]
        return res

目前来讲,由于所有内容都在一个pyx文件里面,因此任何C级属性都可以自由访问。

>>> import cython_test
>>> cython_test.Girl('古明地觉', 16, 'female')
<cython_test.Girl object at 0x7f9716ee3308>
>>> g = cython_test.Girl('古明地觉', 16, 'female')
>>> 
>>> g.get_info()
'name: 古明地觉, age: 16, gender: female'
>>> g.set_score([90.4, 97.3, 97.6])
>>> g.get_score()
[90.4, 97.3, 97.6]
>>> 

访问非常地自由,没有任何限制,但是随着我们Girl这个类的功能越来越多的话,该怎么办呢?

所以我们需要创建一个pxd文件,就叫lover.pxd吧,然后把我们希望暴露给外界访问的C级结构放在里面。

# lover.pxd
ctypedef double real

cdef class Girl:

    cdef public :
        str name  
        long age  
        str gender  
    cdef real *scores  
    
    cpdef str get_info(self)
    cpdef set_score(self, list scores)
    cpdef list get_score(self)

我们看到在pxd文件中,我们只存放了C级结构的声明,像ctypedef、cdef、cpdef等等,并且函数的话我们只是存放了定义,函数体并没有写在里面,同理后面也不可以有冒号。另外,pxd文件是在编译时访问的,而且我们不可以在里面放类似于def这样的纯Python声明,否则会发生编译错误。

所以pxd文件只放相应的声明,而它们的具体实现是在pyx文件中,因此有人发现了,这个pxd文件不就是C中的头文件吗?答案确实如此。

然后我们对应的lover.pyx文件也需要修改,lover.pyx和lover.pxd具有相同的基名称,Cython会将它们视为一个命名空间。另外,如果我们在pxd文件中声明了一个函数,那么在pyx文件中不可以再次声明,否则会发生编译错误。怎么理解呢?

我们说类似于cpdef func(): pass这种形式,它是一个函数(有定义);但是cpdef func()这种形式,它只是一个函数声明。所以函数声明和C中的函数声明也是类似的,在Cython中没有冒号、以及函数体的话,那么就是函数声明。而在Cython的pyx文件中也可以进行函数声明,比如C源文件中也是可以声明函数的,但是一般都会把声明写在h头文件中,在Cython中也是如此,会把C级结构、一些声明写在pxd文件中。

而一旦声明,那么只能声明一次,不可以重复声明,具有相同基名称的pyx、pxd文件中,函数声明只能出现一次,如果在pxd文件中出现了,那么在pyx中就不可以重复声明了。并且对于函数,如果声明了,那么则需要有具体的实现。

注意:声明针对的是C一级的结构,所以对于def定义的Python函数是不可以进行声明的。

重新修改我们的pyx文件

# lover.pyx
from libc.stdlib cimport malloc, free

cdef class Girl:

    def __cinit__(self, *args, **kwargs):
        self.scores = <real *> malloc(3 * sizeof(real))

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

    def __dealloc__(self):
        if self.scores != NULL:
            free(self.scores)

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

    cpdef set_score(self, list scores):
        assert scores is not None and len(scores) == 3
        cdef real score
        cdef long idx
        for idx, score in enumerate(scores):
            self.scores[idx] = score

    cpdef list get_score(self):
        cpdef list res = [self.scores[0], self.scores[1], self.scores[2]]
        return res

虽然结构没有什么变化,但是我们把一些C级数据拿到pxd文件中了,所以pyx文件中的可以直接删掉了,会自动到对应的pyd文件中找,因为它们有相同的基名称,Cython会将其整体看成一个命名空间。所以:这里pyx文件和pxd文件一定要有相同的基名称,只有这样才能够找得到,否则你会发现代码中real是没有被定义的,当然还有self的一些属性,因为它们必须要使用cdef在类里面进行声明。

但是哪些东西我们才应该写在pxd文件中呢?本质上讲,任何在C级别上,需要对其它模块公开的,我们才需要写在pxd文件中,比如:

  • C类型声明--ctypedef、结构体、共同体、枚举(后续系列中介绍)
  • 外部的C、C++库的声明(后续系列中介绍)
  • cdef、cpdef模块级函数的声明
  • cdef class扩展类的声明
  • 扩展类的cdef属性
  • 使用cdef、cpdef方法的声明
  • C级内联函数或者方法的实现

但是,一个pxd文件不可以包含如下内容:

  • Python函数和非内联C级函数、方法的实现
  • Python类的定义
  • IF或者DEF宏的外部Python可执行代码

那么我们的pxd文件都带来了哪些功能呢?那就是lover.pyx文件可以被其它的pyx文件导入了,这几个pyx文件作为一个整体为Python提供更强大的功能,否则的话其它的pyx文件是无法导入的,导入方式是使用cimport。

cimport语句

然后我们在另一个pyx文件中导入这个lover.pyx,当然导入的话其实寻找的是pxd,然后调用的是pyx里面的函数。

# cython_test.pyx
from lover cimport Girl

cdef class NewGirl(Girl):
    pass

然后进行编译,不过此时由于涉及到多个pyx文件,因此这些pyx都要进行编译才行。

from distutils.core import Extension, setup
from Cython.Build import cythonize


ext = [Extension("lover", ["lover.pyx"]),  # 不用管pxd,会自动包含
       Extension("cython_test", ["cython_test.pyx"])]

setup(ext_modules=cythonize(ext, language_level=3))

编译的命令和之前一样,编译之后会发现原来的目录中有两个pyd文件了。

将这两个文件拷贝出来,首先在cython_test.pyx中,是直接导入的lover,因此这两个pyd文件要在一个目录中。

>>> from cython_test import NewGirl
>>> 
>>> g = NewGirl('古明地觉', 17, 'female')
>>> g.get_info()
'name: 古明地觉, age: 17, gender: female'
>>> 
>>> g.set_score([90.1, 90.3, 93.5])
>>> g.get_score()
[90.1, 90.3, 93.5]
>>> 

此时两个pyd文件就实现了导入,我们可以将这个cython_test.pyx写的更复杂一些。

from lover cimport Girl


cdef class NewGirl(Girl):

    cdef public str where

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

    def new_get_info(self):
        return super(NewGirl, self).get_info() + f", where: {self.where}"
>>> from cython_test import NewGirl
>>> # 自己定义了__init__,接收4个参数
>>> g = NewGirl('古明地觉', 17, 'female')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cython_test.pyx", line 8, in cython_test.NewGirl.__init__
    def __init__(self, name, age, gender, where):
TypeError: __init__() takes exactly 4 positional arguments (3 given)
>>> 
>>> # 传递4个参数,前面3个会交给父类处理
>>> g = NewGirl('古明地觉', 17, 'female', '东方地灵殿')
>>> g.get_info()  # 父类的方法
'name: 古明地觉, age: 17, gender: female'
>>> 
>>> g.new_get_info()  # 在父类的方法返回的结果之上,进行添加
'name: 古明地觉, age: 17, gender: female, where: 东方地灵殿'
>>> 

因此我们看到使用起来基本上和Python之间是没有区别,主要就是如果涉及到多个pyx,那么这些pyx都要进行编译。并且想被导入,那么该pyx文件一定要有相同基名称的pxd文件。导入的时候使用cimport,会去pxd文件中找,然后具体实现则是去调用pyx文件。但即便pyx文件能被导入,但也未必就能使用其所有的C级结构,想使用的话必须要在对应的pxd文件中进行声明。

另外,可能有人发现了,我们这是绝对导入,对于目前演示来说使用绝对导入、相对导入是没有什么区别的。但实际上,pyd应该采用相对导入,因为它无法作为启动文件,只能被导入。所以我们在pyx文件中使用相对导入即可。

我们举个栗子,之前的lover.pyx和lover.pxd不变,在cython_test.pyx进行相对导入。

from .lover import Girl

但是:上面三个文件在一个单独的目录中,假设就叫lover吧。1.py就是我们执行编译的,它和lover目录是同级的。

然后编译扩展模块的时候可以用之前的方式编译,只不过Extension中文件路径要指定对。但是这里我们换一种方式吧,我们之前说支持通配符。

from distutils.core import setup
from Cython.Build import cythonize

# 将lover目录下的所有pyx文件进行编译,名字和pyx文件名是一致的。
# 如果是Extension的话,我们可以自己指定
setup(ext_modules=cythonize("lover/*.pyx", language_level=3))

当编译成功之后,对应的目录就会出现两个扩展模块,我们将其移动过来。

但是注意:由于涉及到相对导入,所以我们不可以在和cython_test.so同目录下导入,否则会报错。

>>> import cython_test
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "lover/cython_test.pyx", line 1, in init cython_test
    from .lover import Girl
ImportError: attempted relative import with no known parent package
>>> 

我们可以将这两个so文件移动到一个单独的目录中,就移动到lover中吧。

其实我们还可以在里面写入__init__文件,这样才更符合Python中的包,但这里我们就不写了,只要可以相对导入即可。

>>> from lover.cython_test import Girl
>>> 
>>> g = Girl("古明地觉", 17, "female")
>>> g.get_info()
'name: 古明地觉, age: 17, gender: female'
>>> 

预定义的pxd文件

还记得我们之前的from libc.stdlib cimport malloc, free这行代码吗?显然这是Cython提供的,没错它就在Cython模块主目录下的Includes目录中,libc这个包下面除了stdlib之外,还有stdio、math、string等pxd文件。除此之外,它还有一个libcpp包对应的pxd文件,里面包含了C++标准模板库(STL)容器,如:string、vector、list、map、pair、set等等。

而CPython解释器的Include目录中定义了大量的C头文件,而Cython也提供了对应的pxd文件,可以方便地访问Python/C api。当然还有一个最重要的包就是numpy,Cython也是支持的,当然这些我们会在后面系列中介绍了。

from ... cimport...和from ... import ...的用法是一致的,区别就是前者多了一个c

下面我们就来演示一下常见的功能。

from libc cimport math
print(math.sin(math.pi / 2))

from libc.math cimport cos, pi
print(cos(pi / 2))

# 使用C++模板
from libcpp.vector cimport vector
cdef vector[int] *vi = new vector[int](10)

# 注意:如果使用import和cimport导入了相同的函数,那么会编译错误
"""
from math import sin
from lib.math cimport sin
这种方式是报错的
解决办法可以通过as
from math import sin as py_sin
from lib.math cimport sin as c_sin
"""

我们说pxd文件类似于C的头文件(h文件),它们有以下相似之处:

  • 都通过使用外部代码声明C级结构
  • 都允许我们将一个大文件分成不同的多个组件
  • 都负责声明用于实现的C级接口

除此之外,还有一个pxi文件,但是个人觉得用处不是很大,这里就不说了。

原文地址:https://www.cnblogs.com/traditional/p/13284386.html