Cython 模块之间的相互导入,组织你的 Cython 代码

简介: Cython 模块之间的相互导入,组织你的 Cython 代码


楔子




前面介绍 Cython 语法的时候,一直都是一个 pyx 文件,而且文件名也一直叫 cython_test.pyx 就没变过,但如果是多个 pyx 文件该怎么办?怎么像 Python 那样进行导入呢?

Python 提供了模块和包来帮助我们组织项目,这允许我们将函数、类、变量等等,按照各自的功能或者实现的业务,分组到各自的逻辑单元中,从而使项目更容易理解和定位。并且模块和包也使得代码重用变得容易,如果需要访问彼此之间的功能,直接通过 import 语句导入即可。

而 Cython 也支持我们将项目分成多个模块,首先它完全支持 import 语句,并且含义与 Python 完全相同。这就允许我们在运行时访问已经写好的模块中定义的 Python 对象,这个模块也可以是编译好的扩展模块。

但故事显然没有到此为止,因为只有 import 的话,Cython 是不允许两个 pyx 文件访问彼此的静态数据的。比如:cython_test1.pyx 和 cython_test2.pyx ,这两个文件之间无法通过 import 互相访问。

而为了解决这一问题,Cython 提供了相应类型的文件来组织 Cython 文件以及 C 文件。到目前为止,我们一直使用扩展名为 .pyx 的 Cython 源文件,它是包含代码逻辑的实现文件,但除了它还有扩展名为 .pxd 的文件。

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

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


.pyx 文件和 .pxd 文件



我们目前一直在处理 .pyx 文件,它是我们编写具体 Cython 代码的文件。如果 Cython 项目非常小,那么一个 .pyx 文件足够了。但如果功能变得繁杂,需要进行文件上的划分、并且还能相互导入,那么就需要 .pxd 文件了。

举个例子,我们的文件还叫 cython_test.pyx。

from libc.stdlib cimport malloc, free
# 给 double 起一个别名
ctypedef double real
cdef class Girl:
    cdef public :
        str name  # 姓名
        long age  # 年龄
        str gender  # 性别
    cdef real *scores  # 分数
    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}," \
               f" gender: {self.gender}"
 
    cpdef set_score(self, list scores):  
        # 虽然 not None 也可以写在参数后面
        # 但是它只适用于 Python 函数, 也就是 def 定义的函数
        assert scores is not None and len(scores) == 3
        cdef real score
        cdef Py_ssize_t idx
        # 遍历 scores,设置在 self.scores 里面
        for idx, score in enumerate(scores):
            self.scores[idx] = score
    
    cpdef list get_score(self):
        # 获取 self.scores,但它是一个 real *
        # 我们需要转成列表之后返回
        cdef list res = [self.scores[0], 
                         self.scores[1], 
                         self.scores[2]]
        return res

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

import pyximport
pyximport.install(language_level=3)
import cython_test
g = cython_test.Girl('古明地觉', 16, 'female')
print(g.get_info())
"""
name: 古明地觉, age: 16, gender: female
"""
g.set_score([90.4, 97.3, 97.6])
print(g.get_score())  
"""
[90.4, 97.3, 97.6]
"""

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

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

# cython_test.pxd
ctypedef double real
cdef class Girl:
    cdef public :
        str name  
        long age  
        str gender  
    cdef real *scores   
    cpdef str get_info(self)
    # 如果参数有默认值,那么在声明的时候让其等于 * 即可
    # 比如:arg=*,表示该函数的 arg 参数有默认值
    cpdef set_score(self, list scores)  
    cpdef list get_score(self)

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

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

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

类似于 cpdef func(): pass 这种形式,它是一个函数(有定义);但是 cpdef func() 这种形式,它只是一个函数声明。所以 Cython 的函数声明和 C 的函数声明也是类似的,函数在 Cython 中没有冒号、以及函数体的话,那么就是函数声明。

而在 Cython 的 pyx 文件中也可以进行函数声明,就像 C 源文件中也是可以声明函数一样,但是一般都会把声明写在 h 头文件中。在 Cython 里面也是如此,会把 C 级结构、一些声明写在 pxd 文件中。

而一旦声明了,就不可再次声明。比如 cdef public 那些成员变量,它们在 pxd 文件中已经声明了,那么 pyx 中就不可以再有了,否则就会出现变量的重复声明。

重新修改我们的 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}," \
               f" gender: {self.gender}"
 
    cpdef set_score(self, list scores):  
        assert scores is not None and len(scores) == 3
        cdef real score
        cdef Py_ssize_t idx
        # 遍历 scores,设置在 self.scores 里面
        for idx, score in enumerate(scores):
            self.scores[idx] = score
    
    cpdef list get_score(self):
        cdef list res = [self.scores[0], 
                         self.scores[1], 
                         self.scores[2]]
        return res

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

然后调用方式还是和之前一样,也是没有任何问题的。

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

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


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

  • Python 函数;
  • Python 类;
  • 外部的 Python 可执行代码;


当然这些东西也没有必要刻意去记,总之 pyx 文件负责功能的具体实现,但有些时候,我们希望某个 pyx 文件里的功能,可以被其它的 pyx 文件访问。比如我想在 b.pyx 里面访问 a.pyx 里面的某个函数、扩展类等等,那么就再定义一个 a.pxd,把 a.pyx 里面需要被 b.pyx 或其它文件导入的内容在对应的 a.pxd 文件中进行声明即可。

然后在导入的时候会去找 pxd 文件,根据里面的声明去(和当前 pxd 文件具有相同基名称的 pyx 文件中)寻找对应的实现逻辑,而导入方式是使用 cimport。

cimport 和 import 语法一致,只不过前者多了一个 c,但是 cimport 是用来导入 pxd 文件中声明的静态数据。

有了 pxd 文件,pyx 文件就可以被其它的 pyx 文件导入了,这几个 pyx 文件作为一个整体为 Python 提供更强大的功能,否则的话 pyx 文件之间是无法相互导入的。

最后再说一句,具有相同基名称的 pxd 和 pyx 文件是一个整体,pxd 文件里面的声明,对应的 pyx 文件可以直接用。虽然编写代码的时候 IDE 会提示,但是编译时 Cython 编译器会将它们当成一个整体,但前提是它们的基名称相同。


多文件相互导入



那么下面就来测试一下多文件之间的相互导入吧,假设再定义一个 caller.pyx,在里面导入 cython_test.pyx。当然导入的话其实寻找的是 cython_test.pxd,然后调用的是 cython_test.pyx 里面的具体实现。

from cython_test cimport Girl
# cython_test.pyx 里面定义了一个扩展类 Girl
# 我们导入它,当然啦,如果想要导入
# 那么 Girl 必须在 cython_test.pxd 中声明
cdef class NewGirl(Girl):
    pass

这里由于涉及到了多个 pyx 文件,所以我们需要手动编译,建立一个 setup.py。

from distutils.core import Extension, setup
from Cython.Build import cythonize
# 不用管 pxd, 会自动包含, 因为它们具有相同的基名称
# cython 在编译的时候会自动寻找
ext = [Extension("caller", ["caller.pyx"]),  
       Extension("cython_test", ["cython_test.pyx"])]
setup(ext_modules=cythonize(ext, language_level=3))

执行 python setup.py build 进行编译,编译之后会发现 build 目录中有两个 pyd 文件了。

cython_test.c 和 caller.c 是 Cython 编译器生成的 C 文件,然后基于 C 文件生成扩展模块。我们将这两个扩展模块移动到当前的主目录下,然后在 main.py 里面导入测试。

import caller
print(caller) 
"""
<module 'caller' from 'D:\\satori\\caller.cp38-win_amd64.pyd'>
"""
g = caller.NewGirl("古明地觉", 17, "female")
print(g.get_info())  # name: 古明地觉, age: 17, gender: female
g.set_score([99.9, 90.4, 97.6])
print(g.get_score())  # [99.9, 90.4, 97.6]

我们看到结果没有任何问题,在编译的时候,caller.pyx 会从 cython_test.pxd 里面导入变量 Girl,如果里面找不到,就会报出编译错误。如果找到了,那么就去对应的 cython_test.pyx 里面寻找具体实现。

所以光在 pyx 文件里面实现还不够,如果希望被别的 pyx 访问,那么还要在对应的 pxd 里面进行声明。

我们还可以将 caller.pyx 写更复杂一些。

from cython_test 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):
        cdef str info = super(NewGirl, self).get_info() 
        return info + f", where: {self.where}"

重新编译之后,再次导入。

import caller
# 自己定义了 __init__
# 接收 4 个参数, 前面 3 个会交给父类处理
g = caller.NewGirl("古明地觉", 17, "female", "地灵殿")
print(g.get_info())
"""
name: 古明地觉, age: 17, gender: female
"""
print(g.new_get_info()) 
"""
name: 古明地觉, age: 17, gender: female, where: 东方地灵殿
"""

因此我们看到使用起来基本上和 Python 没有区别,主要就是如果涉及到多个 pyx,那么这些 pyx 都要进行编译。并且要想被其它 pyx 文件导入,那么该 pyx 文件一定要有相同基名称的 pxd 文件。导入的时候使用 cimport,会去 pxd 文件中寻找相关声明,然后具体实现则是去 pyx 文件中找。

当然啦,如果某个 pyx 文件不需要被别的 pyx 文件访问,那么就不需要 pxd 文件了。比如这里的 caller.pyx,它不需要被其它的 pyx 文件访问,所以我们没有定义 caller.pxd。但如果 caller.pyx 功能变得复杂的话,从 C 语言工程的角度来说,我们还是倾向于定义一个 caller.pxd,然后将声明写在里面。

另外可能有人发现了,我们这里是绝对导入。但实际上,一些 pyd 文件会放在单独的工程目录中,这时候应该采用相对导入,况且它无法作为启动文件,只能被导入。所以我们可以在 pyx 文件中进行相对导入,因为编译之后的 pyd 文件和之前的 pyx 文件之间的关系是对应的。

然后我们将之前的 cython_test.pxd, cython_test.pyx, caller.pyx 放在一个单独的目录中。


此时里面的 caller.pyx 就应该采用相对导入,我们修改 caller.pyx。

from .cython_test cimport Girl

只需要改动这一行代码即可,然后编译扩展模块。但是有些细节需要注意,首先当出现相对导入的时候,它们一定在一个单独的目录中,而这个目录里面要创建一个 __init__.py。然后编译脚本,也需要变化。

from distutils.core import Extension, setup
from Cython.Build import cythonize
# 需要注意 Extension 的第一个参数
# 首先我们这个文件叫做 setup.py
# 当前的目录层级如下
"""
D:\satori 目录:
    cython_relative_demo:
        __init__.py
        caller.pyx
        cython_test.pxd
        cython_test.pyx
    main.py  
    setup.py  
"""
# 我们的 setup.py 和 cython_relative_demo 是同级的
# 然后 Extension 的第一个参数不可以指定为 caller、cython_test
# 如果这么做的话, 当代码中涉及到相对导入的时候
# 在编译时就会报错: relative cimport beyond main package is not allowed
# Cython 编译器要求在编译 pyx 文件、指定模块名的时候
# 还要把该 pyx 文件所在的目录也带上
ext = [Extension("cython_relative_demo.caller",
                 ["cython_relative_demo/caller.pyx"]),
       
       Extension("cython_relative_demo.cython_test",
                 ["cython_relative_demo/cython_test.pyx"])]
setup(ext_modules=cythonize(ext, language_level=3))

这样编译就没有问题了,然后我们来看一下编译之后的目录:

我们看到多了之前指定的目录,然后将这两个文件移动到下面的 cython_relative_demo 目录中,因为我们的 pyx 文件就是在那里定义的,所以编译之后也应该放在原来的位置。

# 这里不需要 pyximport 了
# 因为导入的是已经编译好的 pyd 文件
# 当然即使有 pyximport, 也会优先导入 pyd 文件
from cython_relative_demo import caller
g = caller.NewGirl("古明地觉", 17, "female", "地灵殿")
print(g.get_info())
"""
name: 古明地觉, age: 17, gender: female
"""
print(g.new_get_info())
"""
name: 古明地觉, age: 17, gender: female, where: 东方地灵殿
"""

结果是一样的。

但是问题来了,如果这两个 pyx 文件的路径更复杂呢?


我们将其移动到了各自的目录中,那么这个时候要如何编译呢?不过编译之前,我们首先要修改一下 caller.pyx。

# 应该将导入改成这样才行
from ..cython_test_dir.cython_test cimport Girl

然后修改编译脚本:

from distutils.core import Extension, setup
from Cython.Build import cythonize
# 当前的程序主目录层级如下
"""
D:\satori 目录:
    cython_relative_demo:
        caller_dir:
            __init__.py
            caller.pyx
        cython_test_dir:
            __init__.py
            cython_test.pxd
            cython_test.pyx
        __init__.py  
    main.py  
    setup.py  
"""
ext = [Extension("cython_relative_demo.caller_dir.caller",
                 ["cython_relative_demo/caller_dir/caller.pyx"]),
       Extension("cython_relative_demo.cython_test_dir.cython_test",
                 ["cython_relative_demo/cython_test_dir/cython_test.pyx"])]
setup(ext_modules=cythonize(ext, language_level=3))

最后再来重新编译,看看目录的结构如何:


我们看到目录变成了这样,接着将 pyd 文件移动到对应 pyx 文件所在的目录中即可,然后导入测试一下。

# 这里导入的位置也要变
from cython_relative_demo.caller_dir import caller
g = caller.NewGirl("古明地觉", 17, "female", "东方地灵殿")
print(g.get_info()) 
"""
name: 古明地觉, age: 17, gender: female
"""
print(g.new_get_info()) 
"""
name: 古明地觉, age: 17, gender: female, where: 东方地灵殿
"""

依旧可以执行成功,因此以上我们便介绍了当出现相对导入时 pyx 文件的编译方式,并且此时需要手动编译。如果是 pyximport 自动编译的话,需要通过我们之前介绍的定义 .pyxbld 文件的方式,指定编译过程。否则的话,也会出现编译失败的情况。

其实通过定义 .pyxbld 文件的方式要更简单一些,因为所有的 .pyxbld 文件都是一样的,比如 caller.pyxbld 和 cython_test.pyxbld 的内容都长下面这样:

from distutils.core import Extension, setup
from Cython.Build import cythonize
def make_ext(modname, pyxfilename):
    # 可以直接返回一个 ext
    # 但生成的 C 文件会以 Python2 的语法为主
    # 所以通过 pyxbld 文件手动指定依赖的时候
    # pyximport.install 里面的 language_level 无效
    ext = Extension(modname,
                    sources=[pyxfilename])
    # 可以提前编译好,然后在这里指定 language_level=3
    # 但 cythonize 可以对多个 Extension 对象编译
    # 返回的是列表,因此我们选择第一个元素
    return cythonize(ext, language_level=3)[0]

好了,编写完成,还是很简单的,我们看一下当前目录结构。

里面的 setup.py 文件可以无视掉,然后我们使用 pyximport 自动编译并导入。

import pyximport
pyximport.install(language_level=3)
from cython_relative_demo.caller_dir import caller
g = caller.NewGirl("古明地觉", 17, "female", "地灵殿")
print(g.get_info())
"""
name: 古明地觉, age: 17, gender: female
"""
print(g.new_get_info())
"""
name: 古明地觉, age: 17, gender: female, where: 东方地灵殿
"""

还是很简单的,如果你的线上机器能够保证环境稳定,那么也可以通过定义 .pyxbld 的方式,导入起来更方便。

注意:在相对导入 pyx 文件时,要确保 pyx 文件所在的目录里有 __init__.py。

以上我们便将多个 Cython 源代码组织起来了,但是除了这种方式之外,我们还可以使用 include 的方式。

# cython_test1.pyx
cdef a = 123
# cython_test2.pyx
include "./cython_test1.pyx"
cdef b = 234
print(a + b)

这里的两个 pyx 文件都定义在当前目录,然后我们看到可以像 C 一样使用 include 将别的 pyx 文件包含进来,就像在当前文件中定义的一样。

import pyximport
pyximport.install(language_level=3)
import cython_test2
"""
357
"""

如果我们要手动编译的话也是可以的,但只需要对 cython_test2 编译即可,include 的内容会自动加进来。


小结



pyx 文件、pxd 文件,再加上 cimport 和 include,可以让我们将 Cython 代码组织到单独的模块和包中,而不牺牲性能。这使得 Cython 可以进行扩展,而不仅仅用来加速,它完全可以作为主力语言开发一个成熟的项目。

E N D


相关文章
|
5月前
|
Python
请描述 Python 中的模块和包的概念,以及如何导入和使用它们。
请描述 Python 中的模块和包的概念,以及如何导入和使用它们。
43 3
|
12月前
31 # 模块的概念
31 # 模块的概念
50 0
|
2天前
|
存储 缓存 API
比较一下 Python、C、C 扩展、Cython 之间的差异
比较一下 Python、C、C 扩展、Cython 之间的差异
6 0
|
2月前
|
Shell Python 容器
Python模块是其代码组织和重用的基本方式。
【8月更文挑战第18天】Python模块是其代码组织和重用的基本方式。
18 1
|
5月前
|
数据安全/隐私保护 Python
详解python中的类、模块、包的概念和区别
详解python中的类、模块、包的概念和区别
107 0
详解python中的类、模块、包的概念和区别
|
5月前
|
Python
【Python基础】模块的概念、模块的导入和下载第三方模块
【Python基础】模块的概念、模块的导入和下载第三方模块
|
11月前
|
Python
Python类、模块、包的概念及区别
Python类、模块、包的概念及区别
|
Python
Python程序结构:模块和包的组织与导入
Python程序结构:模块和包的组织与导入
113 0
|
JSON 数据挖掘 数据库连接
【100天精通python】Day14:python模块_标准模块,自定义模块
【100天精通python】Day14:python模块_标准模块,自定义模块
109 0
|
Java 数据库
项目的模块以及每一个模块的作用
项目的模块以及每一个模块的作用
项目的模块以及每一个模块的作用