楔子
我們之前在介紹 Cython 語法的時候,一直都是一個 pyx 文件,而且文件名也一直叫 cython_test.pyx 就沒變過,但如果是多個 pyx 文件改怎么辦?怎么像 Python 那樣進行導入呢?
Python 提供了 modules 和 packages 來幫助我們組織項目,這允許我們將函數、類、變量等等,按照各自的功能或者實現的業務,分組到各自的邏輯單元中,從而使項目更容易理解和定位。並且模塊和包也使得代碼重用變得容易,在 Python 中我們使用 import 語句訪問其它 module 和 package 中的函數。
而 Cython 也支持我們將項目分成多個模塊,首先它完全支持 import 語句,並且含義與 Python 中的含義完全相同。這就允許我們在運行時訪問外部純 Python 模塊中定義的 Python 對象,或者其它擴展模塊中定義的可以被訪問的 Python 對象。
但故事顯然沒有到此為止,因為只有 import 的話,Cython 是不允許兩個模塊訪問彼此的 cdef、cpdef 定義的函數、ctypedef、struct 等等,也不允許訪問對其它的擴展類型進行 C 一級的訪問。比如:cython_test1.pyx 和 cython_test2.pyx ,這兩個文件之間無法通過 import 互相訪問,當然 cimport 也無法實現這一點。
而為了解決這一問題,Cython 提供了相應類型的文件來組織 Cython 文件以及 C 文件。到目前為止,我們一直使用擴展名為 .pyx 的 Cython 源文件,它是包含代碼的邏輯的實現文件,但是除了它還有擴展名為 .pxd 的文件。
pxd 文件你可以想象成類似於 C 中的頭文件,用於存放一些聲明之類的,而 Cython 的 cimport 就是從 pxd 文件中進行屬性導入。
這一節我們就來介紹 cimport 語句的詳細信息,以及 .pyx、.pxd 文件之間的相互聯系,我們如何使用它們來構建更大的 Cython 項目。有了 cimport 和這兩種類型的文件,我們就可以有效地組織 Cython 項目,而不會影響性能。
Cython的實現文件(.pyx文件)和聲明文件(.pxd文件)
我們目前一直在處理 .pyx 文件,它是我們編寫具體 Cython 代碼的文件,當然它和 py 文件是等價的。如果的 Cython 項目非常小,並且沒有其它代碼需要訪問里面的 C 級結構,那么一個 .pyx 文件足夠了。但如果我們想要共享 pyx 文件中的 C 級結構,那么就需要 .pxd 文件了。
假設我們的文件還叫 cython_test.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 函數, 也就是 def 定義的函數
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 pyximport
pyximport.install(language_level=3)
import cython_test
g = cython_test.Girl('古明地覺', 16, 'female')
print(g) # <cython_test.Girl object at 0x000001C49D81B330>
g.get_info()
g.set_score([90.4, 97.3, 97.6])
print(g.get_score()) # [90.4, 97.3, 97.6]
訪問非常地自由,沒有任何限制,但是隨着我們 Girl 這個類的功能越來越多的話,該怎么辦呢?
所以我們需要創建一個 pxd 文件: cython_test.pxd,然后把我們希望暴露給外界訪問的 C 級結構放在里面。
# 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)
cpdef set_score(self, list scores) # 如果參數有默認值,那么在聲明的時候讓其等於 * 即可,比如:arg=*,表示該函數的 arg 參數有默認值
cpdef list get_score(self)
我們看到在 pxd 文件中,我們只存放了 C 級結構的聲明,像 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}, 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 文件中的可以直接刪掉了,會自動到對應的 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函數和非內聯C級函數、方法的實現
Python類的定義
IF或者DEF宏的外部Python可執行代碼
那么我們的 pxd 文件都帶來了哪些功能呢?那就是 cython_test.pyx 文件可以被其它的 pyx 文件導入了,這幾個 pyx 文件作為一個整體為 Python 提供更強大的功能,否則的話其它的 pyx 文件是無法導入的。所以我們應該將需要其它 pyx 文件導入的內容在對應的 pxd 文件中進行聲明,然后在導入的時候會去找 pxd 文件,根據里面聲明去(和當前 pxd 文件具有相同基名稱的 pyx 文件中)尋找對應的實現邏輯,而導入方式是使用 cimport。
cimport 和 import 語法一致,只不過前者多了一個 c,但是 cimport 是用來導入 pxd 文件中聲明的靜態數據。
多文件互相導入
然后我們在另一個 pyx 文件中導入這個 cython_test.pyx,當然導入的話其實尋找的是 cython_test.pxd,然后調用的是 cython_test.pyx 里面的具體實現。
# 文件名: caller.pyx
from cython_test cimport Girl
cdef class NewGirl(Girl):
pass
這里由於涉及到了多個 pyx 文件,所以我們先來介紹一下通過編譯的方式。
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))
編譯的命令和之前一樣,編譯之后會發現原來的目錄中有兩個 pyd 文件了。
將這兩個文件拷貝出來,首先在 caller.pyx 中是直接導入的 cython_test.pyx,因此這兩個 pyd 文件要也在一個目錄中。所以編譯之后,還要注意它們之間的層級關系。
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 寫更復雜一些。
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):
return super(NewGirl, self).get_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 文件一定要有相同基名稱的 pxd 文件。導入的時候使用 cimport,會去 pxd 文件中找,然后具體實現則是去 pyx 文件中尋找。
另外,可能有人發現了,我們這里是絕對導入。但實際上,一些 pyd 文件會放在單獨工程目錄中,這時候應該采用相對導入,況且它無法作為啟動文件,只能被導入。所以我們可以在 pyx 文件中進行相對導入,因為編譯之后的 pyd 文件和之前的 pyx 文件之間的關系是對應的。
然后我們將之前的 cython_test.pxd、cython_test.pyx、caller.pyx 放在一個單獨目錄中。
然后將 caller.pyx 中的絕對導入改成相對導入。
from .cython_test cimport Girl
然后編譯擴展模塊的時候可以用之前的方式編譯,只不過 Extension 中文件路徑要指定對。
from distutils.core import setup, Extension
from Cython.Build import cythonize
# 注意: Extension 的第一個參數, 首先我們這個文件叫做 build_ext.py, 當前的目錄層級如下
"""
當前目錄:
cython_relative_demo:
caller.pyx
cython_test.pxd
cython_test.pyx
build_ext.py
"""
# 所以我們的 build_ext.py 和 cython_relative_demo 是同級的
# 然后我們在編譯的時候, name(Extension 的第一個參數) 不可以指定為 caller、cython_test
# 如果這么做的話, 當代碼中涉及到相對導入的時候, 在編譯時就會報錯: relative cimport beyond main package is not allowed
# cython 編譯器要求, 你在編譯 pyx 文件、指定模塊名的時候, 也需要把該 pyx 文件所在的目錄也帶上
ext = [
Extension("cython_relative_demo.caller", sources=["cython_relative_demo/caller.pyx"]),
Extension("cython_relative_demo.cython_test", sources=["cython_relative_demo/cython_test.pyx"])
]
setup(ext_modules=cythonize(ext, language_level=3))
這樣編譯就沒有問題了,執行 python build_ext.py build 編譯成功,然后我們來看一下編譯之后的目錄:
我們看到多了我們之前指定的目錄,其實個人覺得 cython_relative_demo.caller 這種形式完全可以寫成 caller,因為文件路徑都已經指定了。但是 cython 編譯器要求,我們在執行相對導入的時候不可以只指定模塊名,我們也沒得辦法。
我們將這兩個文件拷貝出來,移動到下面的 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 setup, Extension
from Cython.Build import cythonize
# 這里也是一樣的道理, 因為我們這個編譯用的文件和 cython_relative_demo 是同級的
# 所以在指定模塊名的時候, 要從當前目錄中的 cython_relative_demo 開始定位
ext = [
Extension("cython_relative_demo.caller_dir.caller",
sources=["cython_relative_demo/caller_dir/caller.pyx"]),
Extension("cython_relative_demo.cython_test_dir.cython_test",
sources=["cython_relative_demo/cython_test_dir/cython_test.pyx"])
]
# 同理, 如果我們哪天自己也編寫一個開源的第三方庫(假設叫 matsuri), 要將目錄里面的 pyx 文件、或者目錄里面的目錄里面的 pyx 文件編譯成 pyd 文件時候
# 也應該從庫(matsuri)所在的目錄進行編譯, 然后從 matsuri 這一層開始進行文件定位, 即:
"""
ext = [Extension("matsur.xx.yy", sources=["matsur/xx/yy.pyx"]),
Extension("matsur.xx.xx.yy.zz", sources=["matsur/xx/xx/yy/zz.pyx"])
編譯完成之后, 再將 pyd 文件拷貝到對應的 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 文件的方式,指定編譯過程。否則的話,也會出現編譯失敗的情況,可以自己嘗試一下。
以上我們便將多個 Cython 源代碼組織起來了,但是除了這種方式之外,我們還可以使用 include 的方式。
# cython_test1.pyx
cdef a = 123
# cython_test2.pyx
include "./cython_test1.pyx"
cdef b = 234
print(a + b)
這里的 cython_test1.pyx 和 cython_test2.pyx 都定義在當前目錄吧,因為相對導入已經介紹完了,沒有必要再放在一個單獨的目錄中了。然后我們看到可以像 C 一樣使用 include 將別的 pyx 文件包含進來,就相當於在當前文件中定義的一樣。
import pyximport
pyximport.install(language_level=3)
import cython_test2
"""
357
"""
如果我們要編譯的話也是可以的,只需要指定 cython_test2 文件即可,include 的內容會自動加進來。
預定義的 .pxd 文件
還記得我們之前的 from libc.stdlib cimport malloc, free
這行代碼嗎?顯然這是 Cython 提供的,沒錯它就在 Cython 模塊主目錄下的 Includes 目錄中,libc 這個包下面除了 stdlib 之外,還有 stdio、math、string 等 pxd 文件。除此之外,Includes 目錄還有一個 libcpp 包對應的 pxd 文件,里面包含了 C++ 標准模板庫(STL)容器的聲明,如:string、vector、list、map、pair、set 等等。當然它們都是聲明,但是在編譯的時候會自動尋找相關實現,只不過實現邏輯需要借助編譯器,而我們看不到罷了。
當然除了 libc、libcpp 之外,Includes 目錄中還有其它的好東西,比如 cpython 目錄,里面提供了大量的 pxd 文件,通過它們可以方便地訪問 Python/C API。當然還有一個最重要的包就是 numpy,Cython 也是支持的,當然這些我們會在后面系列中介紹了。
使用 cimport 導入一個 C 模塊
# cython_test.pyx
from libc cimport math
print(math.sin(math.pi / 2))
from libc.math cimport sin, pi
print(sin(pi / 2))
import pyximport
pyximport.install(language_level=3)
import cython_test
"""
1.0
1.0
"""
cimport 的使用方式和 import 是一致的,但只不過上面導入的是更快的 C 版本。
from libcpp.vector cimport vector
cdef vector[int] *v1 = new vector[int](3)
Cython 也支持從 C++ STL 中導入 C++ 類。另外,如果我們使用 import、cimport 導入了具有相同名稱的不同函數,Cython 將引發一個編譯錯誤。
from libc.math cimport sin
from math import sin
"""
Error compiling Cython file:
------------------------------------------------------------
...
from libc.math cimport sin
from math import sin ^
------------------------------------------------------------
cython_test.pyx:2:17: Assignment to non-lvalue 'sin'
"""
為了修復這一點,我們只需要這么做。
from libc.math cimport sin as c_sin
from math import sin as py_sin
print(c_sin(3.1415926 / 2))
print(py_sin(3.1415926 / 2))
import pyximport
pyximport.install(language_level=3)
import cython_test
"""
0.9999999999999997
0.9999999999999997
"""
此時就沒有任何問題了。但如果我們導入的是模塊的話,那么是可以重名的。
from libc cimport math
import math
print(math.sin(math.pi / 2))
盡管 import math 是在下面,但是調用的時候會從 C 標准庫中進行調用,但是不管怎么樣,這種做法總歸是不好的。我們應該修改一下:
from libc cimport math as c_math
import math as py_math
因此這些預定義的 pxd 文件就類似於 C、C++ 中的頭文件。
它們都聲明了 C 一級的數據結構供外界調用
它們都允許我們對功能進行拆分, 分別通過不同的模塊實現
它們都實現了公共的 C 級接口
C、C++ 頭文件通過 #include 命令進行訪問,該命令會對相應的頭文件進行包含。而 Cython 的 cimport 更智能,也更不容易出錯,我們可以把它看做是一個使用命名空間的編譯時導入語句。
而 Cython 的早期沒有 cimport 語句,而是有一個 include 語句,我們之前說過了,它是在源碼級對文件進行包含。而 include 則類似於 Python 的import,如果你覺得這個文件內容太多了,那么可以單獨進行拆分,然后再使用 include 包含進去。
總結
pyx 文件、pxd 文件,再加上 cimport、include,我們可以將 Cython 代碼組織到單獨的模塊和包中,而不犧牲性能。這使得 Cython 可以進行擴展,而不僅僅用來加速,它完全可以作為主力語言開發一個成熟的項目。