楔子
前面我們說了 Cython 是什么,為什么我們要用它,以及如何編譯和運行 Cython 代碼。有了這些知識,那么是時候進入 Cython 的深度探索之路了。
Cython 和 Python 的差別從大方向上來說無非兩個,一個是:運行時解釋和預先編譯;另一個是:動態類型和靜態類型。
解釋執行 VS 編譯執行
為了更好地理解為什么 Cython 可以提高 Python 代碼的執行性能,有必要對比一下 Python 虛擬機執行 Python 代碼和操作系統執行已經編譯的 C 代碼之間的差別。
Python代碼在運行之前,會先被編譯成 pyc 文件(里面存儲的是 Python 底層的PyCodeObject 對象),然后讀取里面的 PyCodeObject 對象,執行內部的字節碼。而字節碼是能夠被 Python 虛擬機解釋或者執行的基礎指令集,並且虛擬機獨立於平台,因此在一個平台生成的字節碼可以在任意平台運行。虛擬機將一個高級字節碼翻譯成一個或者多個可以被操作系統執行、最終被CPU執行的低級操作(指令)。這種虛擬化很常見並且十分靈活,可以帶來很多好處:其中一個好處就是不會被挑剔的編譯器嫌棄(相較於編譯型語言,你在一個平台編譯的可執行文件在其它平台上可能就用不了了),而缺點是運行速度比本地編譯好的代碼慢。
而站在 C 的角度,由於不存在虛擬機或者解釋器,因此也就不存在所謂的高級字節碼。C 代碼會被直接翻譯、或者編譯成機器碼,可以直接以一個可執行文件或者動態庫(dll 或者 so)的形式存在。但是注意:它依賴於當前的操作系統,是為當前平台和架構量身打造的,因為可以直接被 CPU 執行,而且級別非常低(伴隨着速度快),所以它與所在的操作系統是有關系的。
那么有沒有一種辦法可以彌補虛擬機的字節碼和 CPU 的機器碼之間的宏觀差異呢?答案是有的,那就是 C 代碼可以被編譯成一種稱之為擴展模塊的特定類型的動態庫,並且這些模塊必須是成熟的 Python 模塊,但是里面的代碼已經是經由標准 C 編譯器編譯成的機器代碼。那么 Python 在導入擴展模塊執行的時候,虛擬機不會再解釋高級字節碼,而是直接運行機器代碼,這樣就能移除解釋器的性能開銷。
這里我們提一下擴展模塊,我們說 Windows 中存在dll(動態鏈接庫)、Linux中存在 so(共享文件)。如果只是 C 或者 C++、甚至是是 Go 等等編寫的普通源文件,然后編譯成 dll 或者 so,那么這兩者可以通過 ctypes 調用,但是無法通過 import 導入。如果你強行導入,那么會報錯:
ImportError: dynamic module does not define module export function
但是如果是遵循 Python/C API 編寫,然后使用 Python 編譯成擴展模塊的話,盡管該擴展模塊在 Linux 上也是 .so、Windows 上是 pyd(pyd也是個dll),但它們是可以直接被 Python 解釋器識別被導入的。
那么 Cython 是怎么插上一腳的呢?正如我們在上一篇博客說的那樣,我們可以使用 cython 編譯器和標准 C 編譯器將 Cython 源代碼翻譯成依賴特定平台的擴展模塊,這樣 Python 在執行擴展模塊的時候,就等同於運行機器碼,省去了翻譯的過程。
那么將一個普通的 Python 代碼編譯成擴展模塊的話(我們說Cython是Python的超集,即使是純Python也是可以編譯成擴展模塊的),效率上可以有多大的提升呢?根據 Python 代碼所做的事情,這個差異會非常廣泛,但是通常將 Python 代碼轉換成等效的擴展模塊的話,效率大概有10%到30%的提升。因為一般情況下,代碼既有 IO 密集也會有 CPU 密集。
所以即便沒有任何的 Cython 代碼,純 Python 在編譯成擴展模塊之后也會有性能的提升。並且如果是純計算型,那么效率會更高。
Cython 給了我們免費加速的便利,讓我們在不寫 Cython、或者只寫純Python的情況下,還能得到優化。但這種只針對純 Python 進行的優化顯然只是擴展模塊的冰山一角,真正的性能改進是使用 Cython 的靜態類型來替換 Python 的動態解析。
因為我們說 Python 不會進行基於類型的優化,所以即使編譯成擴展模塊,但如果類型不確定,還是沒有辦法達到高效率的。
就拿兩個變量相加舉例:我們說 Python 不會做基於類型方面的優化,所以這一行代碼對應的機器碼數量顯然會很多,即使編譯成了擴展模塊之后,其對應的機器碼數量也是類似的(內部會有優化,因此機器碼數量可能會少一些,但不會少太多)。這兩者區別就是:Python 是有一個翻譯的過程,將字節碼翻譯成機器碼;而擴展模塊是已經直接就幫你全部都翻譯成機器碼了。但是CPU執行的時候,由於機器碼數量是差不多的,因此執行時間也是差不多的,區別就是少了一個翻譯的過程。但是很明顯,Python 將字節碼翻譯成機器碼花費的時間幾乎是不需要考慮的,重點是在 CPU 執行機器碼所花費的時間。
因此將純 Python 代碼編譯成擴展模塊,速度不會提升太明顯,提升的 10~30% 也是 cython 編譯器內部的優化,比如:發現函數中某個對象在函數結束就不被使用了,所以將其分配的棧上等等。如果使用 Cython 時指定了類型,那么類型確定的話機器碼的數量就會大幅度減少。CPU執行10條機器碼花的時間和執行1條機器碼花的時間那個長,不言而喻。
因此使用 Cython,重點是規定好類型,一旦類型確定,那么速度會快很多。
動態類型 VS 靜態類型
Python 語言和 C、C++ 之間的另一個重要的差異就是:前者是動態類型,后者是靜態類型。靜態類型語言要求在編譯的時候就必須指定變量的類型,我們經常會通過顯式的聲明一個變量來完成這一點,或者在某些情況下編譯器會自動推斷變量的類型。另一方面,如果一旦聲明某個變量,那么之后此作用域該中變量的類型就不可以再改變了。
看起來限制還蠻多的,那么靜態類型可以帶來什么好處呢?除了編譯時的類型檢測,編譯器也可以根據靜態類型生成適應相應平台的高性能機器碼。
動態語言(針對於 Python)則不一樣了,對於動態語言來說,類型不是和變量綁定的,而是和對象綁定的,變量只是一個指向對象的指針罷了。因此 Python 中如果想創建一個變量,那么必須在創建的同時賦上值,不然 Python 不知道這個變量到底指向哪一個對象。而像 C 這種靜態語言,可以創建一個變量的同時不賦上初始值,比如:int n,因為已經知道 n 是一個 int 類型了,而且分配的空間大小已經確定了。
並且對於動態語言來說,變量即使在同一個作用域中也可以指向任意的對象。並且我們說 Python 中的變量是一個指針,比如:a = 666,相當於創建了一個整型 666,然后讓 a 這個變量指向它;如果再來一個 a = "古明地覺",那么會再創建一個字符串,然后讓 a 指向這個字符串,或者說 a 不再存儲整型 666 的地址,而是存儲新創建的字符串的地址。
所以在運行 Python 程序時,解釋器要花費很多時間來確認要執行的低階操作,並抽取相應的數據。考慮到 Python 設計的靈活性,解釋器總是要一種非常通用的方式來確定相應的低階操作,因為 Python 中的變量在任意時刻可以有任意類型。以上便是所謂的動態解析,而 Python 的通用動態解析是緩慢的。還是以 a + b 為栗:
1. 解釋器要檢測 a 引用的對象的類型,這在C一級至少需要一次指針查找。
2. 解釋器從該類型中尋找加法方法的實現,這可能一個或者多個額外的指針查找和內部函數調用。
3. 如果解釋器找到了相應的方法,那么解釋器就有了一個實際的函數調用。
4. 解釋器會調用這個加法函數,並將 a 和 b 作為參數傳遞進去。
5. 我們說 Python 中的對象在C中都是一個結構體,比如:整型在 C 中是 PyLongObject,內部有引用計數、類型、ob_size、ob_digit,這些成員是什么不必關心,總之其中一個成員肯定是存放具體的值的,其他成員是存儲額外的屬性的。而加法函數顯然要從這兩個結構體中篩選出實際的數據,顯然這需要指針查找以及將數據從 Python 類型轉換到 C 類型。如果成功,那么會執行加法的實際操作;如果不成功,比如類型不對,發現 a 是整型但 b 是個字符串,就會報錯。
6. 執行完加法操作之后,必須將結果再轉回 Python 中的對象,然后獲取它的指針、轉成 PyObject * 之后才能夠返回。
而 C 語言面對 a + b 這種情況,表現則是不同的。因為 C 是靜態編譯型語言,C 編譯器在編譯的時候就決定了執行的低階操作和要傳遞的參數數據。在運行時,一個編譯好的 C 程序幾乎跳過了 Python 解釋器要必須執行的所有步驟。對於 a + b,編譯器提前就確定好了類型,比如整型,那么編譯器生成的機器碼指令是寥寥可數的:將數據加載至寄存器,相加,存儲結果。
所以我們看到編譯后的 C 程序幾乎將所有的時間都只花在了調用快速的 C 函數以及執行基本操作上,沒有 Python 的那些花里胡哨的動作。並且由於靜態語言對變量類型的限制,編譯器會生成更快速、更專業的指令,這些指令是為其數據量身打造的。因此某些操作,使用 C 語言可以比使用 Python 快上幾百倍甚至幾千倍,這簡直再正常不過了。
因此 Cython 在性能上可以帶來如此巨大的提升的原因就在於,它將靜態類型引入 Python 中,靜態類型將 運行時的動態解析 轉化成 基於類型優化的機器碼。
另外,在 Cython 之前我們只能通過在 C 中重新實現 Python 代碼來從靜態類型中獲益,也就是用 C 來編寫所謂的擴展模塊。而 Cython 可以讓我們很容易地寫類似於 Python 代碼的同時,還能使用 C 的靜態類型系統。而我們下面將要學習的第一個、也是最重要的 Cython 關鍵字:cdef,它是我們通往 C 性能的大門。
通過 cdef 進行靜態類型聲明
首先 Python 中聲明變量的方式在 Cython 中也是可以使用的,因為 Python 代碼也是合法的 Cython 代碼。
a = [x for x in range(12)]
b = a
a[3] = 42.0
assert b[3] == 42.0
a = "xxx"
assert isinstance(b, list)
在 Cython 中,沒有類型化的動態變量的行為和 Python 完全相同,通過賦值語句 b = a 允許 b 和 a 都指向同一個列表。在 a[3] = 42.0 之后,b[3] = 42.0 也是成立的,因此斷言成立。即便后面將 a 修改了,也只是讓 a 指向了新的對象,調整相應的引用計數,而對 b 而言則沒有受到絲毫影響,因此 b 指向的依舊是一個列表。這是完全合法、並且有效的 Python 代碼。
而對於靜態類型變量,我們在 Cython 中通過 cdef 關鍵字並指定類型、變量名的方式進行聲明。比如:
cdef int i
cdef int j
cdef float k
# 我們看到就像使用 Python 和 C 的混合體一樣
j = 0
i = j
k = 12.0
j = 2 * k
assert i != j
上面除了變量的聲明之外,其它的使用方式和 Python 並無二致,當然簡單的賦值的話基本上所有語言都是類似的。但是 Python 的一些內置的函數、類、關鍵字等等都是可以直接使用的,因為我們在 Cython 中是可以直接寫 Python 代碼的,它是 Python 的超集。
但是有一點需要注意:我們上面創建的變量 i、j、k 是 C 中的類型(int、float 比較特殊,后面會解釋),其意義最終是要遵循 C 的標准的。
不僅如此,就連使用 cdef 聲明變量的方式也是按照 C 的標准來的。
cdef int i, j, k
cdef float x, y
# 或者
cdef int a = 1, b = 2
cdef float c = 3.0, b = 4.1
而在函數內部,cdef 也是要進行縮進的,它們聲明的變量也是一個局部變量。
def foo():
# 這里的 cdef 是縮進在函數內部的
cdef int i
cdef int N = 2000
cdef float a, b = 2.1
並且 cdef 還可以使用類似於 Python 中上下文的方式。
def foo():
# 這種聲明方式也是可以的, 和上面的方式是完全等價的
cdef:
int i
int N = 2000
float a, b = 2.1
# 但是注意聲明的變量要注意縮進
# Python 對縮進是有講究的, 它規定了 Python 中的作用域
# 所以我們看到 Cython 在語法方面還是保留了 Python 的風格
關於靜態和常量
如果你了解 C 的話,那么思考一下:如果想在函數中返回一個局部變量的指針並且外部在接收這個指針之后,還能訪問指針指向的值,這個時候該怎么辦呢?我們知道 C 函數中的變量是分配在棧上的(不使用 malloc 函數,而是直接創建一個變量),函數結束之后變量對應的值就被銷毀了,所以這個時候即使返回一個指針也是無意義的。盡管比較低級的編譯器檢測不出來,你在返回指針之后還是能夠訪問指向的內存,但是這只是你當前使用的編輯器比較笨,它檢測不出來。如果是高級一點的編譯器,那么你在訪問的時候會報出段錯誤或者打印出一個錯誤的值;而更高級的編譯器甚至連指針都不讓你返回了,因為指針指向的內存已經被回收了,你要這個指針做什么?因此指針都不讓你返回了。
而如果想做到這一點,那么只需要在聲明變量的同時在前面加上 static 關鍵字,比如 static int i,這樣的話 i 這個變量就不會被分配到棧區,而是會被分配到文字常量區,它的聲明周期就不會隨着函數的結束而結束,而是伴隨着整個程序。
但是 static 並不是一個有效的 Cython 關鍵字,因此我們無法在 Cython 聲明一個 C 的靜態變量。除了 static,在C中還有一個 const,用來聲明一個不可變的變量,也就是常量,一旦使用 const聲明,比如 const int i = 3,那么這個 i 在后續就不可以被修改了。而在Cython中,const 是支持的,但是目前我們不需要關心,后續系列會介紹。
注意:以上的 int、float 都是 C 中的類型,不是 Python 中的類型(后面會詳細說),但除了 int、float 之外,我們還能在 Cython 中聲明哪些 C 的類型呢?
首先基礎類型,像 short、int、long、unsigned short、long long、size_t、ssize_t 等等都是支持的,聲明變量的方式均為:cdef 類型 變量
,可以聲明的時候賦初始值,也可以不賦初始值(這些都是 C 中的類型);還有指針、數組、定義類型別名、結構體、共同體、函數指針等等也是支持的,我們后面介紹的時候說。
舉個栗子,如果要在 Cython 中定義一個 C 的函數指針要怎么做呢?
cdef int (*signal(int (*f)(int)))(int)
我們看到函數指針的聲明和 C 也是一模一樣的,只需在開頭加上一個 cdef 而已。但是不要慌,我們一般不會定義這種函數指針,直接定義一個 Python 函數不香嗎?誰沒事定義這玩意兒。
但是問題來了,你知道上面的函數指針指向一個什么函數嗎?一點一點分析的話,會發現指向的函數接受一個函數指針作為參數、並返回一個函數指針,而這兩個函數指針均指向接收整型、返回整型的函數。
有點繞,可以慢慢分析,不過日常也用不到這樣的函數聲明。否則就類似於寫 C 了,因為我們說寫 Cython 的感覺是像 Python 的。
Cython中的自動類型推斷
在 Cython 中聲明一個靜態類型變量,使用 cdef 並不是唯一的方法,Cython 會對函數體中沒有進行類型聲明的變量自動執行類型推斷。比如:for 循環中全部都是整型相加,沒有涉及到其它類型的變量,那么 Cython 在自動對變量進行推斷的時候會發現這個變量可以被優化為靜態類型的變量。
但是一個程序不會那么智能地對於一個動態類型的語言進行全方位的優化,默認情況下,Cython 只有在確認這么做不會改變代碼塊的語義之后才會進行類型推斷。
看一下下面這個簡單的函數:
def automatic_inference():
i = 1
d = 2.0
c = 3 + 4j
r = i * d + c
return r
在這個例子中,Cython 將字面量 1、3 +4j 以及變量 i、c、r 標記為通用的Python 對象。盡管這些對象的類型和 C 中的類型具有高度的相似性,但是 Cython 會保守地推斷整型 i 可能無法代表 C 中的 long(C 中的整數有范圍,而 Python沒有、可以無限大),因此會將其作為符合 Python 代碼語義的 Python 對象。而對於 d = 2.0,則可以自動推斷出符合 C 中的 double,因為 Python 中的浮點數對應的值在底層就是使用一個 double 來存儲的。所以最終對於用戶來講,變量 d 看似是一個 Python 中的對象,但是 Cython 在執行的時候會講其視為 C 中的 double 以提高性能。
這就是即使我們寫純 Python,cython 編譯器也能進行優化的原因,因為會進行推斷。但是很明顯,我們不應該讓 cython 編譯器去推斷,而是我們來明確指定對應的類型。
當然我們如果非要 cython 編譯器去猜,也是可以的,而且還可以通過 infer_types 編譯器指令,在一些可能會改變 Python 代碼語義的情況下給 Cython 留有更多的余地來推斷一個變量的類型。比如:當兩個整型相加時可能導致的結果溢出,因為 Python 中的整型在底層是使用數組來存儲的,所以不管多大都可以相加,只要你的內存足夠。但是在 C 中不可以,因為 C 中的變量是有明確的類型的,既然是類型,那么空間在一開始就已經確定了。比如 int 使用4個字節,而一旦結果使用4個字節無法表示的時候,就會得到一個意向不到的錯誤結果。所以如果非要 Cython 來類型推斷的話,我們是需要給其留有這樣的余地的。
對於一個函數如果啟動這樣的類型推斷的話,我們可以使用 infer_types 的裝飾器形式。不過還是那句話,我們應該手動指定類型,而不是讓 cython 編譯器去推斷,因為我們是代碼的編寫者,類型什么的我們自己最清楚。
cimport cython
@cython.infer_types(True)
def more_inference():
i = 1
d = 2.0
c = 3 + 4j
r = i * d + c
return r
這里出現了一個新的關鍵字叫做 cimport,至於它的含義我們后面會說,目前只需要知道它和 import 關鍵字一樣,是用來導入模塊的即可。然后我們通過裝飾器 @cython.infer_types(True)
,啟動了相應的類型推斷,也就是給 Cython 留有更多的猜測空間。
當 Cython 支持更多的推斷的時候,變量 i 被類型化為 C long;d 和之前一樣是 double,而 c 和 r 都是復數變量,復數則依舊使用 Python 中的復數類型。但是注意:並不代表啟用 infer_types 時,就萬事大吉了;我們知道在不指定 infer_types 的時候,Cython 在推斷類型的時候顯然是采用最最保險的方法、在保證程序正確執行的情況下進行優化,不能因為為了優化而導致程序出現錯誤,顯然正確性和效率之間正確性是第一位的。而整型由於存在溢出的問題,所以 Cython 是不會自動轉化為 C long 的;但是我們通過 infer_types 啟動了更多的類型推斷,因此在不改變語義的情況下 Cython 是會將整型推斷為C long的,但是溢出的問題它不知道,所以在這種情況下是需要我們來要負責確保整型不會出現溢出。
Cython 中的 C 指針
正如我們說的,可以使用 C 的語法在 Cython 中聲明一個 C 指針。
cdef double a
cdef double *b = NULL
# 和 C 一樣, *可以放在類型或者變量的附近
# 但是如果在一行中聲明多個指針變量, 那么每一個變量都要帶上*
cdef double *c, *d
# 如果是這樣的話, 則表示聲明一個指針變量和一個整型變量
cdef int *e, f
既然可以聲明指針變量,那么說明能夠取得某個變量的地址才對。是的,在 Cython 中通過 & 獲取一個變量的地址。
cdef double a = 3.14
cdef double *b = &a
問題來了,既然可以獲取指針,那么能不能通過 * 來獲取指針指向的值呢?答案可以獲取值,但是方式不是通過 * 來實現。我們知道在 Python 中,*有特殊含義,沒錯,就是 *args 和 **kwargs,它們允許函數中接收任意個數的參數,並且通過 * 還可以對一個序列進行解包。因此對於 Cython 來講,無法通過 *p 這種方式來獲取 p 指向的內存。在 Cython 中獲取指針指向的內存的方式是通過類似於 p[0] 這種方式,p 是一個指針變量,那么 p[0] 就是 p 指向的內存。
cdef double a = 3.14
cdef double *b = &a
print(f"a = {a}")
# 修改b指向的內存
b[0] = 6.28
# 再次打印a
print(f"a = {a}")
這個模塊叫做 cython_test.pyx,然后在另一個 py 文件中導入。
import pyximport
pyximport.install(language_level=3)
import cython_test
"""
a = 3.14
a = 6.28
"""
pyx 里面有 print 語句,因此導入的時候就自動打印了,我們看到 a 確實被修改了。因此我們在 Cython 中可以通過 & 來獲取指針,也可以通過 指針[0]
的方式獲取指針指向的內存。唯一的區別就是C里面是使用 * 的方式,而在 Cython 里面如果使用 *b = 6.28
這種方式在語法上則是不被允許的。
而 C 和 Cython 中關於指針的另一個區別就是該指針在指向一個結構體的時候,假設一個結構體指針叫做 s,里面有兩個成員 a 和 b,都是整型。那么對於 C 而言,可以通過 s -> a + s -> b
的方式將兩個成員相加,但是對於 Cython 來說,則是 s.a + s.b
。我們看到這個和 Go 是類似的,無論是結構體指針還是結構體本身,都是使用 .
的方式訪問結構體內部成員。
靜態類型變量和動態類型變量的混合
Cython 允許靜態類型變量和動態類型變量之間進行賦值,這是一個非常強大的特性。它允許我們使用動態的 Python 對象,並且在決定性能的地方能很輕松地將其轉化為快速的靜態對象。
假設我們有幾個靜態的 C int 要組合成一個 Python 中的元組,Python/C API 創建和初始化的話很簡單,但是卻很乏味,需要幾十行代碼以及大量的錯誤檢查;而在Cython中,只需要像 Python 一樣做即可:
cdef int a, b, c
t = (a, b, c)
然后我們來導入一下:
import pyximport
pyximport.install(language_level=3)
import cython_test
# 我們看到在Cython中沒有指定初始值, 所以默認為0
# 比如我們直接 a = int(), 那么 a 也是 0
print(cython_test.t) # (0, 0, 0)
print(type(cython_test.t)) # <class 'tuple'>
print(type(cython_test.t[0])) # <class 'int'>
# 雖然t是可以訪問的, 但是 a、b、c 是無法訪問的,因為它們是 C 中的變量
# 使用 cdef 定義的變量都會被屏蔽掉,在 Python 中是無法使用的
try:
print(cython_test.a)
except Exception as e:
print(e) # module 'cython_test' has no attribute 'a'
我們看到執行的過程很順暢,這里要說的是:a、b、c 都是靜態的整型,Cython 允許使用它們創建動態類型的 Python 元組,然后將該元組分配給 t。所以這個小栗子便體現了 Cython 的美麗和強大之處,可以以顯而易見的方式創建一個元組,而無需考慮其它情況。因為 Cython 的目的就在於此,希望概念上簡單的事情在實際操作上也很簡單。
想象一下使用 Python/C API 的場景,如果要創建一個元組該怎么辦?首先要使用 PyTuple_New 申請指定元素個數的空間,還要考慮申請失敗的情況,然后調用 PyTuple_SetItem 將元素一個一個的設置進去,這顯然是非常麻煩的,肯定沒有
t = (a, b, c)
來的直接。
雖說如此,但並不是所有東西都可以這么做的。上面的例子之所以有效,是因為Python int 和 C int(short、long等等)有明顯的對應關系。如果是指針呢?首先我們知道 Python 中沒有指針這個概念,或者說指針被 Python 隱藏了,只有解釋器才能操作指針。因此在 Cython 中,我們不可以在函數中返回一個指針,以及打印一個指針、指針作為 Python 的動態數據結構(如:元組、列表、字典等等)中的某個元素,這些都是不可以的。
回到我們元組的那個例子,如果 a、b、c 是一個指針,那么必須要在放入元組之前取消它們的引用,或者說放入元組中的只能是它們指向的值。因為 Python 在語法層面沒有指針的概念,所以不能將指針放在元組里面。同理:假設 cdef int a = 3
,可以是cdef int *b = &a
,但絕不能是 b = &a
。因為直接 b = xxx
的話,那么 b 是 Python 中的變量,其類型則需要根據值來推斷,然而值是一個指針,所以這是不允許的。
但是 cdef int b = a
和 b = a
則都是合法的,因為 a 是一個整型,C 中的整型是可以轉化成 Python 中的整型的,因此編譯的時候會自動轉化。只不過如果是前者那么相當於創建了一個 C 的變量 b,Python 導入的時候無法訪問;如果是后者,那么相當於創建一個 Python 變量 b,Python 導入的時候可以訪問。
舉個例子:
cdef int a
b = &a
"""
cdef int a
b = &a
^
------------------------------------------------------------
cython_test.pyx:5:4: Cannot convert 'int *' to Python object
Traceback (most recent call last):
"""
# 我們看到在導入的時候, 編譯失敗了, 因為 b 是 Python 中的類型, 而 &a 是一個 int*, 所以無法將 int * 轉化成 Python 對象
再舉個例子:
cdef int a = 3
cdef int b = a
c = a
import pyximport
pyximport.install(language_level=3)
import cython_test
try:
print(cython_test.a)
except Exception as e:
print(e) # module 'cython_test' has no attribute 'a'
try:
print(cython_test.b)
except Exception as e:
print(e) # module 'cython_test' has no attribute 'b'
print(cython_test.c) # 3
我們看到 a 和 b 是 C 中的類型,無法訪問,但變量 c 是可以訪問的。不過問題又來了,看一下下面的幾種情況:
先定義一個C的變量,然后給這個變量重新賦值:
cdef int a = 3
a = 4
# Python中能否訪問到 a 呢?
# 答案是訪問不到的, 雖說是 a = 4, 像是創建 Python 的變量, 但是不好意思, 上面已經創建了 C 的變量 a
# 因此下面再操作 a,都是操作 C 的變量 a, 如果你來一個a = "xxx", 那么是不合法的
# a 已經是整型了,你再將一個字符串賦值給 a 顯然不是合法的
先定義一個 Python 變量,再定義一個同名的 C 變量:
b = 3
cdef int b = 4
"""
b = 3
^
------------------------------------------------------------
cython_test.pyx:4:0: Previous declaration is here
warning: cython_test.pyx:5:9: cdef variable 'b' declared after it is used
"""
# 即使一個是 Python 的變量, 一個是 C 的變量, 也依舊不可以重名。不然訪問 b 的話,究竟訪問哪一個變量呢?
# 所以 b = 3 的時候, 變量就已經被定義了。而 cdef int b = 4 又定義了一遍, 顯然是不合法的。
# 不光如此, cdef int c = 4 之后再寫上 cdef int c = 5 仍然屬於重復定義, 不合法。
# 但 cdef int c = 4 之后,寫上 c = 5 是合法的, 因為這相當於改變 c 的值, 並沒有重復定義。
先定義一個 Python 變量,再定義一個同名的 Python 變量:
cdef int a = 666
v = a
print(v)
cdef double b = 3.14
v = b
print(v)
# 這么做是合法的, 其實從 Cython 是 Python 的超集這一點就能理解。
# 主要是:Python 中變量的創建方式和 C 中變量的創建方式是不一樣的, Python 中的變量在 C 中是一個指向某個值的指針, 而 C 中的變量就是代表值本身
# cdef int a = 666, 相當於創建了一個變量 a, 這個變量 a 代表的就是 666 本身, 而這個 666 是 C 中整數 666
# 而 v = a 相當於先根據 a 的值、也就是 C 中 整數666 創建一個 Python 的整數 666, 然后再讓 v 指向它
# 那么 v = b 也是同理, 因為 v 是 Python 中的變量, 它想指向誰就指向誰; 而 b 是一個 C 中的 double, 可以轉成 Python 的 float
# 但如果將一個指針賦值給 v 就不可以了, 因為 Python 中沒有哪個數據類型可以和 C 中的指針相對應
再來看一個栗子:
num = 666
a = num
b = num
print(id(a) == id(b)) # True
首先這個栗子很簡單,因為 a 和 b 指向了同一個對象,但如果是下面這種情況呢?
cdef int num = 666
a = num
b = num
print(id(a) == id(b))
你會發現打印的是 False,因為此時這個 num 是 C 中變量,然后 a = num 會先根據 num 的值創建一個 Python 中的整數,然后再讓 a 指向它;同理 b 也是如此,而顯然這會創建兩個不同 666,雖然值一樣,但是地址不一樣。
所以這就是 Cython 的方便之處,不需要我們自己轉化,而是在編譯的時候會自動轉化。當然還是按照我們之前說的,自動轉化的前提是可以轉化,也就是兩者之間要互相對應(比如 Python 的 int 和 C 的 int、long,Python 的 float 和 C 的 float、double 等等)。
而我們說因為 Python int 和 C/C++ int 之間是對應的,所以 Python 會自動轉化,那么其它類型呢?Python 類型和 C/C++ 類型之間的對應關系都有哪些呢?
注意:對於這些 C 的類型,Cython 有更豐富的類型來表示。
bint 類型
bint 在 C 中是一個布爾類型,但其實本質上是一個整型,然后會自動轉化為 Python 的布爾類型,當然 Python 中布爾類型也是繼承自整型。bint 類型有着標准 C 的實現:0為假,非0為真。
cdef bint flag1 = 123 # 非0是True
cdef bint flag2 = 0 # 0是False
a = flag1
b = flag2
這里我們要進行賦值給 Python 中的變量,不然后續無法訪問。
import pyximport
pyximport.install(language_level=3)
import cython_test
print(cython_test.a) # True
print(cython_test.b) # False
整數類型與轉換溢出
在 Python2 中,有 int 和 long 兩種類型來表示整數。Python2 中的 int 使用 C 中的 long 來存儲,是有范圍的,而 Python2 中的 long 是沒有范圍的;但在Python3中,只有int,沒有long,而所有的 int 對象都是沒有范圍的。
將 Python 中的整型轉化成 C 中的整型時,Cython 生成代碼會檢測是否存在溢出。如果 C 中的 long 無法表示 Python 中的整型,那么運行時會拋出 OverflowError。
i = 2 << 81 # 顯然 C 中的 int 是存不下的
cdef int j = i
import pyximport
pyximport.install(language_level=3)
import cython_test
"""
...
...
File "cython_test.pyx", line 3, in init cython_test
cdef int j = i
ImportError: Building module cython_test failed: ['OverflowError: Python int too large to convert to C long\n']
"""
我們看到轉成 C 的 int 時,如果存不下會自動嘗試使用 long,如果還是越界則報錯。
float類型
Python 中的 float 對應的值在 C 中也是用 double 來存儲的,對於浮點來說可以放心使用。
typedef struct {
PyObject_HEAD
double ob_fval;
} PyFloatObject;
// Python 中的對象在底層都是一個結構體, float 對象則是一個 PyFloatObject
// 而 PyObject_HEAD 是一些額外信息:引用計數、指向對應類型的指針
// 而是 ob_fval 則是真正存放具體的值的, 顯然這是一個double
復數類型
Python 中的復數在 C 中是使用兩個 double 來存儲的,一個存儲實部、一個存儲虛部。
typedef struct {
double real;
double imag;
} Py_complex;
typedef struct {
PyObject_HEAD
Py_complex cval;
} PyComplexObject;
復數不常用,了解一下即可。
bytes類型、str類型
在 Cython 中我們如果想創建一個字節串可以使用 bytes,而創建一個字符串則是 str 或者 unicode。沒錯,這些都是 Python 中的類型,關於 C 類型和 Python 類型在 Cython 中的表現我們后面會詳細說。
# 創建一個字節串使用 bytes
cdef bytes name = "古明地覺".encode("utf-8")
# 創建一個字符串可以使用 str, 和 Python 一樣
cdef str where1 = "東方地靈殿"
# 也可以使用 unicode, 但是字符串要有前綴u,兩種方式在 Python3 是等價的, 因此建議使用 str
# 之所以會有 unicode 是為了兼容 Python2
cdef unicode where2 = u"東方地靈殿"
NAME = name
WHERE1 = where1
WHERE2 = where2
import pyximport
pyximport.install(language_level=3)
import cython_test
print(cython_test.NAME.decode("utf-8")) # 古明地覺
print(cython_test.WHERE1) # 東方地靈殿
print(cython_test.WHERE2) # 東方地靈殿
print(cython_test.NAME.decode("utf-8")[2]) # 地
print(cython_test.WHERE1[: 2] + cython_test.WHERE2[2:]) # 東方地靈殿
當然還有很多很多類型,別着急,我們在后續會慢慢介紹。
使用 Python 類型進行靜態聲明
我們之前使用 cdef 的時候用的都是 C 中的類型,比如 cdef int、cdef float,當然 Python 中也有這兩個,不過我們使用的確實是 C 中的類型,再或者 cdef unsigned long long 等等也是可以的。那么可不可以使用 Python 中的類型進行靜態聲明呢,其實細心的話會發現是可以的,因為我們上面使用了 cdef str 聲明變量了。
不光是 str,只要是在 CPython 中實現了,並且 Cython 有權限訪問的話,都可以用來進行靜態聲明,而 Python 中內建的類型都是滿足要求的。換句話說,只要在 Python 中可以直接拿來用的,都可以直接當成 C 的類型來進行聲明(bool 類型除外)。
# 聲明的時候直接初始化
cdef tuple b = tuple("123")
cdef list c = list("123")
cdef dict d = {"name": "古明地覺"}
cdef set e = {"古明地覺", "古明地戀"}
cdef frozenset f = frozenset(["古明地覺", "古明地戀"])
A = a
B = b
C = c
D = d
E = e
F = f
import pyximport
pyximport.install(language_level=3)
from cython_test import *
print(A) # 古明地覺
print(B) # ('1', '2', '3')
print(C) # ['1', '2', '3']
print(D) # {'name': '古明地覺'}
print(E) # {'古明地戀', '古明地覺'}
print(F) # frozenset({'古明地戀', '古明地覺'})
我們看到得到的結果是正確的,完全可以當成 Python 中的類型來使用。這里在使用 Python 中的類型進行靜態聲明的時候,我們都賦上了一個初始值,但如果只是聲明沒有賦上初始值,那么得到的結果是一個 None。注意:只要是用 Python 中的類型進行靜態聲明且不賦初始值,那么結果都是 None。比如:cdef tuple b; B = b
,那么 Python 在打印 B 的時候得到的就是 None,而不是一個空元組。不過整型是個例外,因為 int 我們實際上用的是 C 里面 int,會得到一個 0,當然還有float。
為什么 Cython 可以做到這一點呢?實際上這些結構在 CPython 中都是已經實現好了的,Cython 將它們設置為指向底層中某個數據結構的 C 指針,比如:cdef tuple a,那么 a 就是一個PyTupleObject *,它們可以像普通變量一樣使用,當然 Python 中的變量也是一樣的,a = tuple(),那么 a 同樣是一個 PyTupleObject *。
同理我們想一下 C 擴展,我們使用 Python/C API 編寫擴展模塊的時候,也是一樣的道理,只不過還是那句話,使用 C 來編寫擴展非常的麻煩,因為用 C 來開發本身就是麻煩的事情。所以 Cython 幫我們很好的解決了這一點,讓我們可以將寫 Python 一樣寫擴展,會自動地將我們的代碼翻譯成C級的代碼。因此從這個角度上講,Cython 可以做的,使用純 C 來編寫擴展也是完全可以做的,區別就是一個簡單方便,一個麻煩。更何況使用 C 編寫擴展,需要掌握Python/C API,而且還需要有 Python 解釋器方面的知識,門檻還是比較高的,可能一開始掌握套路了還不覺得有什么,但是到后面當你使用 C 來實現一個 Python 中的類的時候,你就知道這是一件相當恐怖的事情了。而在 Cython 中,定義一個類仍然非常簡單,像 Python 一樣,我們后續系列會說。
另外使用 Python 中的類型聲明變量的時候不可以使用指針的形式,比如:cdef tuple *t,這么做是不合法的,會報錯:Pointer base type cannot be a Python object
。此外,我們使用 cdef 的時候指定了類型,那么賦值的時候就不可以那么無拘無束了,比如:cdef tuple a = list("123")
就是不合法的,因為聲明了 a 指向一個元組,但是我們給了一個字典,那么編譯擴展模塊的時候就會報錯:TypeError: Expected tuple, got list
。
這里再思考一個問題,我們說 Cython 創建的變量無法被直接訪問,需要將其賦值給 Python 中的變量才可以使用。那么,在賦完值的時候,這兩個變量指向的是同一個對象嗎?
cdef list a = list("123")
# a是一個PyListObject *, 然后b也是一個PyListObject *
# 但是這兩位老鐵是不是指向同一個PyListObject對象呢?
b = a
# 打印一下a is b
print(a is b)
# 修改a的第一個元素之后,再次打印b
a[0] = "xxx"
print(b)
import pyximport
pyximport.install(language_level=3)
import cython_test
"""
True
['xxx', '2', '3']
"""
我們看到 a 和 b 確實是同一個對象,並且 a 在本地修改了之后,會影響到 b。畢竟兩個變量指向的是同一個列表、或者 PyListObject 結構體實例,當然我們使用 del 刪除一個元素也是同理。
我們說 Cython 中的變量和 Python 中的變量是等價的,那么 Python 中變量可以使用的 api,Cython 中的變量都可以使用,比如 a.insert、a.append 等等。只不過對於 int 和 float 來說,C 中也存在同名的類型,那么會優先使用 C 的類型,這也是我們期望的結果。
而且一旦使用的是 C 里面的類型,比如
cdef int = 1;cdef float b = 22.33
,那么 a 和 b 就不再是 PyLongObject * 和 PyFloatObject * 了,因為它們用的不是 Python 中的類型,而是 C 中的類型。所以 a 和 b 的類型就是 C 中實打實的 int 和 float,並且 a 和 b 也不再是一個指針,它們代表的就是具體的整數和浮點數。為什么要在使用 int 和 float 的時候,要選擇 C 中 int 和 float 呢?答案很好理解,因為 Cython 本身就是用來加速計算的,而提到計算,顯然避不開 int 和 float,因此這兩位老鐵默認使用的 C 里面類型。事實上單就 Python 中的整型和浮點來說,在運算時底層也是先轉化成 C 的類型,然后再操作,最后將操作完的結果再轉回 Python 中的類型。而如果默認就使用C的類型,就少了轉換這一步了,可以極大提高效率。
然而即便是 C 中的整型和浮點型,在操作的時候和 C 還是有一些不同的,主要就在於除法和取模。什么意思呢,我們往下看。
當我們操作的是 Python 的 int 時,那么結果是不會溢出的;如果操作的是靜態的 C 對象,那么整型可能存在溢出,這些我們是知道的。但是除此之外,還有一個最重要的區別就是除法和取模,在除法和取模上,C 的類型使用的卻不是 C 的標准。舉個栗子:
當使用有符號整數計算模的時候,C 和 Python 有着明顯不同的行為:比如 -7 % 5
,如果是 Python 的話那么結果為 3,C 的話結果為 -2。顯然 C 的結果是符合我們正常人思維的,但是為什么 Python 得到的結果這么怪異呢?
事實上不光是 C,Go、Js 也是如此,計算
-7 % 5
的結果都是-2,但 Python 得到 3 主要是因為其內部的機制不同。我們知道a % b
,等於a - (a / b) * b
,其中a / b
表示兩者的商。比如 7 % 2,等於 7 - (7 / 2) * 2 = 7 - 3 * 2 = 1,對於正數,顯然以上所有語言計算的結果都是一樣的。而負數出現差異的原因就在於:C 在計算 a / b 的時候是截斷小數點,而 Python 是向下取整。比如上面的 -7 % 5,等於 -7 - (-7 / 5) * 5。-7 / 5 得到的結果是負的一點多,C的話直接截斷得到 -1,因此結果是 -7 - (-1) * 5 = -2;但 Python 是向下取整,負的一點多變成 -2,因此結果變成了 -7 - (-2) * 5 = 3。
# Python中 / 默認是得到浮點, 整除的話使用 //
# 我們看到得到的是 -2
print(-7 // 5) # -2
因此在除法和取模方面,尤其需要注意。另外即使在Cython中,也是一樣的。
cdef int a = -7
cdef int b = 5
cdef int c1 = a / b
cdef int c2 = a // b
print(c1)
print(c2)
print(-7 // 5)
以上打印的結果都是 -2,說明 Cython 默認使用 Python 的語義進行除法,當然還有取模,即使操作的對象是靜態類型的 C 標量。這么做原因就在於為了最大程度的和 Python 保持一致,如果想要啟動 C 語義都需要顯式地進行開啟。然后我們看到 a 和 b 是靜態類型的 C 變量,它們也是可以使用 // 的,因為 Cython 的目的就像寫Python一樣。但是我們看到無論是 a / b 還是 a // b 得到的都是 -2,這很好理解。因為在 Cython 中 a 和 b 都是靜態的 int,而在C中對兩個 int 使用加減乘除得到的依舊是一個 int,因此會將中間得到的浮點數變成整型,至於是直接截斷還是向下取整則是和 Python 保持一致的,是按照 Python 的標准來的。至於 a // b 對於整型來說就更不用說了,a // b 本身就表示整除,因此在 Cython 中兩個 int 之間使用 / 和使用 // 是一樣的。然后我們再來舉個浮點數的例子。
cdef float a = -7
cdef float b = 5
cdef float c1 = a / b
cdef float c2 = a // b
print(c1)
print(c2)
import cython_test
"""
-1.399999976158142
-2.0
"""
此時的 a 和 b 都是浮點數,那么 a / b 也是個浮點,所以沒有必要截斷了,小數位會保留;而 a // b雖然得到的也是浮點(只要 a 和 b 中有一個是浮點,那么 a / b 和 a // b 得到的也是浮點),但它依舊具備整除的意義,所以 a // b 得到結果是 -2.0,然后賦值給一個 float 變量,還是 -2.0。不過為什么 a // b 得到的是 -2.0,可能有人不是很明白,因此關於 Python 中 / 和 // 在不同操作數之間的差異,我們再舉個栗子看一下:
7 / 2 == 3.5 # 3.5, 很好理解
7 // 2 == 3 # // 表示整除, 因此 3.5 會向下取整, 得到 3
-7 / 2 == -3.5 # -3.5, 很好理解
-7 // -2 = -4 # // 表示取整, 因此 -3.5 會向下取整, 得到 -4
7.0 / 2 == 3.5 # 3.5, 依舊沒問題
7.0 // 2 == 3.0 # //兩邊出現了浮點, 結果也是浮點; 但 // 又是整除, 所以你可以簡單認為是先取整(得到 3), 然后變成浮點(得到3.0)
-7.0 / 2 == -3.5 # -3.5, 依舊很簡單
-7.0 // 2 == -7.8 // 2 == -4.0 # -3.5 和 -3.9 都會向下取整, 然后得到-4, 但結果是浮點, 所以是-4.0
-7.0 / -2 == 3.5 # 3.5, 沒問題
-7.0 // -2 == 3 # 3.5向下取整, 得到3
所以 Python 的整除或者說地板除還是比較奇葩的,主要原因就在於其它語言是截斷(小數點后面直接不要了),而 Python 是向下取整。如果是結果為正數的話,截斷和向下取整是等價的,所以此時基本所有語言都是一樣的;而結果為負數的話,那么截斷和向下取整就不同了,因為 -3.14 截斷得到的是 -3、但向下取整得到的不是 -3,而是 -4。因此這一點務必要記住,算是 Python 的一個坑吧。話說如果沒記錯的話,好像只有 Python 采用了向下取整這種方式,別的語言(至少C、js、Go)都是截斷的方式。
還有一個問題,那就是整型和浮點型之間可不可以相互賦值呢?先說結論:
整型賦值給浮點型是可以的
但是浮點型賦值給整型不可以
# 7是一個純數字, 那么它既可以在賦值 int 類型變量時表示整數7
# 也可以在賦值給 float 類型變量時表示 7.0
cdef int a = 7
cdef float b = 7
# 但如果是下面這種形式, 雖然也是可以的, 但是會彈出警告
cdef float c = a
# 提示: '=': conversion from 'int' to 'float', possible loss of data
# 因為 a 的值雖然也是 7, 但它已經具有相應的類型了, 就是一個 int, 將 int 賦值給 float 會警告
# 而將浮點型賦值給整型則不行
# 這行代碼在編譯的時候會報錯: Cannot assign type 'double' to 'int'
cdef int d = 7.0
而且我們說,使用 cdef int、cdef float 聲明的變量不再是指向 Python 中 int對象、float對象的PyLongObject *、PyFloatObject *,其類型就是 C 中的 int、float。盡管整型沒有考慮溢出,但是它在做運算的時候遵循 Python 的規則(主要是除法),那么可不可以讓其強制遵循C的規則呢?
cimport cython
# 通過@cython.cdivision(True)進行裝飾即可完成這一點
@cython.cdivision(True)
def divides(int a, int b):
return a / b
import cython_test
print(-7 // 2) # -4
# 函數參數 a 和 b 都是整型, 相除得到還是整型
# 如果是 Python 語義, 那么在轉化的時候會向下取整得到 -4, 但這里是 C 語義, 所以是截斷得到 -3
print(cython_test.divides(-7, 2)) # -3
除了這種方式,還可以下面下面兩種方式來指定。
1. 通過上下文管理器的方式
cimport cython
def divides(int a, int b):
with cython.cdivision(True):
return a / b
2. 通過注釋的方式進行全局聲明
# cython: cdivision=True
def divides(int a, int b):
return a / b
如果什么都不指定的話,執行一下看看。
def divides(int a, int b):
return a / b
import cython_test
print(-7 // 2) # -4
print(cython_test.divides(-7, 2)) # -4
此時就和Python語義是一樣的了。
總結:
使用 cdef int、cdef float 聲明的變量不再是 Python 中的 int、float,也不再對應 CPython 中的 PyLongObject * 和PyFloatObject *,而就是 C 中的 int 和 float。
雖然是 C 中的 int 和 float,並且也沒有像 Python 一樣考慮整型溢出的問題(實際上溢出的情況非常少,如果可能溢出的話,就不要使用 C 中的 int 或者 long,而是使用 Python 的 int),但是在進行運算的時候是遵循 Python 的語義的。因為 Cython 就是為了優化 Python 而生的,因此在各個方面都要和 Python 保持一致。
但是也提供了一些方式,禁用掉 Python 的語義,而是采用 C 的語義。方式就是上面說的那三種,它們專門針對於整除和取模,因為加減乘都是一樣的,只有除和取模會有歧義。
不過這里還有一個隱患,因為我們在除法的時候使其遵循 C 的語義,而 C 不會對分母為 0 的情況進行考慮,而 Python 則會進行檢測。如果分母為 0,在 Python 中會拋出:ZeroDivisionError,在C中會可能導致未定義的行為(從硬件損壞和數據損害都有可能,好嚇人,媽媽我怕)。
Cython 中還有一個 cdivision_warnings,使用方式和 cdivision 完全一樣,表示:當取模的時候如果兩個操作數中有一個是負數,那么會拋出警告。
cimport cython
@cython.cdivision_warnings(True)
def mod(int a, int b):
return a % b
import cython_test
# -7 - (2 * -4) == 1
print(cython_test.mod(-7, 2))
# 提示我們取整操作在 C 和 Python 有着不同的語義, 同理 cython_test.mod(7, -2) 也會警告
"""
RuntimeWarning: division with oppositely signed operands, C and Python semantics differ
return a % b
1
"""
# -7 - (-2 * 3) = -1
print(cython_test.mod(-7, -2)) # -1
# 但是這里的 cython_test.mod(-7, -2) 卻沒有彈出警告,這是為什么呢?
# 很好理解,我們說只有商是負數的時候才會存在歧義,但是 -7 除以 -2 得到的商是 3.5,是個正數
# 而正數的表現形式在截斷和向下取整中都是一致的,所以不會警告
# 同理 cython_test.mod(7, 2) 一樣不會警告
另外這里的警告是同時針對 Python 和 C 的,即使我們再使用一層裝飾器 @cython.cdivision(True)
裝飾、將其改變為 C 的語義的話,也一樣會彈出警告的。個人覺得 cdivision_warnings 意義不是很大,了解一下即可。
用於加速的靜態類型
我們上面介紹了在 Cython 中使用 Python 的類型進行聲明,這咋一看有點古怪,為什么不直接使用 Python 的方式創建變量呢?a = (1, 2, 3)
不香么?為什么非要使用 cdef tuple a = (1, 2, 3)
這種形式呢?答案是 "為了遵循一個通用的 Cython 原則":我們提供的靜態信息越多,Cython 就越能優化結果。所以 a = (1, 2, 3)
,這個 a 可以指向任意的對象,但是 cdef tuple a = (1, 2, 3)
的話,這個 a 只能指向元組,在明確了類型的時候,執行的速度會更快。
看一個列表的例子:
lst = []
lst.append(1)
我們只看 lst.append(1) 這一行,顯然它再簡單不過了,但是你知道 Python 解釋器是怎么操作的嗎?
1. 檢測類型,我們說 Python 中變量是一個 PyObject *,因為任何對象在底層都嵌套了 PyObject 這個結構體,但具體是什么類型則需要一步檢索才知道。通過 PyTypeObject *type = lst -> ob_type
,拿到其類型。
2. 轉化類型,PyListObject *lst = (PyListObject *)lst
3. 查找屬性,我們調用的是 append 方法,因此調用 PyObject_GetAttrString,參數就是字符串 "append",找到指向該方法的指針。如果不是 list,但是內部如果有 append 方法也是可以的,然后進行調用。
因此我們看到一個簡單的 append,Python 內部是需要執行以上幾個步驟的,但如果我們實現規定好了類型呢?
cdef list lst = []
lst.append(1)
那么此時會有什么差別呢?我們對 list 對象進行 append 的時候底層調用的 C 一級的函數是 PyList_Append,通過索引賦值的時候調用的是 PyList_SetItem,索引取值的時候調用的是 PyList_GetItem,等等等等。每一個操作在 C 一級都指向了一個具體的函數,如果我們提前知道了類型,那么 Cython 生成的代碼可以將上面的三步變成一步,沒錯,直接通過 C api 讓 lst.append 指向 PyList_Append 這個 C 一級的函數,這樣省去了類型檢測、轉換、屬性查找等步驟,直接調用調用即可。
所以列表解析比普通的 for 循環快也是如此,因為 Python 對內置的結構非常熟悉,當我們使用的是列表解析式,那么也同樣會直接指向 PyList_Append 這個 C 一級的函數。
同理我們在 Cython 中使用 for 循環的時候,也是如此。如果我們循環一個可迭代對象,而這個可迭代對象內部的元素都是同一種類型(假設是 dict 對象),那么我們在循環之前可以先聲明循環變量的類型,比如:cdef dict item,然后在 for item in xxx,這樣也能提高效率。
引用計數和靜態字符串類型
我們知道 Python 會自動管理內存的,解釋器 CPython 通過直接的引用計數來判斷一個對象是否應該被回收,但是無法解決循環引用,於是 Python 中又提供了垃圾回收來解決這一點。
這里多提一句 Python 中的 gc,我們知道 Python 判斷一個對象回收的標准就是它的引用計數是否為 0,為 0 就被回收。但是這樣無法解決循環引用,於是 Python 中的 gc 就是來解決這個問題的。那么它是怎么解決的呢?
首先什么樣的對象會發生循環引用呢?不用說,顯然是可變對象,比如:列表、類的實例對象等等,像 int、str 這些不可變對象肯定是不會發生循環引用的,單純的引用計數足以解決。
而對於可變對象,Python 會通過分代技術,維護三個鏈表:零代鏈表、一代鏈表、二代鏈表。將那些可變對象移到鏈表上,然后通過三色標記模型找到那些發生循環引用的對象,將它們的引用計數減一,從而解決循環引用的問題。不過有人好奇了,為什么是三個鏈表,一個不行嗎?事實上,Python 檢測循環引用、或者觸發一次 gc 還是要花費一些代價的,對於某些經過 gc 的洗禮之后還活着的對象,我們認為它們是比較穩定的,不應該每次觸發 gc 就對它們進行檢測。所以 Python 會把零代鏈表中比較穩定的對象移動到一代鏈表中,同理一代鏈表也是如此,不過最多就是二代鏈表,沒有三代鏈表。當清理零代鏈表的次數達到 10 次的時候,會清理一次一代鏈表;清理一代鏈表達到 10 次的時候,會清理一次二代鏈表。
而 Cython 也為我們處理所有的引用計數問題,確保 Python 對象(無論是Cython動態聲明、還是Python動態聲明)在引用計數為 0 時被銷毀。
很好理解,就是內存管理的問題 Cython 也會負責的。其實不用想也大概能猜到 Cython 會這么做,畢竟
cdef tuple a = (1, 2, 3)
和a = (1, 2, 3)
底層都對應 PyTupleObject *,只不過后者在操作的時候需要先通過 PyObject * 獲取類型(PyTupleObject *)
再轉化罷了,而前者則省略了這一步。但它們底層都是 CPython 中的結構體,所以內存都由解釋器管理。還是那句話,Cython 代碼是要被翻譯成 C 的代碼的,在翻譯的時候會自動處理內存的問題,當然這點和 Python 也是一樣的。
但是當 Cython 中動態變量和靜態變量混合時,那么內存管理會有微妙的影響。我們舉個栗子:
# char *, 在 Cython 只能接收一個 ascii 字符串, 或者 bytes 對象
# 但下面這行代碼是編譯不過去的
cdef char *name = "古明地覺".encode("utf-8")
# 不是說后面可以跟一個 bytes 對象嗎?
# 但問題是這個 bytes 對象它是一個臨時對象, 什么是臨時對象呢? 就是創建完了但是沒有變量指向它
# 這里的 name 是使用 C 的類型創建的變量, 所以它不會增加這個 bytes 對象的引用計數
# 因此這個 bytes 對象創建出來之后就會被銷毀, 編譯時會拋出:Storing unsafe C derivative of temporary Python reference
# 告訴我們創建出來的Python對象是臨時的
# 這么做是可以的, 並且 "komeiji satori" 會被解釋成 C 中的字符串
cdef char *name = "komeiji satori"
# 同理 cdef int a = 123; 這個 123 也是 C 中的整型, 但 cdef char *name = "古明地覺" 則不行, 因為它不是ascii字符串
那么如何解決這一點呢?答案是使用變量保存起來就可以了。
# 這種做法是完全合法的, 因為我們這個 bytes 對象是被 name1 指向了
name1 = "古明地覺".encode("utf-8")
cdef char *buf1 = name1
# 然鵝這么做是不行的, 編譯是通過的, 但是執行的時候會報錯:TypeError: expected bytes, str found
name2 = "komeiji satori"
cdef char *buf2 = name2
# 可能有人覺得, cdef char *buf2 = "komeiji satori"就可以, 為什么賦值給一個變量就不行了
# 因此 char * 它需要接收的是 C 中的字符串, 或者 Python 中的 bytes, 而我們賦值一個變量的時候它就已經是 Python 中的字符串了
因此關於 char * 來總結一下:
cdef char *buf = "satori".encode("utf-8") 理論上是合理的,但是由於這個對象創建完之后就被銷毀,所以不行。這個是在編譯的時候就會被檢測到,因為這屬於內存方面的問題。
cdef char *buf = "satori" 是可以的,因為此時 "satori" 會被解釋成 C 中的字符串。
name = "古明地覺".encode("utf-8"); cdef char *buf = name 也可以的,因為 name 指向了字節對象,所以不會被銷毀,能夠提取它的 char 指針。
name = "satori"; cdef char *buf = name 則不行,原因在於我們將 "satori" 賦值給了 name,那么這個 name 顯然就是 Python 中的字符串,而我們不可以將 Python 中的字符串賦值給 C 中的 char *,只能賦字節串,因此會報錯。但該錯誤是屬於賦值出錯了,因此它是一個運行時錯誤,所以編譯成擴展模塊的時候是可以正常通過的。
不過還是那句話,只有當直接給 char * 變量賦一個 ascii 字符串的時候,才會被當成是 C 中的字符串,如果賦了非 ascii 字符串、或者是 ascii 字符串用變量接收了並且賦的是變量,那么也是不合法的。因此建議,字符串直接使用 str 即可,沒有必要使用 char *。
那么下面的代碼有沒有問題呢?如果有問題該怎么改呢?
word1 = "hello".encode("utf-8")
word2 = "satori".encode("utf-8")
cdef char *word = word1 + word2
會不會出問題呢?顯然會有大問題,盡管 word1 和 word2 指向了相應的 bytes 對象,但是 word1 + word2 則是會創建一個新的 bytes 對象,這個新的 bytes 對象可沒有人指向。因此提取其 char * 之后也沒用,因為這個新創建的 bytes 對象會被直接銷毀。
而解決的辦法有兩種:
tmp = word1 + word2; cdef char *word = tmp,使用一個動態的方式創建一個變量指向它,確保它不會被銷毀。
cdef bytes tmp = word1 + word2; cdef char *word = tmp,道理一樣,只不過使用的是靜態聲明的方式。
另外,其實像上面這種情況並不常見,基本上只有 char * 會有這個問題,因為它比較特殊,底層使用一個指針來表示字符串。和 int、long 不同,cdef long a = 123,這個 123 直接就是 C 中的 long,我們可以直接使用;但將 Python 中的 bytes 對象賦值給 char *,在 C 的級別 char * 所引用的數據還是由 CPython 進行管理的,char * 緩沖區無法告訴解釋器還有一個對象(非 Python 對象)引用它,這就導致了它的引用計數不會加1,而是創建完之后就會被銷毀。
所以我們需要提前使用 Python 中的變量將其保存起來,這樣就不會刪除了。而我們說只有char *會面臨這個問題,而其它的則無需擔心。但是我們完全可以不使用 char *,使用 str 和 bytes 難道不好嗎?
Cython的函數
我們上面所學的關於動態變量和靜態變量的知識也適用於函數,Python 的函數和 C 的函數都有一些共同的屬性:函數名稱、接收參數、返回值,但是 Python 中的函數更加的靈活這強大。因為 Python 中一切皆對象,所以函數也是一等公民,可以隨意賦值、並具有相應的狀態和行為,這種抽象是非常有用的。
一個Python函數可以:
在導入時和運行時動態創建
使用 lambda 關鍵字匿名創建
在另一個函數(或其它嵌套范圍)中定義
從其它函數中返回
作為一個參數傳遞給其它函數
使用位置參數和關鍵字參數調用
函數參數可以使用默認值
C函數調用開銷最小,比Python函數快幾個數量級。一個C函數可以:
可以作為一個參數傳遞給其它函數,但這樣做比 Python 麻煩的多
不能在其它函數內部定義,而這在 Python 中不僅可以、而且還非常常見,畢竟 Python 中常用的裝飾器就是通過高階函數加上閉包實現的,而閉包則可以理解為是函數的內部嵌套其它函數。
具有不可修改的靜態分配名稱
只能接受位置參數
函數參數不支持默認值
正所謂魚和熊掌不可兼得,Python 的函數調用雖然慢幾個數量級(即使沒有參數),但是它的靈活性和可擴展性都比 C 強大很多,這是以效率為代價換來的。而 C 的效率雖然高,但是靈活性沒有 Python 好,這便是各自的優缺點。
那么說完 Python 函數和 C 函數各自的優缺點之后該說啥啦,對啦,肯定是 Cython 如何將它們組合起來、吸取精華剔除糟粕的啦,阿sir。
在 Cython 中使用 def 關鍵字定義 Python 函數
Cython 支持使用 def 關鍵字定義一個通用的 Python 函數,並且還可以按照我們預期的那樣工作。比如:
def rec(n):
if n == 1:
return 1
return n * rec(n - 1)
import pyximport
pyximport.install(language_level=3)
import cython_test
print(cython_test.rec(20)) # 2432902008176640000
顯然這是一個 Python 語法的函數,參數 n 是接收一個動態的 Python 變量,但它在 Cython 中也是合法的,並且表現形式是一樣的。
我們知道即使是普通的 Python 函數,我們也可以通過 cython 進行編譯,但是就調用而言,這兩者是沒有任何區別的。不過我們說執行擴展里面的代碼時,已經繞過了解釋器解釋字節碼這一過程;但是 Python 代碼則不一樣,它是需要被解釋執行的,因此在運行期間可以隨便動態修改內部的屬性。我們舉個栗子就很清晰了:
Python 版本:
# 文件名:a.py
def foo():
return 123
# 另一個文件
from a import foo
print(foo()) # 123
print(foo.__name__) # foo
foo.__name__ = "哈哈"
print(foo.__name__) # 哈哈
Cython 版本:
def foo():
return 123
import pyximport
pyximport.install(language_level=3)
from cython_test import foo
print(foo()) # 123
print(foo.__name__) # foo
foo.__name__ = "哈哈"
"""
...
...
foo.__name__ = "哈哈"
AttributeError: attribute '__name__' of 'builtin_function_or_method' objects is not writable
"""
我們看到報錯了:'builtin_function_or_method' 的屬性 '__name__' 不可寫,因為 Python 中的函數是一個類型函數,它是通過解釋器的,所以它可以修改自身的一些屬性。但是 Cython 代碼在編譯之后,變成了 builtin_function_or_method,繞過了解釋這一步,因為不能對它自身的屬性進行修改。事實上,Python 的一些內置函數也是不能修改的。
try:
getattr.__name__ = "xxx"
except Exception as e:
print(e) # attribute '__name__' of 'builtin_function_or_method' objects is not writable
這些內置的函數直接指向了底層 C 一級的函數,因此它們的屬性是不能夠被修改的。
回到剛才的用遞歸計算階乘的例子上來,顯然 rec 函數里面的 n 是一個動態變量,如果想要加快速度,就要使用靜態變量,也就是規定好類型。
def rec(long n):
if n == 1:
return 1
return n * rec(n - 1)
此時當我們傳遞的時候,會將值轉成 C 中的 long,如果無法轉換則會拋出異常。
另外在 Cython 中定義任何函數,我們都可以將動態類型的參數和靜態類型的參數混合使用。 Cython 允許靜態參數具有默認值,並且可以按照位置參數或者關鍵字參數的方式傳遞。
# 這樣的話, 我們就可以不傳參了, 默認 n 是 20
def rec(long n=20):
if n == 1:
return 1
return n * rec(n - 1)
但是遺憾的是,即便我們使用了 long n 這種形式定義參數,效率也不會有提升。因為這里的 rec 還是一個 Python 函數,它的返回值也是一個 Python 中的整型,而不是靜態的 C long。因此在計算 n * rec(n - 1) 的時候,Cython 必須生成大量代碼,從返回的 Python 整數中提取底層的 C long,然后乘上靜態類型的變量 n,最后再將結果得到的 C long 打包成 Python 的整型。所以整個過程基本上是沒什么變化的。
那么如何才能提升性能呢?顯然這明顯可以不使用遞歸而是使用循環的方式,當然這個我們不談,因為這個 Cython 沒啥關系。我們想做的是告訴Cython:"這是一個 C long,你要在不創建任何 Python 整型的情況下計算它,我會將你最終計算好的結果包裝成 Python 中的整型,總之你計算的時候不需要 Python 整數參與。"
如何完成呢?往下看。
在 Cython 中使用 cdef 關鍵字定義 C 函數
cdef 關鍵字除了創建變量之外,還可以創建具有 C 語義的函數。cdef 定義的函數其參數和返回值通常都是靜態類型的,它們可以處理 C 指針、結構體、以及其它一些無法自動轉換為 Python 類型的 C 類型。所以把 cdef 定義的函數看成是長得像 Python 函數的 C 函數即可。
cdef long rec(long n):
if n == 1:
return 1
return n * rec(n - 1)
我們之前的例子就可以改寫成上面這種形式,我們看到結構非常相似,主要區別就是指定了返回值的類型。
但是此時的函數是沒有任何 Python 對象參與的,因此不需要從 Python 類型轉化成 C 類型。該函數和純 C 函數一樣有效,調用函數的開銷最小。另外,即便是 cdef 定義的函數,我們依舊可以創建 Python 對象和動態變量,或者接收它們作為參數也是可以的。但是 cdef 編寫的函數應該是在,為了獲取 C 的效率、不需要動態變量的情況下編寫的。
當然我們在 Cython 源文件中可以使用 cdef 定義函數、也可以是用 def 定義函數,這是顯然的。cdef 函數返回的類型可以是任何的靜態類型(如:指針、結構體、C數組、靜態Python類型)。如果省略了返回值,那么默認是object 比如:cdef f1():等價於cdef object f1()
,也就是說此時返回任何對象都是可以的。關於返回值的問題,我們來舉個例子。
# 合法, 返回的是一個 list 對象
cdef list f1():
return []
# 等於cdef object f2(): 而 Python 中任何對象都是 object 對象
cdef f2():
pass
# 雖然要求返回列表, 但是返回 None 也是可以的(None特殊, 后面會繼續說)
cdef list f3():
pass
# 同樣道理
cdef list f4():
return None
# 這里是會報錯的:TypeError: Expected list, got tuple
cdef list f5():
return 1, 2, 3
使用 cdef 定義的函數,可以被其它的函數(cdef 和 def 都行)調用,但是 Cython 不允許從外部 Python 代碼來調用 cdef 函數,我們之前使用 cdef 定義的變量也是如此。因為 Python 中函數也可以看成是變量,所以我們通常會定義一個 Python 中的函數,然后讓 Python 中的函數來調用 cdef 定義的函數,所以此時的 Python 函數就類似於一個包裝器,用於向外界提供一個訪問的接口。
cdef long _rec(long n):
if n == 1:
return 1
return n * rec(n - 1)
def rec(n):
return _rec(n)
這種方式是最快的,之前的方式都有大量的 Python 開銷。
但不幸的時,這種方式有一個弊端,相信肯定都能想到。那就是 C 中的整數類型(int、long等等)都存在精度問題,而 Python 的整型是不受限制的,只要你的內存足夠。解決辦法就是確保不會溢出,或者將 long 換成double。
這是一個很普遍的問題,基本上所有的語言都是這樣子,只有 Python 在表示整型的時候是沒有限制的。有些時候,Python 數據和 C 數據並不能總是實現完美的映射,需要意識到 C 的局限性。這也是為什么 Cython 不會擅自把 Python 中的 int 變成 C 中的 int、long,因為這兩者在極端情況下不是等價的。但是絕大多數情況下,使用 long 是足夠的,甚至都不需要 long,int 也足夠,至少我平時很少遇見 long 存不下的數字,或者實在不行就用double嘛。
使用 cpdef 結合 def、cdef
我們在 Cython 定義一個函數可以使用 def 和 cdef,但是還有第三種定義函數的方式,使用 cpdef 關鍵字聲明。cpdef 是 def 和 cdef 的混合體,結合了這兩種函數的特性,並解決了局限性。我們之前使用 cdef 定義了一個函數 _rec,但是它沒法直接被外部訪問,因此又定義了一個 Python 函數 rec 供外部調用,相當於提供了一個接口。所以我們需要定義兩個函數,一個是用來執行邏輯的(C版本),另一個是讓外部訪問的(Python版本,一般這種函數我們稱之為 Python 包裝器。很形象,C 版本不能被外部訪問,因為定義一個 Python 函數將其包起來)。
但是 cpdef 定義的函數會同時具備這兩種身份,怎么理解呢?一個 cpdef 定義的函數會自動為我們提供上面那兩個函數的功能,它們具備相同的名稱。從 Cython 中調用函數時,會調用 C 的版本,在外部的 Python 中導入並訪問時,會調用包裝器。這樣的話,cpdef 函數就可以將 cdef 函數的性能和 def 函數的可訪問性結合起來了。
因此上面那個例子,我們就可以改寫成如下:
cpdef long rec(long n):
if n == 1:
return 1
return n * rec(n - 1)
如果定義兩個函數,這兩個函數還不能重名,但是使用 cpdef 就不需要關心了,這樣可以更方便。
inline cdef and cpdef functions
在 C 和 C++ 中,定義函數時還可以使用一個可選的關鍵字 inline,這個 inline 是做什么的呢?我們知道函數調用是有開銷的(話說你效率這么高了,還在乎這一點啊),而使用 inline 關鍵字定義的函數,那么代碼會被放在符號表中,在使用時直接進行替換(像宏一樣展開),沒有了調用的開銷,提高效率。
Cython 同樣支持 inline 關鍵字,使用時只需要將 inline 放在 cdef 或者 cpdef 后面即可,但是不能放在 def 后面。
cpdef inline unsigned long rec(int n):
if n == 1:
return 1
return rec(n - 1) * n
inline 如果使用得當,那么可以提高性能,特別是在深度嵌套循環中調用的小型內聯函數。因為它們會被多次調用,這個時候通過 inline 可以省去函數調用的開銷。
使用 cpdef 有一個局限性,那就是它要同時兼容 Python 和 C:意味着它的參數和返回值類型必須同時兼容 Python 類型和C類型。但我們知道,並非所有的 C 類型都可以用 Python 類型表示,比如:C 指針、void、C 數組等等,它們不可以作為 cpdef 定義的函數的參數類型和返回值類型。
cpdef 的局限性
我們說 cpdef 最方便的一點是在實現了高性能的純 C 函數之外,還自帶了一個包裝器,雖然讓我們變得更加輕松了,但也帶來了一些局限性,那就是 cpdef 不支持閉包。
cpdef list func():
# lam(3) -> 33
# lam(11) -> 1111
lam = lambda x: int(str(x) * 2)
# return [11, 22, 33, 44, 55]
return [lam(_) for _ in range(1, 5)]
顯然函數里面的邏輯在 Python 中、或者說在 def 定義的函數中再正常不過了,但如果是 cpdef 的話,那么編譯的時候會報錯:
closures inside cpdef functions not yet supported
原因是函數(包括匿名函數)不能夠定義在 cpdef 中,因此上面編譯失敗顯然是由 lam = ...
這一行導致的。
解決辦法就是換成列表解析式:[int(str(_) * 2) for _ in range(1, 5)]
,同時將匿名函數的定義給刪掉。但如果在 cdef 中則是沒有任何問題的,cdef 里面是可以定義函數的,當然 cdef 不能被外界訪問,因此我們還需要使用 def 定義一個包裝器。
cdef list wrapper():
# lam(3) -> 33
# lam(11) -> 1111
lam = lambda x: int(str(x) * 2)
# return [11, 22, 33, 44, 55]
return [lam(_) for _ in range(1, 5)]
def wrapper_func():
return wrapper()
上面是完全正常的,因此總結一下:
1. cdef 和 def 是一樣的,不會受到任何的語法限制,但 def 起不到加速效果,cdef 無法被外界訪問
2. cpdef 是兩者的結合體,即能享受加速帶來的收益,又能自動提供包裝器給外界
3. 但 cpdef 會受到一些語法層面的限制,比如內部無法定義函數,因此最完美的做法就是使用 cdef 定義函數之后再手動提供包裝器。但是當不涉及到閉包的時候,還是推薦使用 cpdef 定義的
函數和異常處理
def 定義的函數在 C 的級別總是會返回一個 PyObject*,這個是恆定的不會改變,因為 Python 中的所有變量在底層都是一個 PyObject *。它允許 Cython 正確地從 def 函數中拋出異常,但是 cdef 和 cpdef 可能會返回一個非 Python 類型,因此此時則需要一些其它的異常提示機制。
cpdef int divide_ints(int i, int j):
return i // j
如果這里的 j 我們傳遞了一個 0,會引發 ZeroDivisionError,但是這個異常卻沒有辦法傳遞給它的調用方。
>>> import cython_test
>>> cython_test.divide_ints(1, 1)
1
>>> cython_test.divide_ints(1, 0)
ZeroDivisionError: integer division or modulo by zero
Exception ignored in: 'cython_test.divide_ints'
ZeroDivisionError: integer division or modulo by zero
0
>>>
異常沒法傳遞,換句話說就是異常沒有辦法向上拋,即使檢測到了這個異常。最終會忽略警告信息,並且也會返回一個錯誤的值 0。
為了正確傳遞此異常,Cython 提供了一個 except 字句,允許 cdef、cpdef 函數和調用方通信,說明在執行中發生了、或可能發生了 Python 異常。
cpdef int divide_ints(int i, int j) except? -1:
return i // j
>>> import cython_test
>>> cython_test.divide_ints(1, 0)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cython_test.pyx", line 1, in cython_test.divide_ints
cpdef int divide_ints(int i, int j)except?-1:
File "cython_test.pyx", line 2, in cython_test.divide_ints
return i // j
ZeroDivisionError: integer division or modulo by zero
>>>
我們看到此時異常被正常的傳遞給調用方了,此時程序就崩潰了,而之前那種情況程序是沒有崩潰的。
這里我們實現的方式是通過在結尾加上 except ? -1
來實現這一點,這個 except ? -1
允許返回值 -1 充當發生異常時的哨兵。事實上不僅是 -1,只要在返回值類型的范圍內的任何數字都行,它們的作用就是傳遞異常。但是問題來了,如果函數恰好就返回了 -1 的時候該怎么辦呢?看到 except ? -1
中的那個問號了嗎,它就是用來做這個的,如果函數恰好返回了一個 -1,那么 Cython 會檢測是否有異常回溯棧,有的話會自動展開堆棧。如果我們將那個問號去掉,看看會有什么結果吧。
cpdef int divide_ints(int i, int j) except -1:
return i // j
>>> import cython_test
>>> cython_test.divide_ints(1, 1)
1
>>>
>>> cython_test.divide_ints(1, 0) # 依舊會引發異常,這沒問題
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cython_test.pyx", line 1, in cython_test.divide_ints
cpdef int divide_ints(int i, int j)except-1:
File "cython_test.pyx", line 2, in cython_test.divide_ints
return i // j
ZeroDivisionError: integer division or modulo by zero
>>>
>>> cython_test.divide_ints(1, -1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
SystemError: <built-in function divide_ints> returned NULL without setting an error
如果你使用 C 編寫過擴展模塊的話,你應該會遇見過這個問題。Python 中的函數總會有一個返回值的,所以在 C 中一定會返回一個 PyObject *。如果 Python 中的函數出錯了,那么在 C 一級就會返回一個 NULL,並且將發生異常設置進去。如果返回了 NULL 但是沒有設置異常的話,就會拋出上面的那個錯誤。而我們這里的 except -1
表示返回了 -1 就代表發生異常了、底層會返回NULL,但是此時卻沒有異常,所以提示我們 returned NULL without setting an error。
所以我們看到 except ? -1
只是單純為了在發生異常的時候能夠往上拋罷了,這里可以是 -1、也可以是其它的什么值。而函數如果也返回了相同的值,那么就會檢測異常回溯棧,沒有報錯就會正常返回。而觸發檢測的條件就是中的那個 ?
,如果不指定 ?
,那么當函數返回了和 except
指定的值相同的值,那么是會報錯的,因此這個時候你應該確保函數不可能會返回 except
后面指定的值。所以盡管加上了 ?
會犧牲一些效率(因為涉及回溯棧的展開,但實際上是沒有什么差別的),但如果你沒有百分之百的把握確定函數不會返回相同的值,那么就使用 ?
做一層檢測吧。或者還可以使用 except *
,此時會對返回的任何值都進行檢測,但沒有什么必要、會產生開銷,直接寫上 except ? -1
即可。這樣只對 -1 進行檢測,因為我們的目的是能夠在發生異常的時候進行傳遞。
另外只有返回值是C的類型,才需要指定 except ? -1
。
cpdef tuple divide_ints(int i, int j):
a = i // j
這個時候即使給 j 傳遞了 0,異常也是會向上拋的,因為返回值不再是 C 中的類型,而是 Python 中的類型;如果將這里 tuple 改成 int、或者 long 之后異常還是會被忽略掉的。
因此,在不指定
except ? -1
的情況下,異常不會向上拋需要滿足兩個條件:1. 必須是C中的對象在操作時發生了錯誤,這里是 i 和 j 相除發生了錯誤;2. 返回值必須是 C 中的類型。
關於擴展模塊中的函數信息
一個函數可以有很多信息,我們可以通過函數的字節碼進行獲取。
def foo(a, b):
pass
print(foo.__code__.co_varnames) # ('a', 'b')
import inspect
print(inspect.signature(foo)) # (a, b)
但是對於擴展模塊中的函數就不能這樣獲取了,我們把上面的 foo 函數定義在 cython_test.pyx 中,然后來看一下:
import pyximport
pyximport.install(language_level=3)
from cython_test import foo
print(foo.__code__) # 123
"""
...
...
print(foo.__code__) # 123
AttributeError: 'builtin_function_or_method' object has no attribute '__code__'
"""
我們看到擴展模塊內的函數變成 built-in 級別的了,所以一些動態信息已經沒有了,即便有也是無法動態修改的,比如之前說的 __name__
。因為信息的訪問、動態修改都是在解釋器解釋執行的時候完成的,而擴展模塊已經是不需要解釋、直接拿來執行就可以,已經是終極形態,所以不像常規定義的 Python 函數,擴展模塊內的函數的動態信息是不支持動態修改的,有的甚至無法訪問。
既然這樣的話,那我如何才能將函數信息體現出來呢?答案是通過 docstring。
cpdef int divide_ints(int i, int j) except ? -1:
"""
:param i: 第一個整型i
:param j: 第二個整型j
:return: i和j相除
"""
return i // j
import pyximport
pyximport.install(language_level=3)
import cython_test
print(cython_test.divide_ints.__doc__)
"""
:param i: 第一個整型i
:param j: 第二個整型j
:return: i和j相除
"""
這是我們向外界進行描述的最好方式,甚至是唯一方式。
類型轉換
C 和 Python 在數值類型上都有各自的成熟規則,但是這里我們介紹的是 C 類型,因為 Cython 使用的是 C 類型。
類型轉換在 C 中很常見,尤其是指針,Cython 也提供了相似的操作。
# 這里是將其它類型的指針變量 v 轉成了int *
cdef int *ptr_i = <int *>v
# 在 C 中, 類似於 int *ptr_i = (int *)v, 只不過小括號變成尖括號
顯式的轉換在 C 中是不被檢測的,因此可以對類型進行完全的控制。
def print_address(a):
# Python 中的變量本質上就是個指針, 所以這里轉成 void *
cdef void *v = <void*> a
# 而指針存儲的值是一個地址, 一串 16進制數, 我們將其轉成 long long, 因為一個 long 存不下
cdef long long addr = <long long> v
# 然后再通過內置函數 id 獲取地址, 因此兩個地址是一樣的
print("Cython address:", addr)
print("Python id :", id(a))
import pyximport
pyximport.install(language_level=3)
import cython_test
cython_test.print_address("古明地覺")
cython_test.print_address([])
"""
Cython address: 2230547577424
Python id : 2230547577424
Cython address: 2230548032896
Python id : 2230548032896
"""
這里傳遞的對象顯然是一個 PyObject *,然后這里先轉成 void *,然后再轉成 long long,將地址使用十進制表示,這一點和內置函數 id 做的事情是相同的。
我們也可以對 Python 中的類型進行強制轉換,轉換之后的類型可以是內置的、也可以是我們自己定義的,來看比較做作的例子。
def func(a):
cdef list lst1 = list(a)
print(lst1)
print(type(lst1))
cdef list lst2 = <list> a
print(lst2)
print(type(lst2))
import pyximport
pyximport.install(language_level=3)
import cython_test
cython_test.func("123")
"""
['1', '2', '3']
<class 'list'>
123
<class 'str'>
"""
cython_test.func((1, 2, 3))
"""
[1, 2, 3]
<class 'list'>
(1, 2, 3)
<class 'tuple'>
"""
我們看到使用 list(a) 轉換是正常的,但是 <list> a 則沒有實現轉換,還是原本的類型。這里的 <list> 作用是接收一個列表然后將其轉化為靜態的列表,換句話說就是將 PyObject * 轉成 PyListObject *。如果接收的不是一個list,那么會轉換失敗。在早期的 Cython 中會引發一個SystemError,但目前不會了,盡管這里的 lst2 我們定義的時候使用的是 cdef list,但如果轉化失敗還保留原來的類型。
可如果我們希望在無法轉化的時候報錯,這個時候要怎么做呢?
def func(a):
# 將 <list> 換成 <list?> 即可
cdef list lst2 = <list?> a
print(lst2)
print(type(lst2))
此時傳遞其它對象就會報錯了,比如我們傳遞了一個元組,會報出 TypeError: Expected list, got tuple。
如果我們處理一些基類或者派生類時,強制轉換也會發生作用,有關強制轉換我們會在后續介紹。
聲明並使用結構體、共同體、枚舉
Cython 也支持聲明、創建、操作 C 中的結構體、共同體、枚舉。先看一下 C 中沒有使用 typedef的結構體、共同體的聲明。
struct mycpx {
float a;
float b;
};
union uu {
int a;
short b, c;
};
如果使用 Cython 創建的話,那么是如下形式:
cdef struct mycpx:
float real
float imag
cdef union uu:
int a
short b, c
這里的 cdef 也可以寫成 ctypedef 的形式。
ctypedef struct mycpx:
float real
float imag
ctypedef union uu:
int a
short b, c
# 此時我們相當於為結構體和共同體起了一個別名叫:mycpx、uu
cdef mycpx zz # 此時的 zz 就是一個 mycpx 類型的變量
# 當然無論結構體是使用 cdef 聲明的還是 ctypedef 聲明的,變量 zz 的聲明都是一樣的
# 但是變量的賦值方式有以下幾種
# 1. 創建的時候直接賦值
cdef mycpx a = mycpx(1, 2)
# 也可以支持關鍵字的方式,但是注意關鍵字參數要在位置參數之后
cdef mycpx b = mycpx(real=1, imag=2)
# 2. 聲明之后,單獨賦值
cdef mycpx c
c.real = 1
c.imag = 2
# 這種方式會麻煩一些,但是可以更新單個字段
# 3. 通過Python中的字典賦值
cdef mycpx d = {"real": 1, "imag": 2}
# 顯然這是使用Cython的自動轉換完成此任務,它涉及更多的開銷,不建議用此種方式。
如果是嵌套結構體也是可以的,但是需要換種方式。
# 如果是C中我們創建一個嵌套結構體,可以使用下面這種方式
"""
struct girl{
char *where;
struct _info {
char *name;
int age;
char *gender;
} info;
};
"""
# 但是Cython中不可以這樣,需要把內部的結構體單獨拿出來才行
ctypedef struct _info:
char *name
int age
char *gender
ctypedef struct girl:
char *where
_info info # 創建一個info成員,類型是_info
cdef girl g = girl(where="sakura sou", info=_info("mashiro", 16, "female"))
print(g.where)
print(g.info.name)
print(g.info.age)
print(g.info.gender)
注意:如果是定義結構體,那么類型必須是 C 中的類型才可以。
定義枚舉也很簡單,我們可以在多行中定義,也可以在單行中定義然后用逗號隔開。
cdef enum my_enum1:
RED = 1
YELLOW = 3
GREEN = 5
cdef enum my_enum2:
PURPLE, BROWN
# 注意:即使是不同枚舉中的成員,但也不能重復
# 比如my_enum1中出現了RED,那么在my_enum2中就不可以出現了
# 當然聲明枚舉除了cdef之外,同樣也可以使用cdef
# 此外,如果我們不指定枚舉名,那么它就是匿名枚舉,匿名枚舉用於聲明全局整數常量
關於結構體、共同體、枚舉,我們在后面的系列介紹和外部代碼進行交互時會使用的更頻繁,目前先知道即可,了解一下相關的語法即可。
使用 ctypedef 給類型起別名
Cython 支持的另一個 C 特性就是可以使用 ctypedef 給類型起一個別名,和 C 中的 typedef 非常類似。主要用在和外部代碼進行交互上面,我們還是將在后續系列中重點使用,目前可以先看一下用法。
ctypedef list LIST # 給list起一個別名
# 參數是一個LIST類型
def f(LIST v):
print(v)
>>> import cython_test
>>> cython_test.f([])
[]
>>> cython_test.f(())
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Argument 'v' has incorrect type (expected list, got tuple)
>>> cython_test.f(None)
None
我們看到接收的是 list,但是我們傳了一個 tuple 進去,因為 LIST 是 list 的別名,當然不管什么 Python 類型,None 都是滿足的。
ctypedef 可以作用於 C 的類型也可以作用於 Python類型,起別名之后這個別名可以像上面那樣作用於函數參數、也可以用於聲明一個變量 cdef LIST lst
,但是不可以像這樣:LIST("123")
。起的別名用於聲明變量,但是不能當成類型本身來用,否則會報錯:'LIST' is not a constant, variable or function identifier。
ctypedef 對於 Cython 來說不是很常用,但是對於 C++ 來說則特別有用,使用 typedef 可以顯著的縮短長模板類型,另外 ctypedef 必須出現在全局作用域中,不可以出現在函數內等局部作用域里。
泛型編程
Cython 有一個新的類型特性,稱為融合類型,它允許我們用一個類型來引用多個類型。
Cython 目前提供了三種我們可以直接使用的混合類型,integral、floating、numeric,它們都是通過 cython 命名空間來訪問的,這個命名空間必須是通過 cimport 導入的。
integral:代指C中的short、int、long
floating:代指C中的float、double
numeric:最通用的類型,包含上面的integral和floating以及復數
from cython cimport integral
cpdef integral integral_max(integral a, integral b):
return a if a >= b else b
上面這段代碼,Cython 將會創建三個版本的函數:1. a 和 b 都是 short、2. a 和 b 都是 int、 3. a 和 b 都是 long。如果是在 Cython 內部使用的話,那么 Cython 在編譯時會檢查到底使用哪個版本;如果是從外部 Python 代碼導入時,將使用 long 版本。也就是說,除非在調用的時候顯式地指定了類型,否則會選擇最大范圍的類型。
可以看出,如果一個融合類型聲明了多個參數,那么這些參數的類型都必須是融合類型中的同一種。
比如我們在 Cython 中調用一下,可以這么做。
cdef allowed():
print(integral_max(<short> 1, <short> 2))
print(integral_max(<int> 1, <int> 2))
print(integral_max(<long> 1, <long> 2) )
# 但是下面的方式不可以
cdef not_allowed():
print(integral_max(<short> 1, <int> 2))
print(integral_max(<int> 1, <long> 2))
# 里面的類型不能混合,否則產生編譯時錯誤
# 因為 Cython 沒生成對應的版本的函數
所以這里就要求了我們必須傳遞 integral,如果傳遞了其它類型,那么在 Cython 中會引發一個編譯時錯誤,在 Python 中會引發一個 TypeError。
如果我們希望同時支持 integral 和 floating 呢?有人說可以使用 numeric,是的,但是它也支持復數,而我們不希望支持復數,所以可以定義一個混合類型。
from cython cimport int, float, short, long, double
# 通過 ctypedef fused 類型 即可定義一個混合類型,支持的類型可以寫在塊里面
ctypedef fused int_float:
int
float
short
long
double
# 不僅是C的類型,Python類型也是可以的
ctypedef fused list_tuple:
list
tuple
def f1(int_float a):
pass
def f2(list_tuple b):
pass
>>> import cython_test
>>> cython_test.f1(123)
>>> cython_test.f2((1, 2, 3))
>>> cython_test.f1("xx")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cython_test.pyx", line 15, in cython_test.__pyx_fused_cpdef
def f1(int_float a):
TypeError: No matching signature found
>>>
>>> cython_test.f2("xx")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "cython_test.pyx", line 18, in cython_test.__pyx_fused_cpdef
def f2(list_tuple b):
TypeError: No matching signature found
傳遞的時候會對參數進行檢測,不符合條件會拋出 TypeError。
我們說對於融合類型而言,Cython 相當於定義了多個版本的函數,然后根據我們傳遞的參數類型來判斷調用哪一種。但其實我們在調用的時候,也可以手動指定:
ctypedef fused list_tuple:
list
tuple
# 注意:a 和 b 要么都為列表、要么都為元組
# 不可以一個是列表、一個是元組
cdef func(list_tuple a, list_tuple b):
print(a, b)
# Cython 會根據我們傳遞的參數來判斷,調用哪一種函數
func([1, 2], [3, 4]) # [1, 2] [3, 4]
# 我們也可以顯式指定要調用的函數版本
func[list]([11, 22], [33, 44]) # [11, 22] [33, 44]
func[tuple]((111, 222), (333, 444)) # (111, 222) (333, 444)
當然我們上面只出現了一種融合類型,我們還可以定義多種:
ctypedef fused list_tuple:
list
tuple
ctypedef fused dict_set:
dict
set
# 會生成如下四種版本的函數:
# 1. 參數 a、c 為列表,b、d 為字典
# 2. 參數 a、c 為列表,b、d 為集合
# 3. 參數 a、c 為元組,b、d 為字典
# 4. 參數 a、c 為元組,b、d 為集合
cdef func(list_tuple a, dict_set b, list_tuple c, dict_set d):
print(a, b, c, d)
# 會根據我們傳遞參數來判斷選擇哪一個版本的函數
func([1], {"x": ""}, [], {})
# 我們依舊可以指定,不讓 Cython 幫我們判斷
# 但是注意:由於存在多種混合類型,一旦指定、那么每一個混合類型都要指定
# 表示類型為 list_tuple 的 a、c 接收的都是 list 對象、類型為 dict_set 的 b、d 接收的都是 dict 對象
func[list, dict]([1], {"x": ""}, [], {})
# 此外,我們必須寫成 func[list, dict]([1], {"x": ""}, [], {}),如果寫成 func[dict, list]([1], {"x": ""}, [], {}) 是不行的
# 因為類型為 list_tuple 的參數先出現,類型為 dict_set 的參數后出現
# 所以第一個出現的類型一定是 list_tuple 里面的類型(list 或 tuple),后面的是 dict_set 里面的類型(dict 或 set)
# 因此一旦指定版本,
# 那么只能是:func[list, dict](...)、func[list, set](...)、func[tuple, dict](...)、func[tuple, set](...) 之一
# 並且和傳遞的參數類型要匹配,否則報錯,比如:func[list, dict]((1, 2, 3), ...) 肯定不行,因為需要 list 我們卻傳遞了 tuple
當然有時候參數不一定只有融合類型,還可以有別的具體類型:
ctypedef fused list_tuple:
list
tuple
ctypedef fused dict_set:
dict
set
cdef func(list_tuple a, dict_set b, int xxx, list_tuple c, dict_set d):
print(a, b, c, d, xxx)
# 但是對於指定版本調用是無影響的,因為在 func 后面的 [] 中只需要指定融合類型中的具體類型,其它的不需要管
func[list, dict]([1], {"x": ""}, 123, [], {}) # [1] {'x': ''} [] {} 123
func[list, set]([1], {1, 2, 3}, 456, [], {2}) # [1] {1, 2, 3} [] {2} 456
以上的函數都是使用 cdef 定義的,無法被外部訪問,但是我們也可以使用 def 和 cpdef 定義,這樣的話,函數就可以被外界訪問了。
此外,我們還可以提前聲明使用的函數版本:
ctypedef fused list_tuple:
list
tuple
ctypedef fused dict_set:
dict
set
cdef func(list_tuple a, dict_set b, int xxx, list_tuple c, dict_set d):
print(a, b, c, d, xxx)
# 聲明一個函數指針,指向的函數接收五個參數,類型分別是 list, set, int, list, set
# 此時必須將所有參數的類型全部指定,不能只指定混合類型,並且聲明為同一種混合類型的參數的具體類型仍然要一致
cdef object (*func_with_list_set)(list, set, int, list, set)
# 賦值
func_with_list_set = func
func([], {1}, 123, [], {2}) # [] {1} [] {2} 123
# 或者這種方式也是可以的,將 func 轉成 <object (*)(list, set, int, list, set)>
(<object (*)(list, set, int, list, set)> func)([], {1}, 123, [], {2}) # [] {1} [] {2} 123
# 還有就是之前的方式,只不過可以拆開使用
# [] 里面只能指定融合類型
cdef func_with_tuple_dict = func[tuple, dict]
func_with_tuple_dict((1, 2), {"a": "b"}, 456, (11, 22), {"b": "a"}) # (1, 2) {'a': 'b'} (11, 22) {'b': 'a'} 456
由於我們定義的是混合類型,那么究竟具體是哪一種我們根據參數的值進行判斷,但其實除了通過參數判斷之外,我們還可以通過混合類型去判斷,舉個栗子:
ctypedef fused list_tuple_dict:
list
tuple
dict
cdef func(list_tuple_dict val):
if list_tuple_dict is list:
print("val 是 list 類型")
elif list_tuple_dict is tuple:
print("val 是 tuple 類型")
else:
print("val 是 dict 類型")
func([])
func(())
func({})
"""
val 是 list 類型
val 是 tuple 類型
val 是 dict 類型
"""
混合類型具體會是哪一種類型,在參數傳遞的時候便會得到確定。
因此 Cython 中的泛型編程還是很強大的,但是在工作中的使用頻率其實並不是那么頻繁。
Cython中的for循環和while循環
Python 中的 for 循環和 while 循環是靈活並且高級的,語法自然、讀起來像偽代碼。而 Cython 也是支持 for 和 while 的,無需修改,並且循環通常占據程序運行時的大部分時間,因此我們可以通過一些指針,確保 Cython 能夠將 Python 中的循環轉換為高效的 C 循環。
n = 100
for i in range(n):
...
上面是一個標准的 Python for 循環,如果這個 i 和 n 是靜態類型,那么 Cython 就能生成更快的 C 代碼。
cdef unsigned long i, n = 100
for i in range(n):
...
# 這段代碼和下面的C代碼是等效的
"""
for (i=0; i<n; ++i) {
/* ... */
}
"""
所以當通過 range 進行循環時,我們應該將 range 里面的參數以及循環變量換成 C 的整型。如果不顯式地進行靜態聲明的話,Cython 就會采用最保守的策略:
cdef unsigned long n = 100
for i in range(n):
...
在循環的時候,這里的 i 也會被當成是 C 的整型,但前提是我們沒有在循環體的表達式中使用 i 這個變量。如果我們使用了,那么 Cython 無法確定是否會發生溢出,因此會保守的選擇 Python 中的類型。
cdef unsigned n = 100
for i in range(n):
print(i + 2 ** 32)
我們看到我們在表達式中使用到了 i,如果這里的 i 是 C 中的整型,那么在和一個純數字相加的時候,Cython 不知道是否會發生溢出,所以這里的 i 就不會變成 C 中的整型。
如果我們能保證表達式中一定不會發生溢出,那么我們可以顯式地將 i 也聲明為 C 中的整數類型。比如:
cdef unsigned long i, n = 100
。
當我們遍歷一個容器(list、tuple、dict等等)的時候,對於容器的高效循環,我們可以考慮將容器轉化為 C++ 的有效容器、或者使用類型化的內存視圖。當然這些我們也是在后面系列中說了,因為這些東西顯然沒辦法一次說清(感覺埋了好多坑,欠了好多債)
。
目前只能在 range 中減少循環開銷,我們將在后續系列中了解優化循環體的更多信息,包括 numpy 在 Cython 中的使用以及類型化內存視圖。至於 while 循環的優化方式和 for 循環是類似的。
循環的另一種方式
但是對於 Cython 而言,循環還有另一種方式,不過已經過時了,不建議使用,了解一下即可:
cdef int i
# 不可以寫成 for i from i >=0 and i < 5
for i from 0<= i < 5: # 等價於 for i in range(0, 5)
print(i)
"""
0
1
2
3
4
"""
for i from 0 <= i < 5 by 2: # for i in range(0, 5, 2)
print(i)
"""
0
2
4
"""
Cython 預處理器
我們知道在 C 中可以使用 #define
定義一個宏,在 Cython 中也是可以的,不過使用的是 DEF
關鍵字。
DEF pi = 3.14
print(pi * 2)
DEF 定義的宏在編譯的時候就會被替換成我們指定的值,可以用於聲明 C 的類型、也可以是 Python 的類型。比如這里的 pi,在編譯的時候就會被換成 3.14,注意:這個宏只是簡單的字符串替換,如果你了解 C 中的宏的話,那么 Cython 中的宏和 C 中的宏是類似的。
UNAME_SYSNAME:操作系統的名稱
UNAME_RELEASE:操作系統的發行版
UNAME_VERSION:操作系統的版本
UNAME_MACHINE:操作系統的機型、或者硬件名稱
UNAME_NODENAME:網絡名稱
除此之外,Cython 還允許我們像 C 一樣使用 IF ELIF ELSE
進行條件編譯。
IF UNAME_SYSNAME == "Windows":
print("這是Windows系統")
ELIF UNAME_SYSNAME == "Linux":
print("這是Linux系統")
ELSE:
print("這是其它系統")
import pyximport
pyximport.install(language_level=3)
import cython_test
"""
這是Windows系統
"""
另外:操作系統這些內置的宏,需要搭配
IF ELIF ELSE
使用,單獨使用是會報錯的。
總結
這次深入介紹了 Cython 的語言特性,並且為了更好的理解,使用了很多 Python 解釋器里面才出現的術語,比如:PyObject、PyFunctionObject 等等,在學習 Cython 的某些知識時相當於站在了解釋器的角度上,當然也介紹了 Python 解釋器的一些知識。所以看這一篇博客,需要你有 Python 解釋器相關的知識、以及了解 C 語言,不然學習起來可能有點吃力。
我們后續將會以這些特性為基礎,進行深入地使用。目前的話,有些知識並沒有覆蓋的那么詳細,比如:結構體等等,因為循序漸進嘛,所以暫時先拋出來,后面系列再慢慢研究。
Cython 生態
Cython是一個輔助語言,它是建立在 Python 之上的,是為 Python 編寫擴展模塊的。所以很少有項目會完全使用 Cython 編寫(uvloop 例外),但它確實是一個成熟的語言,有自己的語法(個人非常喜歡,覺得設計的真酷)。在 GitHub 上搜索,會發現大量的 Cython 源文件分布在眾多的存儲庫中。
考慮到 numpy、pandas、scipy、sklearn 等知名模塊內部都在使用,所以 Cython 也算是間接地被數百萬的開發人員、分析師、工程師和科學家直接或者間接使用。
如果 Pareto 原理是可信的,程序中百分之 80 的運行時開銷是由百分之 20 的代碼引起的,那么對於一個 Python 項目來說,只需要將少部分 Python 代碼轉換成 Cython 代碼即可。
一些用到 Cython 的頂尖項目都是與數據分析和科學計算有關的,這並非偶然。Cython 之所以會在這些領域大放異彩,有以下幾個原因:
1. Cython 可以高效且簡便地封裝現有的 C、C++、FORTRAN 庫,從而對那些已經優化並調試過的功能進行訪問。這里多提一句,FORTRAN算是一個上古的語言了,它的歷史比 C 還要早,但是別看它出現的早、但速度是真的快,尤其是在數值計算方面甚至比 C 還要快。包括 numpy 使用的 blas 內部也用到了 FORTRAN,雖然 FORTRAN 編寫代碼異常的痛苦,但是它在一些學術界和工業界還是具有一席之地的。原因就是它內部的一些算法,都是經過大量的優化、並且久經考驗的,直接拿來用就可以。而 Cython 也提供了相應的姿勢來調用 FORTRAN 已經編寫好的功能。
2. 當轉化為靜態類型語言時,內存和 CPU 密集的 Python 計算會有更好的執行性能。
3. 在處理大型的數據集時,與 Python 內置的數據結構相比,在低級別控制精確的數據類型和數據結構可以讓存儲更高效、執行性能更優秀。
4. Cython 可以和 C、C++、FORTRAN 庫共享同類型的連續數組,並通過 numpy 中的數組直接暴露給Python。
不過即便不是在數據分析和科學計算領域,Cython 也可以大放異彩,它也可以加速一般的 Python 代碼,包括數據結構和密集型算法。例如:lxml 這個高性能的 xml 解析器內部就大量使用了 Cython。因此即使它不在科學計算和數據分析的保護傘下,也依舊有很大的用途。
感覺這一章內容有點多啊。。。。。