楔子
Python 和 C / C++ 混合編程已經屢見不鮮了,那為什么要將這兩種語言結合起來呢?或者說,這兩種語言混合起來能給為我們帶來什么好處呢?首先,Python 和 C / C++ 聯合,無非兩種情況。
1. C / C++ 為主導的項目中引入 Python;
2. Python 為主導的項目中引入 C / C++;
首先是第一種情況,因為 C / C++ 是編譯型語言,而它們的編譯調試的成本是很大的。如果用 C / C++ 開發一個大型項目的話,比如游戲引擎,這個時候代碼的修改、調試是無可避免的。而對於編譯型語言來說,你對代碼做任何一點改動都需要重新編譯,而這個耗時是比較長的,所以這樣算下來成本會非常高。這個時候一個比較不錯的做法是,將那些跟性能無關的內容開放給腳本,可以是 Lua 腳本、也可以是 Python 腳本,而腳本語言不需要編譯,我們可以隨時修改,這樣可以減少編譯調試的成本。還有就是引入了 Python 腳本之后,我們可以把 C / C++ 做的更加模塊化,由 Python 將 C / C++ 各個部分聯合起來,這樣可以降低 C / C++ 代碼的耦合度,從而加強可重用性。
然后是第二種情況,Python 項目中引入 C / C++。我們知道 Python 的效率不是很高,如果你希望 Python 能夠具有更高的性能,那么可以把一些和性能相關的邏輯使用 C / C++ 進行重寫。此外,Python 有大量的第三方庫,特別是諸如 Numpy、Pandas、Scipy 等等和科學計算密切相關的庫,底層都是基於 C / C++ 的。再比如機器學習,底層核心算法都是基於 C / C++ 編寫的,然后在業務層暴露給 Python 去調用,因此對於一些需要高性能的領域,Python 是必須要引入 C / C++ 的。此外 Python 還有一個最讓人詬病的問題,就是由於 GIL 的限制導致 Python 無法有效利用多核,而引入 C / C++ 可以繞過 GIL 的限制。
此外有一個項目叫做 Cython,從名字你就能看出來這是將 Python 和 C / C++ 結合在了一起,之所以把它們結合在一起,很明顯,因為這兩者不是對立的,而是互補的。Python 是高階語言、動態、易於學習,並且靈活。但是這些優秀的特性是需要付出代價的,因為 Python 的動態性、以及它是解釋型語言,導致其運行效率比靜態編譯型語言慢了好幾個數量級。而 C / C++ 是非常古老的靜態編譯型語言,並且至今也被廣泛使用。從時間來算的話,其編譯器已有將近半個世紀的歷史,在性能上做了足夠的優化。而 Cython 的出現,就是為了讓你編寫的代碼具有 C / C++ 的高效率的同時,還能有 Python 的開發速度。
而筆者本人是主 Python 的,所以我們只會介紹第二種,也就是 Python 項目中引入 C / C++。而在 Python 中引入 C / C++,也涉及兩種情況。第一種是,Python 通過 ctypes 模塊直接調用 C / C++ 編寫好的動態鏈接庫,此時不會涉及任何的 Python / C API,只是單純的通過 ctypes 模塊將 Python 中的數據轉成 C 中的數據傳遞給函數進行調用,調用完之后再將返回值轉成 Python 中的數據。因此這種方式它和 Python 底層提供的 Python / C API 無關,和 Python 的版本也無關,因此會很方便。但很明顯這種方式是有局限性的,至於局限性在哪兒,我們后面慢慢聊,因此還有一種選擇是通過 C / C++ 為 Python 編寫擴展模塊的方式,來在 Python 中引入 C / C++,比如 OpenCV。
無論是 ctypes 調用動態鏈接庫,還是 C / C++ 為 Python 編寫擴展模塊,我們都會介紹。
環境准備
首先是 Python 的安裝,估計這應該不用我說了,我這里使用的 Python 版本是 3.8.7。
然后重點是 C / C++ 編譯器的安裝,我這里使用的是 64 位的 Windows 10 操作系統,所以我們需要手動安裝相應的編譯環境。可以下載一個 gcc,然后配置到環境變量中,就可以使用了。
或者安裝 Visual Studio,我的 Visual Studio 版本是 2017,在命令行中可以通過 cl 命令進行編譯。
當然這兩種命令的使用方式都是類似的,或者你也可以使用 Linux,比如 CentOS,基本上自帶 gcc。當然 Linux 的話,環境什么的比較簡單,這里就不再廢話了。重點是如果你是在 Windows 上使用 Visual Studio 的話,在命令行中輸入命令 cl,很可能會提示你命令找不到;再或者編譯的時候,會提示你 fatal error 不包括路徑集等等。出現以上問題的話,說明你的環境變量沒有配置正確,下面來說一下環境變量的配置。再次強調,我操作系統是 64 位 Windows 10,Visual Studio 版本是 2017,相信大部分人應該我是一樣的,如果完全一樣的話,那么路徑啥的應該也是一致的,當然最好還是檢查一下。
首先在 path 中添加如下幾個路徑:
C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.16.27023\bin\Hostx64\x64
C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64
C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\Common7\IDE
然后,新建一個環境變量。
變量名為 LIB,變量值為以下路徑,由於是寫在一行,所以路徑之間需要使用分號進行隔開。
C:\Program Files (x86)\Windows Kits\10\Lib\10.0.17763.0\um\x64
C:\Program Files (x86)\Windows Kits\10\Lib\10.0.17763.0\ucrt\x64
C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.16.27023\lib\x64
最后,還是新建一個環境變量,變量名為 INCLUDE,變量值為以下路徑:
C:\Program Files (x86)\Windows Kits\10\Include\10.0.17763.0\ucrt
C:\Program Files (x86)\Windows Kits\10\Lib\10.0.17763.0\um
C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.16.27023\include
以上就是 Windows 系統中配置 Visual Studio 2017 環境變量的整個過程,配置完畢之后重啟命令行之后就可以使用了。注意:以上是我當前機器的路徑,如果你的配置和我不一樣,記得仔細檢查。
不過個人更習慣使用 gcc,因此后面我們會使用 gcc 進行編譯。
Python ctypes 模塊調用 C / C++ 動態鏈接庫
通過 ctypes 模塊(Python 自帶的)調用 C / C++ 動態庫,也算是 Python 和 C / C++ 聯合編程的一種方案,而且是最簡單的一種方案。因為它只對你的操作系統有要求,比如 Windows 上編譯的動態庫是 .dll 文件,Linux 上編譯的動態庫是 .so 文件,只要操作系統一致,那么任何提供了 ctypes 模塊的 Python 解釋器都可以調用。這種方式的使用場景是 Python 和 C / C++ 不需要做太多的交互,比如嵌入式設備,可能只是簡單調用底層驅動提供的某個接口而已。
再比如我們使用 C / C++ 寫了一個高性能的算法,然后通過 Python 的 ctypes 模塊進行調用也是可以的,但我們之前說使用 ctypes 具有相應的局限性,這個局限性就是 C / C++ 提供的接口不能太復雜。因為 ctypes 提供的交互能力還是比較有限的,最明顯的問題就是不同語言數據類型不同,一些復雜的交互方式還是比較難做到的,還有多線程的控制問題等等。
舉個小栗子
首先我們來舉個栗子,演示一下。
int f(){
return 123;
}
這是個簡單到不能再簡單的 C 函數,然后我們來編譯成動態庫。
編譯方式: gcc -o .dll文件或者.so文件 -shared c或者c++源文件
如果你用的是 Visual Studio,那么把 gcc 換成 cl 即可。我當前的源文件叫做 main.c,我們編譯成 main.dll,那么命令就需要這么寫:gcc -o main.dll -shared main.c。
編譯成功之后,我們通過 ctypes 來進行調用。
import ctypes
# 使用 ctypes 很簡單,直接import進來,然后使用 ctypes.CDLL 這個類來加載動態鏈接庫
# 或者是用 ctypes.cdll.LoadLibrary("./main.dll")
lib = ctypes.CDLL(r"./main.dll") # 加載之后就得到了動態鏈接庫對象
# 我們可以直接通過 . 的方式去調用里面的函數了,會發現成功打印
print(lib.f()) # 123
# 但是為了確定是否存在這個函數,我們一般會使用反射去獲取
# 因為如果函數不存在通過 . 的方式調用會拋異常的
func = getattr(lib, "f", None)
if func:
print(func) # <_FuncPtr object at 0x0000029F75F315F0>
func() # hello world
# 不存在 f2 這個函數,所以得到的結果為 None
func1 = getattr(lib, "f2", None)
print(func1) # None
所以使用ctypes去調用動態鏈接庫非常方便,過程很簡單:
1. 通過 ctypes.CDLL 去加載動態庫,另外注意的是:dll 或者 so 文件的路徑最好是絕對路徑,即便不是也要表明層級。比如我們這里的 py 文件和 dll 文件是在同一個目錄下,但是我們加載的時候不可以寫 main.dll,這樣會報錯找不到,我們需要寫成 ./main.dll
2. 加載動態鏈接庫之后會返回一個對象,我們上面起名為 lib,這個 lib 就是得到的動態鏈接庫了
3. 然后可以直接通過 lib 調用里面的函數,但是一般我們會使用反射的方式來獲取,因為不知道函數到底存不存在,如果不存在直接調用會拋出異常,如果存在這個函數我們才會調用。
Linux 和 Mac 也是一樣的,這里不演示了,只不過編譯之后的名字不一樣。Linux 系統是 .so,Mac 系統是 .dylib。
此外我們也可以在 C 中進行打印,舉個栗子:
#include <stdio.h>
void f(){
printf("hello world");
}
然后編譯,進行調用。
import ctypes
lib = ctypes.CDLL(r"./main.dll") # 加載之后就得到了動態鏈接庫對象
lib.f() # hello world
另外,Python 的 ctypes 調用的都是 C 語言函數,如果你用的 C++ 編譯器,那么會編譯成 C++ 中的函數。我們知道 C 語言的函數不支持重載,說白了就是不可以定義兩個同名的函數,而 C++ 的函數是支持重載的,只要參數類型不一致即可,然后調用的時候會根據傳遞的參數調用對應的函數。所以當我們使用 C++ 編譯器的時候,需要通過 extern "C" 將函數包起來,這樣 C++ 編譯器在編譯的時候會將其編譯成 C 的函數。
#include <stdio.h>
// 注意: 我們不能直接通過 extern "C" {} 將函數包起來, 因為這不符合 C 的語法, extern 在 C 中是用來聲明一個外部變量的
// 所以我們應該使用宏替換的方式, 如果是 C++ 編譯器的話, 那么編譯的時候 #ifdef __cplusplus 是會通過的, 因為 __cplusplus 是一個預定義的宏
// 如果是 C 編譯器, 那么 #ifdef __cplusplus 不會通過
#ifdef __cplusplus
extern "C" {
#endif
void f() {
printf("hello world\n");
}
#ifdef __cplusplus
}
#endif
當然我們在介紹 ctypes 使用的 gcc 都是 C 編譯器,會編譯成 C 的函數,所以后面 extern "C" 的邏輯就不加了。
我們以上就演示了,如何通過 Python 的 ctypes 模塊來調用 C / C++ 動態庫,但顯然目前還是遠遠不夠的。比如說:
double f() {
return 3.14;
}
然后我們調用的時候,會得到什么結果呢?來試一下:
import ctypes
lib = ctypes.CDLL(r"./main.dll") # 加載之后就得到了動態鏈接庫對象
print(lib.f()) # 1374389535
我們看到得到一個不符合預期的結果,我們暫且不糾結它是怎么來的,現在的問題是它返回的為什么不是 3.14 呢?原因是 ctypes 在解析的時候默認是按照整型來解析的,但很明顯我們 C 函數返回是浮點型,因此我們在調用之前需要顯式的指定其返回值。
不過在這之前,我們需要先來看看 Python 類型和 C 類型之間的轉換關系。
Python 類型與 C 語言類型之間的轉換
我們說可以使用 ctypes 調用動態鏈接庫,主要是調用動態鏈接庫中使用C編寫好的函數,但這些函數肯定都是需要參數的,還有返回值,不然編寫動態鏈接庫有啥用呢。那么問題來了,不同的語言變量類型不同,所以 Python 能夠直接往 C 編寫的函數中傳參嗎?顯然不行,因此 ctypes 提供了大量的類,幫我們將 Python 中的類型轉成 C 語言中的類型。
我們說了,Python 中類型不能直接往 C 語言的函數中傳遞(整型是個例外),而 ctypes 可以幫助我們將 Python 的類型轉成 C 類型。而常見的類型分為以下幾種:數值、字符、指針。
數值類型轉換
C 語言的數值類型分為如下:
int:整型
unsigned int:無符號整型
short:短整型
unsigned short:無符號短整型
long:長整形
unsigned long:無符號長整形
long long:64位機器上等同於 long
unsigned long long:等同於 unsigned long
float:單精度浮點型
double:雙精度浮點型
long double:看成是 double 即可
_Bool:布爾類型
ssize_t:等同於 long 或者 long long
size_t:等同於 unsigned long 或者 unsigned long long
下面來演示一下:
import ctypes
# 下面都是 ctypes 中提供的類,將 Python 中的對象傳進去,就可以轉換為 C 語言能夠識別的類型
print(ctypes.c_int(1)) # c_long(1)
print(ctypes.c_uint(1)) # c_ulong(1)
print(ctypes.c_short(1)) # c_short(1)
print(ctypes.c_ushort(1)) # c_ushort(1)
print(ctypes.c_long(1)) # c_long(1)
print(ctypes.c_ulong(1)) # c_ulong(1)
# c_longlong 等價於 c_long,c_ulonglong 等價於c_ulong
print(ctypes.c_longlong(1)) # c_longlong(1)
print(ctypes.c_ulonglong(1)) # c_ulonglong(1)
print(ctypes.c_float(1.1)) # c_float(1.100000023841858)
print(ctypes.c_double(1.1)) # c_double(1.1)
# 在64位機器上,c_longdouble等於c_double
print(ctypes.c_longdouble(1.1)) # c_double(1.1)
print(ctypes.c_bool(True)) # c_bool(True)
# 相當於c_longlong和c_ulonglong
print(ctypes.c_ssize_t(10)) # c_longlong(10)
print(ctypes.c_size_t(10)) # c_ulonglong(10)
字符類型轉換、指針類型轉換
C 語言的字符類型分為如下:
char:一個 ascii 字符或者 -128~127 的整型
wchar:一個 unicode 字符
unsigned char:一個 ascii 字符或者 0~255 的一個整型
C 語言的指針類型分為如下:
char *:字符指針
wchar_t *:字符指針
void *:空指針
import ctypes
# 必須傳遞一個字節(里面是 ascii 字符),或者一個 int,來代表 C 里面的字符
print(ctypes.c_char(b"a")) # c_char(b'a')
print(ctypes.c_char(97)) # c_char(b'a')
# 傳遞一個 unicode 字符,當然 ascii 字符也是可以的,並且不是字節形式
print(ctypes.c_wchar("憨")) # c_wchar('憨')
# 和 c_char 類似,但是 c_char 既可以傳入單個字節、也可以傳整型,而這里的 c_byte 則要求必須傳遞整型。
print(ctypes.c_byte(97)) # c_byte(97)
print(ctypes.c_ubyte(97)) # c_ubyte(97)
# c_char_p 就是 c 里面字符數組了,其實我們可以把它看成是 Python 中的 bytes 對象
# char *s = "hello world";
# 那么這里面也要傳遞一個 bytes 類型的字符串,返回一個地址
print(ctypes.c_char_p(b"hello world")) # c_char_p(2082736374464)
# 直接傳遞一個字符串,同樣返回一個地址
print(ctypes.c_wchar_p("憨八嘎~")) # c_wchar_p(2884583039392)
# ctypes.c_void_p后面演示
常見的類型就是上面這些,至於其他的類型,比如整型指針、數組、結構體、回調函數等等,ctypes 也是支持的,我們后面會介紹。
參數傳遞
下面我們來看看如何傳遞參數。
#include <stdio.h>
void test(int a, float f, char *s)
{
printf("a = %d, b = %.2f, s = %s\n", a, f, s);
}
這是一個很簡單的 C 文件,然后編譯成 dll 之后,讓 Python 去調用,這里我們編譯之后的文件名叫做還叫做 main.dll。
from ctypes import *
lib = CDLL(r"./main.dll") # 加載之后就得到了動態鏈接庫對象
try:
lib.test(1, 1.2, b"hello world")
except Exception as e:
print(e) # argument 2: <class 'TypeError'>: Don't know how to convert parameter 2
# 我們看到一個問題,那就是報錯了,告訴我們不知道如何轉化第二個參數
# 正如我們之前說的,整型是會自動轉化的,但是浮點型是不會自動轉化的
# 因此我們需要使用 ctypes 來包裝一下,當然還有整型,即便整型會自動轉,我們還是建議手動轉化一下
# 這里傳入 c_int(1) 和 1 都是一樣的,但是建議傳入 c_int(1)
lib.test(c_int(1), c_float(1.2), c_char_p(b"hello world")) # a = 1, b = 1.20, s = hello world
我們看到完美的打印出來了,我們再來試試布爾類型。
#include <stdio.h>
void test(_Bool flag)
{
//布爾類型本質上是一個int
printf("a = %d\n", flag);
}
import ctypes
from ctypes import *
lib = ctypes.CDLL("./main.dll")
lib.test(c_bool(True)) # a = 1
lib.test(c_bool(False)) # a = 0
# 可以看到 True 被解釋成了 1,False 被解釋成了 0
# 我們說整型會自動轉化,而布爾類型繼承自整型所以布爾類型也可以直接傳遞
lib.test(True) # a = 1
lib.test(False) # a = 0
然后再來看看字符和字符數組的傳遞:
#include <stdio.h>
#include <string.h>
void test(int age, char *gender)
{
if (age >= 18)
{
if (strcmp(gender, "female") == 0)
{
printf("age >= 18, gender is female\n");
}
else
{
printf("age >= 18, gender is male\n");
}
}
else
{
if (strcmp(gender, "female") == 0)
{
printf("age < 18, gender is female\n");
}
else
{
printf("age < 18, gender is main\n");
}
}
}
from ctypes import *
lib = CDLL("./main.dll")
lib.test(c_int(20), c_char_p(b"female")) # age >= 18, gender is female
lib.test(c_int(20), c_char_p(b"male")) # age >= 18, gender is male
lib.test(c_int(14), c_char_p(b"female")) # age < 18, gender is female
lib.test(c_int(14), c_char_p(b"male")) # age < 18, gender is main
# 我們看到 C 中的字符數組,我們直接通過 c_char_p 來傳遞即可
# 至於單個字符,使用 c_char 即可
同理我們也可以打印寬字符,邏輯是類似的。
傳遞可變的字符串
我們知道 C 中不存在字符串這個概念,Python 中的字符串在 C 中也是通過字符數組來實現的,我們通過 ctypes 像 C 函數傳遞一個字符串的時候,在 C 中是可以被修改的。
#include <stdio.h>
void test(char *s)
{
s[0] = 'S';
printf("%s", s);
}
from ctypes import *
lib = CDLL("./main.dll")
lib.test(c_char_p(b"satori")) # Satori
我們看到小寫的字符串,第一個字符變成了大寫,但即便能修改我們也不建議這么做,因為 bytes 對象在 Python 中是不能更改的,所以在 C 中也不應該更改。當然不是說不讓修改,而是應該換一種方式。如果是需要修改的話,那么不要使用 c_char_p 的方式來傳遞,而是建議通過 create_string_buffer 來給 C 語言傳遞可以修改字符的空間。
from ctypes import *
# 傳入一個 int,表示創建一個具有固定大小的字符緩存,這里是 10 個
s = create_string_buffer(10)
# 直接打印就是一個對象
print(s) # <ctypes.c_char_Array_10 object at 0x000001E2E07667C0>
# 也可以調用 value 方法打印它的值,可以看到什么都沒有
print(s.value) # b''
# 並且它還有一個 raw 方法,表示 C 語言中的字符數組,由於長度為 10,並且沒有內容,所以全部是 \x00,就是C語言中的 \0
print(s.raw) # b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
# 還可以查看長度
print(len(s)) # 10
# 其它類型也是一樣的
v = c_int(1)
# 我們看到 c_int(1) 它的類型就是 ctypes.c_long
print(type(v)) # <class 'ctypes.c_long'>
# 當然你把 c_int,c_long,c_longlong 這些花里胡哨的都當成是整型就完事了
# 此外我們還能夠拿到它的值,調用 value 方法
print(v.value, type(v.value)) # 1 <class 'int'>
v = c_char(b"a")
print(type(v)) # <class 'ctypes.c_char'>
print(v.value, type(v.value)) # b'a' <class 'bytes'>
v = c_char_p(b"hello world")
print(type(v)) # <class 'ctypes.c_char_p'>
print(v.value, type(v.value)) # b'hello world' <class 'bytes'>
v = c_wchar_p("夏色祭")
print(type(v)) # <class 'ctypes.c_wchar_p'>
print(v.value, type(v.value)) # 夏色祭 <class 'str'>
# 因此 ctypes 中的對象調用 value 即可得到 Python 中的對象
當然 create_string_buffer 如果只傳一個 int,那么表示創建對應長度的字符緩存。除此之外,還可以指定字節串,此時的字符緩存大小和指定的字節串大小是一致的:
from ctypes import *
# 此時我們直接創建了一個字符緩存
s = create_string_buffer(b"hello")
print(s) # <ctypes.c_char_Array_6 object at 0x0000021944E467C0>
print(s.value) # b'hello'
# 我們知道在 C 中,字符數組是以 \0 作為結束標記的,所以結尾會有一個 \0,因為 raw 表示 C 中原始的字符數組
print(s.raw) # b'hello\x00'
# 長度為 6,b"hello" 五個字符再加上 \0 一共 6 個
print(len(s))
當然 create_string_buffer 還可以在指定字節串的同時,指定空間大小。
from ctypes import *
# 此時我們直接創建了一個字符緩存,如果不指定容量,那么默認和對應的字符數組大小一致
# 但是我們還可以同時指定容量,記得容量要比前面的字節串的長度要大。
s = create_string_buffer(b"hello", 10)
print(s) # <ctypes.c_char_Array_10 object at 0x0000019361C067C0>
print(s.value) # b'hello'
# 長度為 10,剩余的 5 個顯然是 \0
print(s.raw) # b'hello\x00\x00\x00\x00\x00'
print(len(s)) # 10
下面我們來看看如何使用 create_string_buffer 來傳遞:
#include <stdio.h>
int test(char *s)
{
//變量的形式依舊是char *s
//下面的操作就是相當於把字符數組的索引為5到11的部分換成" satori"
s[5] = ' ';
s[6] = 's';
s[7] = 'a';
s[8] = 't';
s[9] = 'o';
s[10] = 'r';
s[11] = 'i';
printf("s = %s\n", s);
}
from ctypes import *
lib = CDLL("./main.dll")
s = create_string_buffer(b"hello", 20)
lib.test(s) # s = hello satori
此時就成功地修改了,我們這里的 b"hello" 占五個字節,下一個正好是索引為 5 的地方,然后把索引為 5 到 11 的部分換成對應的字符。但是需要注意的是,一定要小心 \0
,我們知道 C 語言中一旦遇到了 \0
就表示這個字符數組結束了。
from ctypes import *
lib = CDLL("./main.dll")
# 這里把"hello"換成"hell",看看會發生什么
s = create_string_buffer(b"hell", 20)
lib.test(s) # s = hell
# 我們看到這里只打印了"hell",這是為什么?
# 我們看一下這個s
print(s.raw) # b'hell\x00 satori\x00\x00\x00\x00\x00\x00\x00\x00'
# 我們看到這個 create_string_buffer 返回的對象是可變的,在將 s 傳進去之后被修改了
# 如果沒有傳遞的話,我們知道它是長這樣的。
"""
b'hell\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
hell的后面全部是C語言中的 \0
修改之后變成了這樣
b'hell\x00 satori\x00\x00\x00\x00\x00\x00\x00\x00'
我們看到確實是把索引為5到11(包含11)的部分變成了" satori"
但是我們知道 C 語言中掃描字符數組的時候一旦遇到了 \0,就表示結束了,而hell后面就是 \0,
因為即便后面還有內容也不會輸出了,所以直接就只打印了 hell
"""
另外除了 create_string_buffer 之外,還有一個 create_unicode_buffer,針對於 wchar_t *,用法和 create_string_buffer 類似。
調用操作系統的庫函數
我們知道 Python 解釋器本質上就是使用 C 語言寫出來的一個軟件,那么操作系統呢?操作系統本質上它也是一個軟件,不管是 Windows、Linux 還是 MacOS 都自帶了大量的共享庫,那么我們就可以使用 Python 去調用。
from ctypes import *
import sys
import platform
# 判斷當前的操作系統平台。
# Windows 平台返回 "Windows",Linux 平台返回 "Linux",MacOS 平台返回 "Darwin"
system = platform.system()
# 不同的平台共享庫不同
if system == "Windows":
lib = cdll.msvcrt
elif system == "Linux":
lib = CDLL("libc.so.6")
elif system == "Darwin":
lib = CDLL("libc.dylib")
else:
print("不支持的平台,程序結束")
sys.exit(0)
# 調用對應的函數,比如 printf,注意里面需要傳入字節
lib.printf(b"my name is %s, age is %d\n", b"van", 37) # my name is van, age is 37
# 如果包含漢字就不能使用 b"" 這種形式了,因為這種形式只適用於 ascii 字符,我們需要手動 encode 成 utf-8
lib.printf("姓名: %s, 年齡: %d\n".encode("utf-8"), "古明地覺".encode("utf-8"), 17) # 姓名: 古明地覺, 年齡: 17
我們上面是在 Windows 上調用的,這段代碼即便拿到 Linux 和 MacOS 上也可以正常執行。
當然這里面還支持其他的函數,我們這里以 Windows 為例:
from ctypes import *
libc = cdll.msvcrt
# 創建一個大小為 10 的buffer
s = create_string_buffer(10)
# strcpy 表示將字符串進行拷貝
libc.strcpy(s, c_char_p(b"hello satori"))
# 由於 buffer 只有10個字節大小,所以無法完全拷貝
print(s.value) # b'hello sato'
# 創建 unicode buffer
s = create_unicode_buffer(10)
libc.strcpy(s, c_wchar_p("我也覺得很變態啊"))
print(s.value) # 我也覺得很變態啊
# 比如 puts 函數
libc.puts(b"hello world") # hello world
對於 Windows 來說,我們還可以調用一些其它的函數,但是不再是通過 cdll.msvcrt 這種方式了。在 Windows 上面有一個 user32 這么個東西,我們來看一下:
from ctypes import *
# 我們通過 cdll.user32 本質上還是加載了 Windows 上的一個共享庫
# 這個庫給我們提供了很多方便的功能
win = cdll.user32
# 比如查看屏幕的分辨率
print(win.GetSystemMetrics(0)) # 1920
print(win.GetSystemMetrics(1)) # 1080
我們還可以用它來打開 MessageBoxA:
可以看到我們通過 cdll.user32 就可以很輕松地調用 Windows 的 api,具體有哪些 api 可以去網上查找,搜索 win32 api 即可。
除了 ctypes,還有幾個專門用來操作 win32 服務的模塊,win32gui、win32con、win32api、win32com、win32process。直接 pip install pywin32 即可,或者 pip install pypiwin32。
顯示窗體和隱藏窗體
import win32gui
import win32con
# 首先查找窗體,這里查找 qq。需要傳入 窗口類名 窗口標題名,至於這個怎么獲取可以使用 spy 工具查看
qq = win32gui.FindWindow("TXGuifoundation", "QQ")
# 然后讓窗體顯示出來
win32gui.ShowWindow(qq, win32con.SW_SHOW)
# 還可以隱藏
win32gui.ShowWindow(qq, win32con.SW_HIDE)
控制窗體的位置和大小
import win32gui
import win32con
qq = win32gui.FindWindow("TXGuiFoundation", "QQ")
# 主要要接收如下參數
# 參數一:控制的窗體
# 參數二:大致方位:HWND_TOPMOST,位於上方
# 參數三:位置x
# 參數四:位置y
# 參數五:長度
# 參數六:寬度
# 參數七:比較固定,就是讓窗體一直顯示
win32gui.SetWindowPos(qq, win32con.HWND_TOPMOST, 100, 100, 300, 300, win32con.SWP_SHOWWINDOW)
那么我們還可以讓窗體滿屏幕亂跑:
import win32gui
import win32con
import random
qqWin = win32gui.FindWindow("TXGuiFoundation", "QQ")
# 將位置變成隨機數
while True:
x = random.randint(1, 1920)
y = random.randint(1, 1080)
win32gui.SetWindowPos(qqWin, win32con.HWND_TOPMOST, x, y, 300, 300, win32con.SWP_SHOWWINDOW)
語音播放
import win32com.client
# 直接調用操作系統的語音接口
speaker = win32com.client.Dispatch("SAPI.SpVoice")
# 輸入你想要說的話,前提是操作系統語音助手要認識。一般中文和英文是沒有問題的
speaker.Speak("他能秒我,他能秒殺我?他要是能把我秒了,我當場······")
Python 中 win32 模塊的 api 非常多,幾乎可以操作整個 Windows 提供的服務,win32 模塊就是相當於把 Windows 服務封裝成了一個一個的接口。不過這些服務、或者調用這些服務具體都能干些什么,可以自己去研究,這里就到此為止了。
ctypes 獲取返回值
我們前面已經看到了,通過 ctypes 向動態鏈接庫中的函數傳參時是沒有問題的,但是我們如何拿到返回值呢?我們之前都是使用 printf 直接打印的,但是這樣顯然不行,我們肯定是要拿到返回值去做一些別的事情的。那么我們在 C 函數中直接 return 不就可以啦,還記得之前演示的返回浮點型的例子嗎?我們明明返回了 3.14,但得到的確是一大長串整數,所以我們需要在調用函數之前告訴 ctypes 返回值的類型。
int test1(int a, int b)
{
int c;
c = a + b;
return c;
}
void test2()
{
}
from ctypes import *
lib = CDLL("./main.dll")
print(lib.test1(25, 33)) # 58
print(lib.test2()) # -883932787
我們看到對於 test1 的結果是正常的,但是對於 test2 來說即便返回的是 void,在 Python 中依舊會得到一個整型,因為默認都會按照整型進行解析,但這個結果肯定是不正確的。不過對於整型來說,是完全沒有問題的。
正如我們傳遞參數一樣,需要使用 ctypes 轉化一下,那么在獲取返回值的時候,也需要提前使用 ctypes 指定一下返回值到底是什么類型,只有這樣才能拿到動態鏈接庫中函數的正確的返回值。
#include <wchar.h>
char * test1()
{
char *s = "hello satori";
return s;
}
wchar_t * test2()
{
// 遇到 wchar_t 的時候,一定要導入 wchar.h 頭文件
wchar_t *s = L"憨八嘎";
return s;
}
from ctypes import *
lib = CDLL("./main.dll")
# 不出所料,我們在動態鏈接庫中返回的是一個字符數組的首地址,我們希望拿到指向的字符串
# 然而 Python 拿到的仍是一個整型,而且一看感覺這像是一個地址。如果是地址的話那么從理論上講是對的,返回地址、獲取地址
print(lib.test1()) # 1788100608
# 但我們希望的是獲取地址指向的字符數組,所以我們需要指定一下返回的類型
# 指定為 c_char_p,告訴 ctypes 你在解析的時候將 test1 的返回值按照 c_char_p 進行解析
lib.test1.restype = c_char_p
# 此時就沒有問題了
print(lib.test1()) # b'hello satori'
# 同理對於 unicode 也是一樣的,如果不指定類型,得到的依舊是一個整型
lib.test2.restype = c_wchar_p
print(lib.test2()) # 憨八嘎
因此我們就將 Python 中的類型和 C 語言中的類型通過 ctypes 關聯起來了,我們傳參的時候需要轉化,同理獲取返回值的時候也要使用 ctypes 來聲明一下類型。因為默認 Python 調用動態鏈接庫的函數返回的都是整型,至於返回的整型的值到底是什么?從哪里來的?我們不需要關心,你可以理解為地址、或者某塊內存的臟數據,但是不管怎么樣,結果肯定是不正確的(如果函數返回的就是整形除外)。因此我們需要提前聲明一下返回值的類型。聲明方式:
lib.CFunction.restype = ctypes類型
我們說 lib 就是 ctypes 調用 dll 或者 so 得到的動態鏈接庫,而里面的函數就相當於是一個個的 CFunction,然后設置內部的 restype(返回值類型),就可以得到正確的返回值了。另外即便返回值設置的不對,比如:test1 返回一個 char *,但是我們將類型設置為 c_float,調用的時候也不會報錯而且得到的也是一個 float,但是這個結果肯定是不對的。
from ctypes import *
lib = CDLL("./main.dll")
lib.test1.restype = c_char_p
print(lib.test1()) # b'hello satori'
# 設置為 c_float
lib.test1.restype = c_float
# 獲取了不知道從哪里來的臟數據
print(lib.test1()) # 2.5420596244190436e+20
# 另外 ctypes 調用還有一個特點
lib.test2.restype = c_wchar_p
print(lib.test2(123, c_float(1.35), c_wchar_p("呼呼呼"))) # 憨八嘎
# 我們看到 test2 是不需要參數的,如果我們傳了那么就會忽略掉,依舊能得到正常的返回值
# 但是不要這么做,因為沒准就出問題了,所以還是該傳幾個參數就傳幾個參數
下面我們來看看浮點類型的返回值怎么獲取,當然方法和上面是一樣的。
#include <math.h>
float test1(int a, int b)
{
float c;
c = sqrt(a * a + b * b);
return c;
}
from ctypes import *
lib = CDLL("./main.dll")
# 得到的結果是一個整型,默認都是整型。
# 我們不知道這個整型是從哪里來的,就把它理解為地址吧,但是不管咋樣,結果肯定是不對的
print(lib.test1(3, 4)) # 1084227584
# 我們需要指定返回值的類型,告訴 ctypes 返回的是一個 float
lib.test1.restype = c_float
# 此時結果就是對的
print(lib.test1(3, 4)) # 5.0
# 如果指定為 double 呢?
lib.test1.restype = c_double
# 得到的結果也有問題,總之類型一定要匹配
print(lib.test1(3, 4)) # 5.356796015e-315
# 至於 int 就不用說了,因為默認就是 int。所以和第一個結果是一樣的
lib.test1.restype = c_int
print(lib.test1(3, 4)) # 1084227584
所以類型一定要匹配,該是什么類型就是什么類型。即便動態鏈接庫中返回的是 float,我們在 Python 中通過 ctypes 也要指定為 float,而不是指定為 double,盡管都是浮點數並且 double 的精度還更高,但是結果依舊不是正確的。至於整型就不需要關心了,但即便如此,int、long 也建議不要混用,而且傳參的時候最好也進行轉化。
ctypes 給動態鏈接庫中的函數傳遞指針
我們使用 ctypes 可以創建一個字符數組並且拿到首地址,但是對於整型、浮點型我們怎么創建指針呢?下面就來揭曉。另外,一旦涉及到指針操作的時候就要小心了,因為這往往是比較危險的,所以 Python 把指針給隱藏掉了,當然不是說沒有指針,肯定是有指針的。只不過操作指針的權限沒有暴露給程序員,能夠操作指針的只有對應的解釋器。
ctypes.byref 和 ctypes.pointer 創建指針
from ctypes import *
v = c_int(123)
# 我們知道可以通過 value 屬性獲取相應的值
print(v.value)
# 但是我們還可以修改
v.value = 456
print(v) # c_long(456)
s = create_string_buffer(b"hello")
s[3] = b'>'
print(s.value) # b'hel>o'
# 如何創建指針呢?通過 byref 和 pointer
v2 = c_int(123)
print(byref(v2)) # <cparam 'P' (000001D9DCF86888)>
print(pointer(v2)) # <__main__.LP_c_long object at 0x000001D9DCF868C0>
我們看到 byref 和 pointer 都可以創建指針,那么這兩者有什么區別呢?byref 返回的指針相當於右值,而 pointer 返回的指針相當於左值。舉個栗子:
// 以整型的指針為例:
int num = 123;
int *p = &num
對於上面的例子,如果是 byref,那么結果相當於 &num,拿到的就是一個具體的值。如果是 pointer,那么結果相當於 p。這兩者在傳遞的時候是沒有區別的,只是對於 pointer 來說,它返回的是一個左值,我們是可以繼續拿來做文章的。
from ctypes import *
n = c_int(123)
# 拿到變量 n 的指針
p1 = byref(n)
p2 = pointer(n)
# pointer 返回的是左值,我們可以繼續做文章,比如繼續獲取指針,此時獲取的就是 p2 的指針
print(byref(p2)) # <cparam 'P' (0000023953796888)>
# 但是 p1 不行,因為 byref 返回的是一個右值
try:
print(byref(p1))
except Exception as e:
print(e) # byref() argument must be a ctypes instance, not 'CArgObject'
因此兩者的區別就在這里,但是還是那句話,我們在傳遞的時候是無所謂的,傳遞哪一個都可以。
傳遞指針
我們知道了可以通過 ctypes.byref、ctypes.pointer 的方式傳遞指針,但是如果函數返回的也是指針呢?我們知道除了返回 int 之外,都要指定返回值類型,那么指針如何指定呢?答案是通過 ctypes.POINTER。
// 接收兩個 float *,返回一個 float *
float *test1(float *a, float *b)
{
// 因為返回指針,所以為了避免被銷毀,我們使用 static 靜態聲明
static float c;
c = *a + *b;
return &c;
}
from ctypes import *
lib = CDLL("./main.dll")
# 聲明一下,返回的類型是一個 POINTER(c_float),也就是 float 的指針類型
lib.test1.restype = POINTER(c_float)
# 別忘了傳遞指針,因為函數接收的是指針,兩種傳遞方式都可以
res = lib.test1(byref(c_float(3.14)), pointer(c_float(5.21)))
print(res) # <__main__.LP_c_float object at 0x000001FFF1F468C0>
print(type(res)) # <class '__main__.LP_c_float'>
# 這個 res 是 ctypes 類型,和 pointer(c_float(5.21)) 的類型是一樣的,都是 <class '__main__.LP_c_float'>
# 我們調用 contents 即可拿到 ctypes 中的值,那么顯然在此基礎上再調用 value 就能拿到 Python 中的值
print(res.contents) # c_float(8.350000381469727)
print(res.contents.value) # 8.350000381469727
因此我們看到了如果返回的是指針類型可以使用 POINTER(類型) 來聲明,也就是說 POINTER 是用來聲明指針類型的,而 byref、pointer 則是用來獲取指針的。
聲明類型
我們知道可以事先聲明返回值的類型,這樣才能拿到正確的返回值。而我們傳遞的時候,直接傳遞正確的類型即可,但是其實也是可以事先聲明的。
from ctypes import *
lib = CDLL("./main.dll")
# 通過 argtypes,我們可以事先指定需要傳入兩個 float 的指針類型,注意:要指定為一個元組,即便是一個參數也要是元組
lib.test1.argtypes = (POINTER(c_float), POINTER(c_float))
lib.test1.restype = POINTER(c_float)
# 但是和 restype 不同,argtypes 實際上是可以不要的
# 因為返回的默認是一個整型,我們才需要通過 restype 事先聲明返回值的類型,這是有必要的
# 但是對於 argtypes 來說,我們傳參的時候已經直接指定類型了,所以 argtypes 即便沒有也是可以的
# 所以 argtypes 的作用就類似於其他靜態語言中的類型聲明,先把類型定好,如果你傳的類型不對,直接給你報錯
try:
# 這里第二個參數傳c_int
res = lib.test1(byref(c_float(3.21)), c_int(123))
except Exception as e:
# 所以直接就給你報錯了
print(e) # argument 2: <class 'TypeError'>: expected LP_c_float instance instead of c_long
# 此時正確執行
res1 = lib.test1(byref(c_float(3.21)), byref(c_float(666)))
print(res1.contents.value) # 669.2100219726562
傳遞數組
下面我們來看看如何使用 ctypes 傳遞數組,這里我們只講傳遞,不講返回。因為 C 語言返回數組給 Python 實際上會存在很多問題,比如:返回的數組的內存由誰來管理,不用了之后空間由誰來釋放,事實上 ctypes 內部對於返回數組支持的也不是很好。因此我們一般不會向 Python 返回一個 C 語言中的數組,因為 C 語言中的數組傳遞給 Python 涉及到效率的問題,Python 中的列表傳遞直接傳遞一個引用即可,但是 C 語言中的數組過來肯定是要拷貝一份的,所以這里我們只講 Python 如何通過 ctypes 給動態鏈接庫傳遞數組,不再介紹動態鏈接庫如何返回數組給 Python。
from ctypes import *
# 創建一個數組,假設叫 [1, 2, 3, 4, 5]
a5 = (c_int * 5)(1, 2, 3, 4, 5)
print(a5) # <__main__.c_long_Array_5 object at 0x00000162428968C0>
# 上面這種方式就得到了一個數組
# 當然下面的方式也是可以的
a5 = (c_int * 5)(*range(1, 6))
print(a5) # <__main__.c_long_Array_5 object at 0x0000016242896940>
下面演示一下:
// 字符數組默認是以 \0 作為結束的,我們可以通過 strlen 來計算長度。
// 但是對於整型的數組來說我們不知道有多長
// 因此有兩種聲明參數的方式,一種是 int a[n],指定數組的長度
// 另一種是通過指定 int *a 的同時,再指定一個參數 int size,調用函數的時候告訴函數這個數組有多長
int test1(int a[5])
{
// 可能有人會問了,難道不能通過 sizeof 計算嗎?答案是不能,無論是 int *a 還是 int a[n]
// 數組作為函數的參數時會退化為指針,我們調用的時候,傳遞的都是指針,指針在 64 位機器上默認占 8 個字節。
// 所以int a[] = {...}這種形式,如果直接在當前函數中計算的話,那么 sizeof(a) 就是數組里面所有元素的總大小,因為a是一個數組名
// 但是當把 a 傳遞給一個函數的時候,那么等價於將 a 的首地址拷貝一份傳過去,此時在新的函數中再計算 sizeof(a) 的時候就是一個指針的大小
//至於 int *a 這種聲明方式,不管在什么地方,sizeof(a) 都是一個指針的大小
int i;
int sum = 0;
a[3] = 10;
a[4] = 20;
for (i = 0;i < 5; i++){
sum += a[i];
}
return sum;
}
from ctypes import *
lib = CDLL("./main.dll")
# 創建 5 個元素的數組,但是只給3個元素
arr = (c_int * 5)(1, 2, 3)
# 在動態鏈接庫中,設置剩余兩個元素
# 所以如果沒問題的話,結果應該是 1 + 2 + 3 + 10 + 20
print(lib.test1(arr)) # 36
傳遞結構體
有了前面的數據結構還不夠,我們還要看看結構體是如何傳遞的,有了結構體的傳遞,我們就能發揮更強大的功能。那么我們來看看如何使用 ctypes 定義一個結構體:
from ctypes import *
# 對於這樣一個結構體應該如何定義呢?
"""
struct Girl {
char *name; // 姓名
int age; // 年齡
char *gender; //性別
int class; //班級
};
"""
# 定義一個類,必須繼承自 ctypes.Structure
class Girl(Structure):
# 創建一個 _fields_ 變量,必須是這個名字,注意開始和結尾都只有一個下划線
# 然后就可以寫結構體的字段了,具體怎么寫估計一看就清晰了
_fields_ = [
("name", c_char_p),
("age", c_int),
("gender", c_char_p),
("class", c_int)
]
我們向 C 中傳遞一個結構體,然后再返回:
struct Girl {
char *name;
int age;
char *gender;
int class;
};
//接收一個結構體,返回一個結構體
struct Girl test1(struct Girl g){
g.name = "古明地覺";
g.age = 17;
g.gender = "female";
g.class = 2;
return g;
}
from ctypes import *
lib = CDLL("./main.dll")
class Girl(Structure):
_fields_ = [
("name", c_char_p),
("age", c_int),
("gender", c_char_p),
("class", c_int)
]
# 此時返回值類型就是一個 Girl 類型,另外我們這里的類型和 C 中結構體的名字不一樣也是可以的
lib.test1.restype = Girl
# 傳入一個實例,拿到返回值
g = Girl()
res = lib.test1(g)
print(res, type(res)) # <__main__.Girl object at 0x0000015423A06840> <class '__main__.Girl'>
print(res.name, str(res.name, encoding="utf-8")) # b'\xe5\x8f\xa4\xe6\x98\x8e\xe5\x9c\xb0\xe8\xa7\x89' 古明地覺
print(res.age) # 17
print(res.gender) # b'female'
print(getattr(res, "class")) # 2
如果是結構體指針呢?
struct Girl {
char *name;
int age;
char *gender;
int class;
};
// 接收一個指針,返回一個指針
struct Girl *test1(struct Girl *g){
g -> name = "mashiro";
g -> age = 17;
g -> gender = "female";
g -> class = 2;
return g;
}
from ctypes import *
lib = CDLL("./main.dll")
class Girl(Structure):
_fields_ = [
("name", c_char_p),
("age", c_int),
("gender", c_char_p),
("class", c_int)
]
# 此時指定為 Girl 類型的指針
lib.test1.restype = POINTER(Girl)
# 傳入一個實例,拿到返回值
# 但返回的是指針,我們還需要手動調用一個 contents 才可以拿到對應的值。
g = Girl()
res = lib.test1(byref(g))
print(str(res.contents.name, encoding="utf-8")) # mashiro
print(res.contents.age) # 16
print(res.contents.gender) # b'female'
print(getattr(res.contents, "class")) # 3
# 另外我們不僅可以通過返回的 res 去調用,還可以通過 g 來調用,因為我們傳遞的是 g 的指針
# 修改指針指向的內存就相當於修改g,所以我們通過g來調用也是可以的
print(str(g.name, encoding="utf-8")) # mashiro
因此對於結構體來說,我們先創建一個結構體(Girl)實例 g,如果動態鏈接庫的函數中接收的是結構體,那么直接把 g 傳進去等價於將 g 拷貝了一份,此時函數中進行任何修改都不會影響原來的 g。但如果函數中接收的是結構體指針,我們傳入 byref(g) 相當於把 g 的指針拷貝了一份,在函數中修改是會影響 g 的。而返回的 res 也是一個指針,所以我們除了通過 res.contents 來獲取結構體中的值之外,還可以通過 g 來獲取。再舉個栗子對比一下:
struct Num {
int x;
int y;
};
struct Num test1(struct Num n){
n.x += 1;
n.y += 1;
return n;
}
struct Num *test2(struct Num *n){
n->x += 1;
n->y += 1;
return n;
}
from ctypes import *
lib = CDLL("./main.dll")
class Num(Structure):
_fields_ = [
("x", c_int),
("y", c_int),
]
# 我們在創建的時候是可以傳遞參數的
num = Num(x=1, y=2)
print(num.x, num.y) # 1 2
lib.test1.restype = Num
res = lib.test1(num)
# 我們看到通過 res 得到的結果是修改之后的值
# 但是對於 num 來說沒有變
print(res.x, res.y) # 2 3
print(num.x, num.y) # 1 2
"""
因為我們將 num 傳進去之后,相當於將 num 拷貝了一份。
函數里面的結構體和這里的 num 盡管長得一樣,但是沒有任何關系
所以 res 獲取的結果是自增之后的結果,但是 num 還是之前的 num
"""
# 我們來試試傳遞指針,將 byref(num) 再傳進去
lib.test2.restype = POINTER(Num)
res = lib.test2(byref(num))
print(num.x, num.y) # 2 3
"""
我們看到將指針傳進去之后,相當於把 num 的指針拷貝了一份。
然后在函數中修改,相當於修改指針指向的內存,所以是會影響外面的 num 的
而動態鏈接庫的函數中返回的是參數中的結構體指針,而我們傳遞的 byref(num) 也是這里的num的指針
盡管傳遞指針的時候也是拷貝了一份,兩個指針本身來說雖然也沒有任何聯系,但是它們存儲的地址是一樣的
那么通過 res.contents 獲取到的內容就相當於是這里的 num
因此此時我們通過 res.contents 獲取和通過 num 來獲取都是一樣的。
"""
print(res.contents.x, res.contents.y) # 2 3
# 另外還需要注意的一點就是:如果傳遞的是指針,一定要先創建一個變量
# 比如這里,一定是:先要 num = Num(),然后再 byref(num),不可以直接就 byref(Num())
# 原因很簡單,因為 Num() 這種形式在創建完 Num 實例之后就銷毀了,因為沒有變量保存它,那么此時再修改指針指向的內存就會有問題,因為內存的值已經被回收了
# 如果不是指針,那么可以直接傳遞 Num(),因為拷貝了一份
所以在這里,C 中返回一個指針是沒有問題的,因為它指向的對象是我們在 Python 中創建的,Python 會管理它。
回調函數
在看回調函數之前,我們先看看如何把一個函數賦值給一個變量。准確的說,是讓一個指針指向一個函數,這個指針叫做函數指針。通常我們說的指針變量是指向一個整型、字符型或數組等等,而函數指針是指向函數。
#include <stdio.h>
int add(int a, int b){
int c;
c = a + b;
return c;
}
int main() {
// 創建一個指針變量 p,讓 add 等於 p
// 我們看到就類似聲明函數一樣,指定返回值類型和變量類型即可
// 但是注意的是,中間一定是 *p,不是 p,因為這是一個函數指針,所以要有 *
int (*p)(int, int) = add;
printf("1 + 3 = %d\n", p(1, 3)); //1 + 3 = 4
return 0;
}
除此之外我們還以使用 typedef。
#include <stdio.h>
int add(int a, int b){
int c;
c = a + b;
return c;
}
// 相當於創建了一個類型,名字叫做 func,這個 func 表示的是一個函數指針類型
typedef int (*func)(int, int);
int main() {
// 聲明一個 func 類型的函數指針 p,等於 add
func p = add;
printf("2 + 3 = %d\n", p(2, 3)); // 2 + 3 = 5
return 0;
}
下面來看看如何使用回調函數,說白了就是把一個函數指針作為函數的參數。
#include <stdio.h>
char *evaluate(int score){
if (score < 60 && score >= 0){
return "bad";
}else if (score < 80){
return "not bad";
}else if (score < 90){
return "good";
}else if (score <=100){
return "excellent";
}else {
return "無效的成績";
}
}
//接收一個整型和一個函數指針,指針指向的函數接收一個整型返回一個 char *
char *execute1(int score, char *(*f)(int)){
return f(score);
}
//除了上面那種方式,我們還可以跟之前一樣通過 typedef
typedef char *(*func)(int);
// 這樣聲明也是可以的。
char *execute2(int score, func f){
return f(score);
}
int main(int argc, char const *argv[]) {
printf("%s\n", execute1(88, evaluate)); // good
printf("%s\n", execute2(70, evaluate)); // not bad
}
我們知道了在 C 中傳入一個函數,那么在 Python 中如何定義一個 C 語言可以識別的函數呢?毫無疑問,類似於結構體,我們肯定是要先定義一個 Python 的函數,然后再把 Python 的函數轉化成 C 語言可以識別的函數。
int add(int a, int b, int (*f)(int *, int *)){
return f(&a, &b);
}
我們就以這個函數為例,add 函數返回一個 int,接收兩個 int,和一個函數指針,那么我們如何在 Python 中定義這樣的函數並傳遞呢?
from ctypes import *
lib = CDLL("./main.dll")
# 動態鏈接庫中的函數接收的函數的參數是兩個 int *,所以我們這里的 a 和 b 也是一個 pointer
def add(a, b):
return a.contents.value + b.contents.value
# 此時我們把 C 中的函數用 Python 表達了,但是這樣肯定是不可能直接傳遞的,能傳就見鬼了
# 那我們要如何轉化呢?
# 可以通過 ctypes 里面的函數 CFUNCTYPE 轉化一下,這個函數接收任意個參數
# 但是第一個參數是函數的返回值類型,然后函數的參數寫在后面,有多少寫多少。
# 比如這里的函數返回一個 int,接收兩個 int *,所以就是
t = CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
# 如果函數不需要返回值,那么寫一個 None 即可
# 然后得到一個類型 t,此時的類型 t 就等同於 C 中的 typedef int (*t)(int*, int*);
# 將我們的函數傳進去,就得到了 C 語言可以識別的函數 func
func = t(add)
# 然后調用,別忘了定義返回值類型,當然這里是 int 就無所謂了
lib.add.restype = c_int
print(lib.add(88, 96, func))
print(lib.add(59, 55, func))
print(lib.add(94, 105, func))
"""
184
114
199
"""
以上便是 ctypes 的基本用法,但其實我們可以通過 ctypes 玩出更高級的花樣,甚至可以串改內部的解釋器。ctypes 內部提供了一個屬性叫 pythonapi,它實際上就是加載了 Python 安裝目錄里面的 python38.dll。有興趣可以自己去了解一下,需要你了解底層的 Python / C API,當然我們也很少這么做。對於 ctypes 調用 C 庫而言,我們目前算是介紹完了。
使用 C / C++ 為 Python 開發擴展模塊
我們上面介紹 ctypes,我們說這種方式它不涉及任何的 Python / C API,但是它只能做一些簡單的交互。而如果是編寫擴展模塊的話,那么它是可以被 Python 解釋器識別的,也就是說我們可以通過 import 的方式進行導入。
關於擴展模塊,這里不得不再提一下 Cython,使用 Python / C API 編寫擴展不是一件輕松的事情,其實還是 C 語言本身比較底層吧。而 Cython 則是幫我們解決了這一點,Cython 代碼和 Python 高度相似,而 cython 編譯器會自動幫助我們將 Cython 代碼翻譯成C代碼,所以Cython本質上也是使用了 Python / C API。只不過它讓我們不需要直接面對C,只要我們編寫 Cython 代碼即可,會自動幫我們轉成 C 的代碼。
所以隨着 Cython 的出現,現在使用 Python / C API 編寫擴展算是越來越少了,不過話雖如此,使用 Python / C API 編寫可以極大的幫助我們熟悉 Python 的底層。
那么廢話不多說,直接開始吧。
編寫擴展模塊的基本骨架
首先使用 C / C++ 為 Python 編寫擴展的話,是需要遵循一定套路的,而這個套路很固定。那么下面就來介紹一下整個流程:
Python 的擴展模塊是需要被 import 進來的,那么它必然要有一個入口。
// 這個 xxx 非常重要,這個是你最終生成的擴展模塊的名字,前面的 PyInit 是寫死的
PyInit_xxx(void) // 模塊初始化入口
有了入口之后,我們還需要創建模塊,創建模塊使用下面這個函數。
PyModule_Create // 創建模塊
創建模塊,那么總要有模塊信息吧。
PyModuleDef // 模塊信息
那么模塊信息里面都可以包含哪些信息呢?模塊名算吧,模塊里面有哪些函數算吧。
PyMethodDef // 模塊函數信息, 一個數組, 因為一個模塊可以包含多個函數
而一個 Python 中的函數底層會對應一個結構體,這個結構體里面保存了 Python 函數的元信息,並且還保存了一個指向 C 函數的指針,這是顯然的。
我們通過一個例子來說明以下吧,這樣會更好理解一些,具體細節在編寫代碼的時候再補充。
def f1():
return 123
def f2(a):
return a + 1
以上是非常簡單的一個模塊,里面只有兩個簡單的函數,但是我們知道當被導入時它就是一個 PyModuleObject 對象。里面除了我們定義的兩個函數之外還有其它的屬性,顯然這是 Python 解釋器在背后幫助我們完成的,具體流程也是我們上面說的那幾步(省略了億點點細節)。
那么我們如何使用 C 來進行編寫呢?下面來操作一下。
/*
編寫 Python 擴展模塊,需要引入 Python.h 這個頭文件
該頭文件在 Python 安裝目錄的 include 目錄下,我們必須要導入它
當然這個頭文件里面還導入了很多其它的頭文件,我們也可以直接拿來用
*/
#include "Python.h"
/*
編寫我們之前的兩個函數 f1 和 f2,必須返回 PyObject *
函數里面至少要接收一個 PyObject *self,而這個參數我們是不需要管的,當然不叫 self 也是可以的
顯然跟方法里面的 self 是一個道理,所以對於 Python 調用者而言,f1 是一個不需要接收參數的函數
*/
static PyObject *
f1(PyObject *self) {
return PyLong_FromLong(123);
}
static PyObject *
f2(PyObject *self, PyObject *a) {
long x;
// 轉成 C 中的 long,進行相加,然后再轉成 Python 的 int; 或者調用 PyNumber_Add() 也可以
x = PyLong_AsLong(a);
PyObject *result = PyLong_FromLong(x + 1);
return result;
}
// 但是注意:雖然我們定義了 f1 和 f2,但是它們是 C 中的函數,不是 Python 的
// Python 中的函數在 C 中對應的是一個結構體,里面會有函數指針,指向這里的 f1 和 f2
// 但除了函數指針,還有其它的信息
/*
定義一個結構體數組,結構體類型為 PyMethodDef,顯然這個 PyMethodDef 就是 Python 中的函數
PyMethodDef 里面有四個成員,分別是:函數名、函數指針(需要轉成PyCFunction)、函數參數標識、函數的doc
關於 PyMethodDef 我們后面會單獨說
*/
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_NOARGS, // 后面單獨說
"this is a function named f1"
},
{"f2", (PyCFunction) f2, METH_O, "this is a function named f2"},
// 結尾要有一個 {NULL, NULL, 0, NULL} 充當哨兵
{NULL, NULL, 0, NULL}
};
/*
我們編寫的 py 文件,解釋器會自動把它變成一個模塊,但是這里我們需要手動定義
下面定義一個 PyModuleDef 類型的結構體,它就是我們的模塊信息
*/
static PyModuleDef module = {
// 頭部信息,PyModuleDef_Base m_base,正如所有對象都有 PyObject 這個結構體一樣
// 而 Python.h 中提供了一個宏,#define PyModuleDef_HEAD_INIT PyModuleDef_Base m_base; 我們可以使用 PyModuleDef_HEAD_INIT 來代替
PyModuleDef_HEAD_INIT,
"kagura_nana", // 模塊的名字
"this is a module named kagura_nana", // 模塊的doc,沒有的話直接寫成NULL即可
-1, // 模塊的獨立空間,這個不需要關心,直接寫成 -1 即可
methods, // 上面的 PyMethodDef 結構數組,必須寫在這里,不然我們沒法使用定義的函數
// 下面直接寫4個NULL即可
NULL, NULL, NULL, NULL
};
// 以上便是 PyModuleDef 結構體實例的創建過程,至於里面的一些細節我們后面說
// 到目前為止,前置工作就做完了,下面還差兩步
/*
擴展庫入口函數,這是一個宏,Python 的源代碼我們知道是使用 C 來編寫的
但是編譯的時候為了支持 C++ 的編譯器也能編譯,於是需要通過 extern "C" 定義函數
然后這樣 C++ 編譯器在編譯的的時候就會按照 C 的標准來編譯函數,這個宏就是干這件事情的,主要和 Python 中的函數保持一致
*/
PyMODINIT_FUNC
/*
模塊初始化入口,注意:模塊名叫 kagura_nana,那么下面就必須要寫成 PyInit_kagura_nana
*/
PyInit_kagura_nana(void)
{
// 將 PyModuleDef 結構體實例的指針傳遞進去,然后返回得到 Python 中的模塊
return PyModule_Create(&module);
}
整體邏輯還是非常簡單的,過程如下:
include "Python.h",這個是必須的
定義我們函數,具體定義什么函數、里面寫什么代碼完全取決於你的業務
定義一個PyMethodDef結構體數組
定義一個PyModuleDef結構體
定義模塊初始化入口,然后返回模塊對象
那么如何將這個 C 文件變成擴展模塊呢?顯然要經過編譯,而 Python 提供了 distutils 標准庫,可以非常輕松地幫我們把 C 文件編譯成擴展模塊。
from distutils.core import *
setup(
# 打包之后會有一個 egg_info,表示該模塊的元信息信息,name 就表示打包之后的 egg 文件名
# 顯然和模塊名是一致的
name="kagura_nana",
version="1.11", # 版本號
author="古明地盆",
author_email="66666@東方地靈殿.com",
# 關鍵來了,這里面接收一個類 Extension,類里面傳入兩個參數
# 第一個參數是我們的模塊名,必須和 PyInit_xxx 中的 xxx 保持一致,否則報錯
# 第二個參數是一個列表,表示用到了哪些 C 文件,因為擴展模塊對應的 C 文件不一定只有一個,我們這里的 C 文件還叫 main.c
ext_modules=[Extension("kagura_nana", ["main.c"])]
)
當前的 py 文件名叫做 1.py,我們在控制台中直接輸入 python 1.py install 即可。注意:在介紹 ctypes 我用的是 gcc,但這里默認是使用 Visual Studio 2017 進行編譯的。
我們看到對應的 pyd 已經生成了,在你當前目錄會有一個 build目錄,然后 build 目錄中 lib 開頭的目錄里面便存放了編譯好的 pyd文件,並且還自動幫我們拷貝到了 site-packages 目錄中。
我們看到了 kagura_nana.cp38-win_amd64.pyd 文件,中間的部分表示解釋器的版本,所以編寫擴展模塊的方式雖然可定制性更高,但它除了操作系統之外,還需要特定的解釋器版本。因為中間是 cp38,所以只能 Python3.8 版本的解釋器才可以導入它。然后還有一個 egg-info,它是我們編寫的模塊的元信息,我們打開看看。
有幾個我們沒有寫,所以是 UNKNOW,當然這都不重要,重要的是我們能不能調用,試一試吧。
import kagura_nana
print(kagura_nana) # <module 'kagura_nana' from 'C:\\python38\\lib\\site-packages\\kagura_nana.cp38-win_amd64.pyd'>
print(kagura_nana.f1()) # 123
print(kagura_nana.f2(123)) # 124
可以看到調用是沒有任何問題的,最后再看一個神奇的東西,我們知道在 pycharm 這樣的智能編輯器中,通過 Ctrl 加左鍵可以調到指定模塊的指定位置。
神奇的一幕出現了,我們點擊進去居然還能跳轉,其實我們在編譯成擴展模塊移動到 site-packages
之后,pycharm 會進行檢測、然后將其抽象成一個普通的 py 文件,方便你查看。我們看到模塊注釋、函數的注釋跟我們在 C 文件中指定的一樣。但是注意:該文件只是 pycharm 方便你查看函數注釋等信息而專門做的一個抽象,事實上你把這個文件刪掉也是沒有關系的。
因此我們可以再總結一下整體流程:
第一步:include "Python.h",必須要引入這個頭文件,這個頭文件中還引入了 C 中的一些頭文件,具體都引入了哪些庫我們可以查閱。當然如果不確定但又懶得看,我們還可以手動再引入一次,反正 include 同一個頭文件只會引入一次。
第二步:理論上這不是第二步,但是按照編寫代碼順序我們就認為它是第二步吧,對,就是按照我們上面寫的代碼從上往下擼。這一步你需要編寫函數,這個函數就是 C 語言中定義的函數,這個函數返回一個 PyObject * ,至少要接收一個PyObject *,我們一般叫它 self,這第一個參數你可以看成是必須的,無論我們傳不傳其他參數,這個參數是必需要有的。所以如果只有這一個參數,那么我們就認為這個函數不接收參數,因為我們在調用的時候沒有傳遞。
static PyObject *
f1(PyObject *self)
{
}
static PyObject *
f2(PyObject *self)
{
}
static PyObject *
f3(PyObject *self)
{
}
// 假設我們定義了這三個函數吧,三個函數都不接受參數
第三步:定義一個 PyMethodDef 類型的數組,這個數組也是我們后面的 PyModuleDef 對象中的一個參數,這個數組名字叫什么就無所謂了。至於 PyMethodDef,我們可以單獨使用 PyMethodDef 創建實例,然后將變量寫到數組中,也可以直接在數組中創建。如果是直接在數組中創建的話,那么就不需要再使用 PyMethodDef 定義了,直接在 {} 里面寫成員信息即可。
static PyMethodDef module_functions[] = {
{
// 暴露給 Python 的函數名
"f1",
// 函數指針,最好使用 PyCFunction 轉一下,可以確保不出問題。
// 如果不轉,我自己測試沒有問題,但是編譯時候會給警告,最好還是按照標准,把指針的類型轉換一下
// 轉換成 Python 底層識別的 PyCFunction
(PyCFunction) f1,
METH_NOARGS, // 參數類型,至於怎么接收 *args 和 **kwargs 的參數,后面說
"函數f1的注釋"
},
{"f2", (PyCFunction)f2, METH_NOARGS, "函數f2的注釋"},
{"f3", (PyCFunction)f3, METH_NOARGS, "函數f3的注釋"},
//別忘記,下面的 {NULL, NULL, 0, NULL},充當哨兵
{NULL, NULL, 0, NULL}
}
第四步:定義 PyModuleDef 對象,這個變量的名字叫什么也沒有要求。
static PyModuleDef m = {
PyModuleDef_HEAD_INIT, // 頭部信息
// 模塊名,這個是有講究的,你要編譯的擴展模塊叫啥,這里就寫啥
"kagura_nana",
"模塊的注釋",
-1, // 模塊的空間,這個是給子解釋器調用的,我們不需要關心,直接寫 -1 即可,表示不使用
module_functions, // 然后是我們上面定義的數組名,里面放了一大堆的 PyMethodDef 結構體實例
// 然后是四個 NULL,因為該結構還有其它成員,但我們不需要使用,所以指定 NULL 即可。當然有的編譯器比較智能,你若不指定自動為 NULL
// 但為了規范,我們還是手動寫上,因為規范的做法就是給每個成員都賦上值
NULL,
NULL,
NULL,
NULL
}
第五步:寫上一個宏,其實把它單獨拆分出來,有點小題大做了。
PyMODINIT_FUNC
// 一個宏,主要是保證函數按照 C 的標准,不用在意,寫上就行
第六步:創建一個模塊的入口函數,我們說編譯的擴展模塊叫 kagura_nana,那么這個函數名就要這么寫。
PyInit_kagura_nana(void)
{
// 會根據上面定義的 PyModuleDef 實例,得到 Python 中的模塊
// PyModule_Create 就是用來創建 Python 中的模塊的,直接將 PyModuleDef 定義的對象的指針扔進去
// 便可得到 Python 中的模塊,然后直接返回即可。
return PyModule_Create(&m);
}
第七步:定義一個py文件,假設叫 xx.py,那么在里面寫上如下內容,然后 python xx.py install 即可。
from distutils.core import *
setup(
# 這是生成的 egg 文件名,也是里面的元信息中的 Name
name="kagura_nana",
# 版本號
version="10.22",
# 作者
author="古明地覺",
# 作者郵箱
author_email="東方地靈殿",
# 當然還有其它參數,作為元信息來描述模塊,比如 description:模塊介紹。
# 有興趣的話可以看函數的注釋,或者根據已有的 egg 文件自己查看
# 下面是擴展模塊,Extension("yousa", ["C源文件"])
# 我們說 Extension 里面的第一個參數也必須是你的擴展模塊的名字,並且必須要和 PyInit_xxx 以及 PyModuleDef 中的第一個成員保持一致
# 至於第二個參數就是一個列表,你需要用到哪些 C 源文件。
# 而且我們看到這個 Extension 也在一個列表里面,因為我們也可以傳入多個 Extension 同時生成多個擴展模塊。
# 我們可以寫好一個生成一個,你也可以一次性寫多個,然后只編譯一次。
ext_modules=[Extension("hanser", ["a.c"])]
以上便是編寫擴展模塊的基本流程,但是里面還有很多細節沒有說。
PyMethodDef
首先是 PyMethodDef,我們說它對應的是 Python 中的函數,那么我們肯定要來看看它的定義,藏身於 Include/methodobject.h 中。
struct PyMethodDef {
/* 函數名 */
const char *ml_name;
/* 實現對應邏輯的 C 函數,但是需要轉成 PyCFunction 類型,主要是為了更好的處理關鍵字參數 */
PyCFunction ml_meth;
/* 參數類型
#define METH_VARARGS 0x0001 擴展位置參數,*args
#define METH_KEYWORDS 0x0002 擴展關鍵字參數,**kwargs
#define METH_NOARGS 0x0004 不需要參數
#define METH_O 0x0008 需要一個參數
#define METH_CLASS 0x0010 被 classmethod 裝飾
#define METH_STATIC 0x0020 被 staticmethod 裝飾
*/
int ml_flags;
//函數的 __doc__,沒有的話傳遞 NULL
const char *ml_doc;
};
typedef struct PyMethodDef PyMethodDef;
如果不需要參數,那么 ml_flags 傳入一個 METH_NOARGS;接收一個參數傳入 METH_O;所以我們上面的 f1 對應的 ml_flags 是 METHOD_NOARGS,f2 對應的 ml_flags 是 METH_O。
如果是多個參數,那么直接寫成 METH_VARAGRS 即可,也就是通過擴展位置參數的方式,但是這要如何解析呢?比如:有一個函數f3接收3個參數,這在C中要如何實現呢?別急我們后面會說。
引用計數和內存管理
我們在最開始的時候就說過,PyObject 貫穿了我們的始終。我們說這里面存放了引用計數和類型指針,並且 Python 中所有對象底層對應的結構體都嵌套了 PyObject,因此 Python 中的所有對象都有引用計數和類型。並且 Python 的對象在底層,都可以看成是 PyObject 的一個擴展,因此參數、返回值都是 PyObject *,至於具體類型則是通過里面的 ob_type 動態判斷。比如:之前使用的 PyLong_FromLong。
PyObject *
PyLong_FromLong(long ival)
{
PyLongObject *v;
// ...
return (PyObject *)v;
}
此外 Python 還專門定義了幾個宏,來看一下:
#define Py_REFCNT(ob) (((PyObject*)(ob))->ob_refcnt)
#define Py_TYPE(ob) (((PyObject*)(ob))->ob_type)
#define Py_SIZE(ob) (((PyVarObject*)(ob))->ob_size)
Py_REFCNT:拿到對象的引用計數;Py_TYPE:拿到對象的類型;Py_SIZE:拿到對象的ob_size,也就是變長對象里面的元素個數。除此之外,Python 還提供了兩個宏:Py_INCREF 和 Py_DECREF 來用於引用計數的增加和減少。
// 引用計數增加很簡單,就是找到 ob_refcnt,然后 ++
#define Py_INCREF(op) ( \
_Py_INC_REFTOTAL _Py_REF_DEBUG_COMMA \
((PyObject *)(op))->ob_refcnt++)
// 但是減少的話,做的事情稍微多一些
// 其實主要就是判斷引用計數是否為 0,如果為 0 直接調用 _Py_Dealloc 將對象銷毀
// _Py_Dealloc 也是一個宏,會調用對應類型對象的 tp_dealloc,也就是析構方法
#define Py_DECREF(op) \
do { \
PyObject *_py_decref_tmp = (PyObject *)(op); \
if (_Py_DEC_REFTOTAL _Py_REF_DEBUG_COMMA \
--(_py_decref_tmp)->ob_refcnt != 0) \
_Py_CHECK_REFCNT(_py_decref_tmp) \
else \
_Py_Dealloc(_py_decref_tmp); \
} while (0)
當然這些東西我們在系列的最開始的時候就已經說過了,但是接下來我們要引出一個非常關鍵的地方,就是內存管理。到目前為止我們沒有涉及到內存管理的操作,但我們知道 Python 中的對象都是申請在堆區的,這個是不會自動釋放的。舉個栗子:
static PyObject *
f(PyObject *self)
{
PyObject *s = PyUnicode_FromString("你好呀~~~");
// Py_None 就是 Python 中的 None, 同理還有 Py_True、Py_False,我們后面會繼續提
// 這里增加引用計數,至於為什么要增加,我們后面說
Py_INCREF(Py_None);
return Py_None;
}
這個函數不需要參數,如果我們寫一個死循環不停的調用這個函數,你會發現內存的占用蹭蹭的往上漲。就是因為這個 PyUnicodeObject 是申請在堆區的,此時內部的引用計數為 1。函數執行完畢變量 s 被銷毀了,但是 s 是一個指針,這個指針被銷毀了是不假,但是它指向的內存並沒有被銷毀。
static PyObject *
f(PyObject *self, PyObject *args, PyObject *kw)
{
PyObject *s = PyUnicode_FromString("hello~~~");
Py_DECREF(s);
Py_INCREF(Py_None);
return Py_None;
}
因此我們需要手動調用 Py_DECREF 這個宏,來將 s 指向的 PyUnicodeObject 的引用計數減 1,這樣引用計數就為 0 了。不過有一個特例,那就是當這個指針作為返回值的時候,我們不需要手動減去引用計數,因為會自動減。
static PyObject *
f(PyObject *self)
{
PyObject *s = PyUnicode_FromString("hello~~~");
// 如果我們把 s 給返回了,那么我們就不需要調用 Py_DECREF 了
// 因為一旦作為返回值,那么會自動減去 1
// 所以此時 C 中的對象是由 Python 來管理的,准確的說應該是作為返回值的指針指向的對象是由 Python 來管理的
return s;
// 所以在返回 Py_None 的時候,我們需要手動將引用計數加 1,因為它作為了返回值。
// 如果你不加 1,那么當你無限調用的時候,總會有那么一刻,Py_None 會被銷毀,因為它的引用計數在不斷減少
// 但當銷毀 Py_None 的時候,會出現 Fatal Python error: deallocating None,解釋器異常退出
}
不過這里還存在一個問題,那就是我們在 C 中返回的是 Python 傳過來的。
static PyObject *
f(PyObject *self, PyObject *val)
{
//傳遞過來一個 PyObject *,然后原封不動的返回
return val;
}
顯然上面 val 指向的內存不是在 C 中調用 api 創建的,而是 Python 創建然后傳遞過來的,也就是說這個 val 已經指向了一塊合法的內存(和增加 Py_None 引用計數類似)。但是內存中的對象的引用計數是沒有變化的,雖說有新的變量(這里的 val)指向它了,但是這個 val 是 C 中的變量不是 Python 中的變量,因此它的引用計數是沒有變化的。然后作為返回值返回之后,指向對象的引用計數減一。所以你會發現在 Python 中,創建一個變量,然后傳遞到 f 中,執行完之后再進行打印就會發生段錯誤,因為對應的內存已經被回收了。如果能正常打印,說明在 Python 中這個變量的引用計數不為 1,也可能是小整數對象池、或者有多個變量引用,那么就創建一個大整數或者其他的對象多調用幾次,因為作為返回值,每次調用引用計數都會減1。
static PyObject *
f(PyObject *self)
{
// 假設創建一個 PyListObject
PyObject *l1 = PyList_New(2);
// 將 l1 賦值給 l2,但是不好意思,這兩位老鐵指向的 PyListObject 的引用計數還是 1
PyObject *l2 = l1;
Py_INCREF(Py_None);
return Py_None;
}
因此我們說,如果在 C 中創建一個 PyObject 的話,那么它的引用計數會是 1,因為對象被初始化了,引用計數默認是 1。至於傳遞,無論你在 C 中將創建 PyObject * 賦值給了多少個變量,它們指向的 PyObject 的引用計數都會是 1。因為這些變量是 C 中的變量,不是 Python 中的。
因此我們的問題就很好解釋了,我們說當一個 PyObject * 作為返回值的時候,它指向的對象的引用計數會減去 1,那么當 Python 傳遞過來一個 PyObject * 指針的時候,由於它作為了返回值,因此調用之后會發現引用計數會減少了。因此當你在 Python 中調用擴展函數結束之后,這個變量指向的內存可能就被銷毀了。如果你在 Python 傳遞過來的指針沒有作為返回值,那么引用計數是不會發生變化的,但是一旦作為了返回值,引用計數會自動減 1,因此我們需要手動的加 1。
static PyObject *
f(PyObject *self, PyObject *val)
{
Py_INCREF(val);
return val;
}
因此我們可以得出如下結論:
如果在 C 中,創建一個 PyObject *var,並且 var 已經指向了合法的內存,比如調用 PyList_New、PyDict_New 等等 api 返回的 PyObject *,總之就是已經存在了 PyObject。那么如果 var 沒有作為返回值,我們必須手動地將 var 指向的對象的引用計數減 1,否則這個對象就會在堆區一直待着不會被回收。可能有人問,如果 PyObject *var2 = var,我將 var 再賦值給一個變量呢?那么只需要對一個變量進行 Py_DECREF 即可,當然對哪個變量都是一樣的,因為在 C 中變量的傳遞不會導致引用計數的增加。
如果 C 中創建的 PyObject * 作為返回值了,那么會自動將指向的對象的引用計數減 1,因此此時該指針指向的內存就由 Python 來管理了,就相當於在 Python 中創建了一個對象,我們不需要關心。
最后關鍵的一點,如果 C 中返回的指針指向的內存是 Python 中創建好的,假設我們在 Python 中創建了一個對象,然后把指針傳遞過來了,但是我們說這不會導致引用計數的增加,因為賦值的變量是 C 中的變量。如果 C 中用來接收參數的指針沒有作為返回值,那么引用計數在擴展函數調用之前是多少、調用之后還是多少。然而一旦作為了返回值,我們說引用計數會自動減 1,因此假設你在調用擴展函數之前引用計數是 3,那么調用之后你會發現引用計數變成了2。為了防止段錯誤,一旦作為返回值,我們需要在返回之前手動地將引用計數加1。
C中創建的:不作為返回值,引用計數手動減 1、作為返回值,不處理;Python 中創建傳遞過來的,不作為返回值,不處理、作為返回值,引用計數手動加 1。
而實現引用計數增加和減少所使用的宏就是 Py_INCREF 和 Py_DECREF,但它們要求傳遞的 PyObject * 不可以為 NULL。如果可能為 NULL 的話,那么建議使用 Py_XINCREF 和 Py_XDECREF。
參數的解析
我們說,PyMethodDef 內部有一個 ml_flags 屬性,表示此函數的參數類型,我們說有如下幾種:
1. 不接受參數,METH_NOARGS,對應函數格式如下:
static PyObject *
f(PyObject *self)
{
}
2. 接受一個參數,METH_O,對應函數格式如下:
static PyObject *
f(PyObject *self, PyObject *val)
{
}
3. 接受任意個位置參數,METH_VARARGS,對應函數格式如下:
static PyObject *
f(PyObject *self, PyObject *args)
{
}
4. 接受任意個位置參數和關鍵字參數,METH_VARARGS | METH_KEYWORDS,對應函數格式如下:
static PyObject *
f(PyObject *self, PyObject *args, PyObject *kwargs)
{
}
第一種和第二種顯然都很簡單,關鍵是第三種和第四種要怎么做呢?我們先來看看第三種,解析多個位置參數可以使用一個函數:PyArg_ParseTuple。
解析多個位置參數
函數原型:int PyArg_ParseTuple(PyObject *args, const char *format, ...); 位於 Python/getargs.c 中
所以重點就在 PyArg_ParseTuple 上面,我們注意到里面有一個 format,顯然類似於 printf,里面肯定是一些占位符,那么都支持哪些占位符呢?常用的如下:
i:接收一個 Python 中的 int,然后解析成 C 的 int
l:接收一個 Python 中的 int,然后將傳來的值解析成 C 的 long
f:接收一個 Python 中的 float,然后將傳來的值解析成 C 的 float
d:接收一個 Python 中的 float,然后將傳來的值解析成 C 的 double
s:接收一個 Python 中的 str,然后將傳來的值解析成 C 的 char *
u:接收一個 Python 中的 str,然后將傳來的值解析成 C 的 wchar_t *
O:接收一個 Python 中的 object,然后將傳來的值解析成 C 的 PyObject *
我們舉個栗子:
static PyObject *
f(PyObject *self, PyObject *args)
{
// 目前我們定義了一個 PyObject *args,如果是 METH_O,那么這個 args 就是對應的一個參數
// 如果 METH_VARAGRS,還是只需要定義一個 *args 即可,只不過此時的 *args 是一個 PyTupleObject,我們需要將多個參數解析出來
//假設此時我們這個函數是接收 3 個 int,然后相加
int a, b, c;
/*
下面我們需要使用 PyArg_ParseTuple 進行解析,因為我們接收三個參數
這個函數返回一個整型,如果失敗會返回 0,成功返回非 0
*/
if (!PyArg_ParseTuple(args, "iii", &a, &b, &c)){
// 失敗我們需要返回 NULL
return NULL;
}
return PyLong_FromLong(a + b + c);
}
我們還是編譯一下,當然編譯的過程我們就不顯示了,跟之前是一樣的。並且為了方便,我們的模塊名就不改了,但是編譯之后的 pyd 文件內容已經變了。不過需要注意的是,我們說編譯之后會有一個 build 目錄,然后會自動把里面的 pyd 文件拷貝到 site-packages 中,如果你修改了代碼,但是模塊名沒有變的話,那么編譯之后的文件名還和原來一樣。如果一樣的話,那么由於已經存在相同文件了,可能就不會再拷貝了。因此兩種做法:要么你把模塊名給改了,這樣編譯會生成新的模塊。要么編譯之前記得把上一次編譯生成的 build 目錄先刪掉,我們推薦第二種做法,不然 site-packages 目錄下會出現一大堆我們自己定義的模塊。
然后我們將 ml_flags 改成 METH_VARARGS,來測試一下。
#include "Python.h"
static PyObject *
f(PyObject *self, PyObject *args)
{
int a, b, c;
if (!PyArg_ParseTuple(args, "iii", &a, &b, &c)){
return NULL;
}
return PyLong_FromLong(a + b + c);
}
static PyMethodDef methods[] = {
{
"f",
(PyCFunction) f,
// 這里需要改成 METH_VARAGRS,這個地方很重要,因為它表示了函數的參數類型。如果這個地方不修改的話,Python 在調用函數時會發生段錯誤
METH_VARARGS,
"this is a function named f"
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
我們編譯成擴展模塊之后,來測試一下,但是注意,你在調用的時候 pycharm 可能會感到別扭。
因為在調用函數 f 的是給你飄黃了,原因就是我們上一次在生成 pyd 的時候,里面的函數是 f1 和 f2,並沒有 f。而我們 pycharm 會將 pyd 抽象成一個普通的 py 文件讓你查看,但同時它也是 pycharm 自動提示的依據。因為上一次 pycharm 已經抽象出來了這個文件,而里面沒有 f 這個函數,所以這里會飄黃。但是不用管,因為我們調用的是生成的 pyd 文件,跟 pycharm 抽象出來的 py 文件無關。
import kagura_nana
# 傳參不符合,自動給你報錯
try:
print(kagura_nana.f())
except TypeError as e:
print(e) # function takes exactly 3 arguments (0 given)
try:
print(kagura_nana.f(123))
except TypeError as e:
print(e) # function takes exactly 3 arguments (1 given)
try:
print(kagura_nana.f(123, "xxx", 123, 123))
except TypeError as e:
print(e) # function takes exactly 3 arguments (4 given)
try:
kagura_nana.f(123, 123.0, 123) # int: 123, long: 123, float: 123.000000, double: 123.000000
except TypeError as e:
print(e) # integer argument expected, got float
print(kagura_nana.f(123, 123, 123)) # 369
怎么樣,是不是很簡單呢?當然 PyArg_ParseTuple 解析失敗,Python 底層自動幫你報錯了,告訴你缺了幾個參數,或者哪個參數的類型錯了。
我們這里是以 i 進行演示的,至於其它的幾個占位符也是類似的。當然 O 比較特殊,因為它是轉成 PyObject *,所以此時我們是可以傳遞元組、列表、字典等任意高階對象的。而我們之前的 ctypes 則是不支持的,還是那句話,因為它沒有涉及任何 Python / C API 的調用,顯然數據的表達能力有限。
解析成 PyObject *
我們說 PyArg_ParseTuple 中的 i 代表 int、l 代表 long、f 代表 float、d 代表 double、s 代表 char*、u代表 wchar_t *,這些都比較簡單。我們重點是 O,其實 O 也不難,無非就是后續的一些 Python / C API 調用罷了。
我們還是以普通的 py 文件為例:
def foo(lst: list):
"""
假設我們傳遞一個列表, 然后返回一個元組, 並且將里面的元素都設置成元素的類型
:return:
"""
return tuple([type(item) for item in lst])
print(foo([1, 2, "3", {}])) # (<class 'int'>, <class 'int'>, <class 'str'>, <class 'dict'>)
如果使用 C 來編寫擴展的話,要怎么做呢?
#include "Python.h"
static PyObject *
foo(PyObject *self, PyObject *args)
{
PyObject *lst; // 首先我們這里要接收一個 PyObject *
// 我們要修改 lst,讓它指向我們傳遞的列表, 因此要傳遞一個二級指針進行修改
if (!PyArg_ParseTuple(args, "O", &lst)){
return NULL;
}
// 計算列表中的元素個數,申請同樣大小的元組。
// 其實還可以使用 PyList_Size,底層也是調用了 Py_SIZE,只是 PyList_Size 會進行類型檢測,同理還有 PyTuple_Size 等等
Py_ssize_t arg_count = Py_SIZE(lst);
// 申請完畢之后,里面的元素全部是 NULL,然后我們來進行設置
// 但是這里我們故意多申請一個,我們看看 NULL 在 Python 中的表現是什么
PyObject *tpl = PyTuple_New(arg_count + 1);
// 申明類型對象、以及元素
PyObject *type, *val;
for (int i = 0; i < arg_count; i++) {
val = PyList_GetItem(lst, i); // 獲取對應元素,賦值給 val
// 獲取對應的類型對象,但得到的是 PyTypeObject *,所以需要轉成 PyObject *
// 或者你使用 Py_TYPE 這個宏也可以,內部自動幫你轉了
type = (PyObject *)val -> ob_type;
//設置到元組中
PyTuple_SetItem(tpl, i, type);
}
return tpl;
}
static PyMethodDef methods[] = {
{
"foo",
(PyCFunction) foo,
// 記得這里寫上 METH_VARARGS, 假設我們寫的是 METH_NOARGS, 那么即便我們上面定義了參數也是沒有意義的
// 調用的時候 Python 會提示你: TypeError: foo() takes no arguments
METH_VARARGS,
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
然后使用 Python 測試一下:
import kagura_nana
print(
kagura_nana.foo([1, 2, "3", {}])
) # (<class 'int'>, <class 'int'>, <class 'str'>, <class 'dict'>, <NULL>)
# 我們看到得到結果是一致的,並且我們多申請了一個空間,但是沒有設置,所以結尾多了一個 <NULL>
# 但是注意:不要試圖通過 kagura_nana.foo([1, 2, "3", {}])[-1] 的方式來獲取這個 NULL,會造成段錯誤
# 因為 Python 操作指針會自動操作指針指向的內存,而 NULL 是一個空指針,指向的內存是非法的
# 另外段錯誤是一種非常可怕的錯誤,它造成的結果就是解釋器直接就異常退出了。
# 並且這不是異常捕獲能解決的問題,異常捕獲也是解釋器正常運行的前提下。因此申請容器的時候,要保證元數個數相匹配
從這里我們也能看出使用 C 來為 Python 寫擴展是一件多么麻煩的事情,因此 Cython 的出現是一個福音。當然我們上面的代碼只是演示,沒有太大意義,完全可以用 Python 實現。
傳遞字符串
然后我們再來看看字符串的傳遞,比較簡單,說白了這些都是 Python / C API 的調用。
#include "Python.h"
static PyObject *
f1(PyObject *self, PyObject *args)
{
// 這里我們接受任意個字符串,然后將它們拼接在一起,最后放在列表中返回。
// 由於是任意個,所以無法使用 PyArg_ParseTuple 了
// 因為我們不知道占位符要寫幾個 O,但我們說 args 是一個元組,那么我們可以按照元組的方式進行解析
Py_ssize_t arg_count = Py_SIZE(args); // 計算元組的長度
PyObject *res = PyUnicode_FromWideChar(L"", 0); // 返回值,因為包含中文,所以是寬字符
for (int i=0; i < arg_count; i++){
// 將 res 和 里面的字符串依次拼接,等價於字符串的加法
res = PyUnicode_Concat(res, PyTuple_GetItem(args, i));
}
// 我們上面這種做法比較笨,直接通過 PyUnicode_Join 直接拼接不香嗎?我們目前先這么做,join 的話在下面的 f2 函數中
// 然后創建一個列表,將結果放進去。我們申請列表,容量只需要為 1 即可
PyObject *lst = PyList_New(1);
PyList_SetItem(lst, 0, res);
// 我們說 lst 是在 C 中創建的, 但是它作為了返回值, 所以我們不需要關心它的引用計數, 因為會自動減一
// 那 res 怎么辦?它要不要減少引用計數,答案是不需要、也不能,因為它作為了容器的一個元素(這里面有很多細節,我們暫且不表,在后面介紹 PyDictObject 的時候再說)
return lst;
}
static PyObject *
f2(PyObject *self, PyObject *args)
{
// 這里還可以指定連接的字符,這里就直接返回吧
PyObject *res = PyUnicode_Join(PyUnicode_FromWideChar(L"||", 2), args);
return res;
}
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_VARARGS,
NULL
},
{
"f2",
(PyCFunction) f2,
METH_VARARGS,
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
Python 進行調用,看看結果。
import kagura_nana
print(kagura_nana.f1("哼哼", "嘿嘿", "哈哈")) # ['哼哼嘿嘿哈哈']
print(kagura_nana.f2("哼哼", "嘿嘿", "哈哈")) # 哼哼||嘿嘿||哈哈
我們看到結果是沒有問題的,還是蠻有趣的。
類型檢查和返回異常
在 Python 中,當我們傳遞的類型不對時會報錯。那么在底層我如何才能檢測傳遞過來的參數是不是想要的類型呢?首先我們想到的是通過 ob_type,假設我們要求 val 是一個 int,那么:
#include "Python.h"
static PyObject *
f1(PyObject *self, PyObject *val)
{
// 獲取類型名稱, 如果是字符串,那么 tp_name 就是 "str",字典是 "dict"
const char *tp_name = val -> ob_type -> tp_name;
char *res;
if (strcmp(tp_name, "int") == 0) {
res = "success";
} else {
res = "failure";
}
return PyUnicode_FromString(res);
}
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_O,
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
import kagura_nana
print(kagura_nana.f1(123)) # success
print(kagura_nana.f1("123")) # failure
以上是一種判斷方式,但是 Python 底層給我們提供了其它的 API 來進行判斷。比如:
判斷是否為整型: PyLong_Check
判斷是否為字符串: PyUnicode_Check
判斷是否為浮點型: PyFloat_Check
判斷是否為復數: PyComplex_Check
判斷是否為元組: PyTuple_Check
判斷是否為列表: PyList_Check
判斷是否為字典: PyDict_Check
判斷是否為集合: PySet_Check
判斷是否為字節串: PyBytes_Check
判斷是否為函數: PyFunction_Check
判斷是否為方法: PyMethod_Check
判斷是否為實例對象: PyInstance_Check
判斷是否為類(type的實例對象): PyType_Check
判斷是否為可迭代對象: PyIter_Check
判斷是否為數值: PyNumber_Check
判斷是否為序列(實現 __getitem__ 和 __len__): PySequence_Check
判斷是否為映射(必須實現 __getitem__、__len__ 和 __iter__): PyMapping_Check
判斷是否為模塊: PyModule_Check
寫法非常固定,因此我們上面的判斷邏輯就可以進行如下修改:
static PyObject *
f1(PyObject *self, PyObject *val)
{
char *res;
if (PyLong_Check(val)) {
res = "success";
} else {
res = "failure";
}
return PyUnicode_FromString(res);
}
這種寫法是不是就簡單多了呢?其它部分不需要動,然后你可以自己重新編譯、並測試一下,看看結果是不是一樣的。
然后問題來了,如果用戶傳遞的參數個數不對,或者類型不對,那么我們應該返回一個 TypeError,或者說返回一個異常。那么在 C 中,要如何設置異常呢?其實設置異常,說白了就是把輸出信息打印到 stderr 中,然后直接返回 NULL 即可。
#include "Python.h"
static PyObject *
f1(PyObject *self, PyObject *args)
{
Py_ssize_t arg_count = Py_SIZE(args);
if (arg_count != 3) {
// 這里是我們設置的異常, 其實參數個數不對的話, 我們可以借助於 PyArg_ParseTuple 來幫助我們
// 因為指定的占位符已經表明了參數的個數
PyErr_Format(PyExc_TypeError, ">>>>>> f1() takes 3 positional arguments but %d were given", arg_count);
}
// 然后我們要求第一個參數是整型, 第二個參數是字符串, 第三個參數是列表
PyObject *a, *b, *c;
// 因為參數一定是三個, 否則邏輯不會執行到這里, 因此我們不需要判斷了
PyArg_ParseTuple(args, "OOO", &a, &b, &c);
// 檢測
if (!PyLong_Check(a)) {
PyErr_Format(PyExc_ValueError, "The 1th argument requires a int, but got %s", Py_TYPE(a) -> tp_name);
}
if (!PyUnicode_Check(b)) {
PyErr_Format(PyExc_ValueError, "The 2th argument requires a str, but got %s", Py_TYPE(b) -> tp_name);
}
if (!PyList_Check(c)) {
PyErr_Format(PyExc_ValueError, "The 3th argument requires a list, but got %s", Py_TYPE(c) -> tp_name);
}
// 檢測成功之后, 我們將整數和字符串添加到列表中
PyList_Append(c, a);
PyList_Append(c, b);
// 這里我們將列表給返回, 而它是 Python 傳遞過來的, 所以一旦返回、引用計數會減一, 因此我們需要手動加一
Py_INCREF(c);
return c;
}
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_VARARGS,
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
所以邏輯就是像上面那樣,通過 PyErr_Format 來設置異常,這個會被 Python 端接收到,但是異常一旦設置,就必須要返回 NULL,否則會出現段錯誤。但反過來嗎,返回 NULL 的話則不一定要設置異常,但如果你不設置,那么 Python 底層會默認幫你設置一個 SystemError,並且異常的 value 信息為:<built-in function f1> returned NULL without setting an error,提示你返回了 NULL 但沒有設置 error。因為返回 NULL 表示程序需要終止了,那么就應該把為什么需要終止的理由告訴使用者。
然后我們來測試一下:
import kagura_nana
try:
kagura_nana.f1()
except Exception as e:
print(e) # >>>>>> f1() takes 3 positional arguments but 0 were given
try:
kagura_nana.f1(1, 2, 3, 4)
except Exception as e:
print(e) # >>>>>> f1() takes 3 positional arguments but 4 were given
try:
kagura_nana.f1(1, 2, 3)
except Exception as e:
print(e) # The 2th argument requires a str, but got int
lst = ["xx", "yy"]
print(kagura_nana.f1(123, "123", lst)) # ['xx', 'yy', 123, '123']
print(lst) # ['xx', 'yy', 123, '123']
所表現的一切,都和我們在底層設置的一樣。另外我們再來看看這個函數的身份是什么:
import kagura_nana
def foo(): pass
print(kagura_nana.f1) # <built-in function f1>
print(sum) # <built-in function sum>
print(foo) # <function foo at 0x000001F1BAAF61F0>
我們居然實現了一個內置函數,怎么樣是不是很神奇呢?因為擴展模塊里面的函數和解釋器內置的函數本質上都是一樣的,所以它們都是 built-in。
返回布爾類型和 None
我們說函數都必須返回一個 PyObject *,如果這個函數沒有返回值,那么在 Python 中實際上返回的是一個 None,但是我們不能返回 NULL,None 和 NULL 是兩碼事。在擴展函數中,如果返回 NULL 就表示這個函數執行的時候,不符合某個邏輯,我們需要終止掉,不能再執行下去了。這是在底層,但是在 Python 的層面,你需要告訴使用者為什么不能執行了,或者說底層的哪一行代碼不滿足條件,因此這個時候我們會在 return NULL 之前需要手動設置一個異常,這樣在 Python 代碼中才知道為什么底層函數退出了。當然有時候會自動幫我們設置,比如們說的 PyArg_ParseTuple。
那么在底層如何返回一個 None 呢?既然要返回我們就需要知道它的結構是什么。
# 首先在 Python 中,None 也是有類型的
print(type(None)) # <class 'NoneType'>
這個 NoneType 在底層對應的是 _PyNone_Type,至於 None 在底層對應的結構體是 _Py_NoneStruct,所以我們返回的時候應該返回這個結構體的指針。不過官方不推薦直接使用,而是給我們定義了一個宏,#define Py_None (&_Py_NoneStruct)
,我們直接返回 Py_None 即可。
不光是 None,我們說還有 True 和 False,True 和 False 對應的結構體是:_Py_FalseStruct,_Py_TrueStruct,它們本質上是 PyLongObject,Python 也不推薦直接返回,也是定義了兩個宏。
#define Py_False ((PyObject *) &_Py_FalseStruct)
#define Py_True ((PyObject *) &_Py_TrueStruct)
推薦我們使用 Py_False 和 Py_True。
另外:
return Py_None; 等價於 Py_RETURN_NONE;
return Py_True; 等價於 Py_RETURN_TRUE;
return Py_False; 等價於 Py_RETURN_FALSE;
可以自己測試一下,比如條件滿足返回 Py_True,不滿足返回 Py_False 等等。
傳遞關鍵字參數
我們上面的例子都是通過位置參數實現的,如果我們通過關鍵字參數傳遞呢?很明顯是會報錯的,因為我們參數名叫什么都不知道,所以上面的例子都不支持關鍵字參數。那么下面我們就來看看關鍵字參數要如何實現。
傳遞關鍵字參數的話,我們是通過 key=value 的方式來實現,那么在 C 中我們如何解析呢?既然支持關鍵字的方式,那么是不是也可以實現默認參數呢?答案是肯定的,我們知道解析位置參數是通過 PyArg_ParseTuple,而解析關鍵字參數是通過 PyArg_ParseTupleAndKeywords。
函數原型: int PyArg_ParseTupleAndKeywords(PyObject *args, PyObject *kw, const char *format, char *keywords[], ...)
我們看到相比原來的 PyArg_ParseTuple,多了一個 kw 和一個 char * 類型的數組,具體怎么用我們在編寫代碼的時候說。
#include "Python.h"
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
// 我們說函數既可以通過位置參數、還可以通過關鍵字參數傳遞,那么函數的參數類型就要變成 METH_VARARGS | METH_KEYWORDS
// 參數 args 就是 PyTupleObject 對象, kwargs 就是 PyDictObject 對象
// 假設我們定義了三個參數,name、age、place,這三個參數可以通過位置參數傳遞、也可以通過關鍵字參數傳遞
wchar_t *name;
int age = 17;
wchar_t *gender = L"FEMALE";
// 告訴 Python 解釋器參數的名字,注意:里面字符串的順序就是函數定義的參數順序
// 這里的字符串就是函數的參數名,上面的是變量名。其實變量名字叫什么無所謂,只是為了一致我們會起相同的名字
char *keys[] = {"name", "age", "gender", NULL};
// 注意結尾要有一個 NULL,否則會報出段錯誤。
// 解析參數,我們看到 format 中本來應該是 uiu 的,但是中間出現了一個 |
// 這就表示 | 后面的參數是可以不填的,如果不填會使用我們上面給出的默認值
// 因此這里 name 就是必填的,因為它在 | 的前面,而 age 和 gender 可以不填,如果不填就用我們上面給出的默認值
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "u|iu", keys, &name, &age, &gender)){
return NULL;
} // keys 就是函數的所以參數的名字,然后后面把指針傳進去,注意順序要和參數順序保持一致
wchar_t res[100];
swprintf(res, 100, L"name: %s, age: %d, gender: %s", name, age, gender);
return PyUnicode_FromWideChar(res, wcslen(res));
}
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_VARARGS | METH_KEYWORDS, // 注意這里, 因為支持位置參數和關鍵字參數, 所以是 METH_VARARGS | METH_KEYWORDS
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
用 Python 來測試一下。
import kagura_nana
try:
print(kagura_nana.f1())
except Exception as e:
print(e) # function missing required argument 'name' (pos 1)
try:
print(kagura_nana.f1(123))
except Exception as e:
print(e) # argument 1 must be str, not int
print(kagura_nana.f1("古明地覺")) # name: 古明地覺, age: 17, gender: FEMALE
print(kagura_nana.f1("古明地戀", 16)) # name: 古明地戀, age: 16, gender: FEMALE
print(kagura_nana.f1("古明地戀", 16, "女")) # name: 古明地戀, age: 16, gender: 女
我們看到一切都符合我們的預期,而且 PyArg_ParseTuple,和 PyArg_ParseTupleAndKeywords 可以自動幫我們檢測參數是否合法,不合法拋出合理的異常。當然你也可以檢測參數的個數,或者將參數一個一個獲取、用 PyXxx_Check 系列檢測函數進行判斷,看看是否符合預期,當然這么做就比較麻煩了。
PyArg_ParseTuple 和 PyArg_ParseTupleAndKeywords 里面的占位符還可以接收一些特殊的符號,我們舉個栗子。為了更好的說明,我們統一以 PyArg_ParseTupleAndKeywords 為例。
占位符 :
下面的是之前寫的 C 代碼,我們不做任何改動,來測試一下當參數傳遞錯誤時的報錯信息。
#include "Python.h"
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
wchar_t *name;
int age = 17;
wchar_t *gender = L"FEMALE";
char *keys[] = {"name", "age", "gender", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "u|iu", keys, &name, &age, &gender)){
return NULL;
}
wchar_t res[100];
swprintf(res, 100, L"name: %s, age: %d, gender: %s", name, age, gender);
return PyUnicode_FromWideChar(res,wcslen(res));
}
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_VARARGS | METH_KEYWORDS,
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
我們用 Python 來測試一下,注意觀察報錯信息。
import kagura_nana
try:
print(kagura_nana.f1())
except Exception as e:
print(e) # function missing required argument 'name' (pos 1)
try:
print(kagura_nana.f1("古明地覺", xxx=123))
except Exception as e:
print(e) # 'xxx' is an invalid keyword argument for this function
try:
print(kagura_nana.f1("古明地覺", name=123))
except Exception as e:
print(e) # argument for function given by name ('name') and position (1)
報錯信息似乎沒有什么特別的,但是注意了,我們來做一下改動。
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "u|iu:abcdefg", keys, &name, &age, &gender)){
return NULL;
}
其它地方都不變,我們只在 format 字符串的結尾加上了一個 :abcdefg
,然后編譯再來測試一下。
import kagura_nana
try:
print(kagura_nana.f1())
except Exception as e:
print(e) # abcdefg() missing required argument 'name' (pos 1)
try:
print(kagura_nana.f1("古明地覺", xxx=123))
except Exception as e:
print(e) # 'xxx' is an invalid keyword argument for abcdefg()
try:
print(kagura_nana.f1("古明地覺", name=123))
except Exception as e:
print(e) # argument for abcdefg() given by name ('name') and position (1)
你看到了什么?沒錯,默認的報錯信息使用的是 function,但我們通過在占位符中指定 :xxx
,可以將 function 變成我們指定的內容 xxx,一般和函數名保持一致。另外需要注意的是,:xxx
要出現在占位符的結尾,並且只能出現一次。如果這樣的話會變成什么樣子呢?
PyArg_ParseTupleAndKeywords(args, kwargs, "u:aaa|iu:abcdefg", keys, &name, &age, &gender)
顯然這變成了只接受一個參數,然后我們將參數不對時、返回報錯信息中的 function 換成了 aaa|iu:abcdefg
。並且你在傳遞參數的時候還會報出如下錯誤:
SystemError: More keyword list entries (3) than format specifiers (1)
因為占位符中相當於只有一個 u,也就是接收一個參數,但是我們后面跟了 &name、&age、&gender。關鍵字 entry 是 3,占位符是 1,兩者不匹配。因此 :xxx
一定要出現在最后面,並且只能出現一次。
另外,即使函數不接收參數我們也是可以這么做的,比如:
#include "Python.h"
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *keys[] = {NULL};
// 不接收參數
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "", keys)){
return NULL;
}
Py_INCREF(Py_None);
return Py_None;
}
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_VARARGS | METH_KEYWORDS,
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
import kagura_nana
try:
print(kagura_nana.f1("xxx"))
except Exception as e:
print(e) # function takes at most 0 arguments (1 given)
然后我們加上 :xxx
。
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *keys[] = {NULL};
// 這里還可以使用數字
if (!PyArg_ParseTupleAndKeywords(args, kwargs, ":123", keys)){
return NULL;
}
Py_INCREF(Py_None);
return Py_None;
}
import kagura_nana
try:
print(kagura_nana.f1("xxx"))
except Exception as e:
print(e) # 123() takes at most 0 arguments (1 given)
我們看到返回信息也被我們修改了,以上就是 :xxx
的作用。所以目前我們看到了兩個特殊符號,一個是 |
用來實現默認參數,一個是這里的 :
用來自定義報錯信息中的函數名。
占位符 !
我們說占位符 O 表示接收一個 Python 中的對象,但這個對象顯然是沒有限制的,可以是列表、可以是字典等等。我們之前是通過 Check 的方式進行檢測,但是 Python 底層為我們提供更簡便的做法,先來看一個常規的例子:
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *keys[] = {"val1", "val2", "val3", NULL};
PyObject *val1;
PyObject *val2;
PyObject *val3;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OOO", keys, &val1, &val2, &val3)){
return NULL;
}
Py_INCREF(Py_None);
return Py_None;
}
這個例子很簡單,就是接收三個 PyObject *,但如果我希望第一個參數的類型是浮點型,第三個參數的類型是字典,這個時候該怎么做呢?此時 ! 就派上用場了。
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *keys[] = {"val1", "val2", "val3", NULL};
PyObject *val1;
PyObject *val2;
PyObject *val3;
// 我們希望限制第一個參數和第三個參數的類型, 那么在它們的后面加上 ! 即可
// 但是注意: 一旦加上了 !, 那么 O! 就要對應兩個位置(分別是類型和變量, 當然都是指針)
// 我們說, 第一個參數是浮點型, 那么第一個 O! 對應 &PyFloat_Type, &val1
// 第二個參數沒有限制, 那么就是 &val2
// 第三個參數是字典, 那么最后一個 O! 對應 &PyDict_Type, &val3
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!OO!:my_func", keys,
&PyFloat_Type, &val1, &val2, &PyDict_Type, &val3)){
return NULL;
}
Py_INCREF(Py_None);
return Py_None;
}
然后其它地方不變,我們來編譯測試一下。
import kagura_nana
try:
print(kagura_nana.f1(123, 123, "xx"))
except Exception as e:
print(e) # my_func() argument 1 must be float, not int
try:
print(kagura_nana.f1(123.0, 11, "xx"))
except Exception as e:
print(e) # my_func() argument 3 must be dict, not str
這個功能就很方便了,可以讓我們更加輕松地限制參數類型。但如果你用過 Cython 的話,你會發現我這里所說的方便實在是不敢恭維。如果你要寫擴展,那么我強烈推薦 Cython,而且用 Cython 可以輕松的連接 C / C++。
注意:! 只能跟在 O 的后面。
占位符 &
& 的話,對於我們編寫擴展而言用的不是很多,首先 & 和 上面說的 ! 用法類似,並且都只能跟在 O 的后面。O! 的話,我們說會對應一個類型指針和一個 PyObject *(參數就會傳遞給它),會判斷傳遞的參數的類型是否和指定的類型一致。但 O& 的話,則是對應一個函數(convert)和一個任意類型的指針(address),會執行 convert(object, address)
,這個 object 就是我們傳遞過來的參數。我們舉個栗子:
void convert(PyObject *object, long *any){
// 將 object 轉成 long, 賦值給 *any
*any = PyLong_AsLong(object);
}
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *keys[] = {"val1", NULL};
long any = 0;
// 我們傳遞一個 Python 中的整數(假設為 PyObject *val1), 那么這里就會執行 convert(val1, &any)
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O&", keys,
convert, &any)){
return NULL;
}
// 執行完畢之后, any 就會被改變, 為了方便我們就直接打印一下吧, 順便加一個 1
printf("any = %ld\n", any + 1);
Py_INCREF(Py_None);
return Py_None;
}
我們來測試一下:
print(kagura_nana.f1(123))
"""
any = 124
None
"""
效果大概就是這樣,個人覺得對於我們編寫擴展而言用處不是很大,了解一下即可。
占位符 ;
占位符 ;
和 :
比較類似,但 ;
更加粗暴。至於怎么個粗暴法,看個栗子就一目了然了。
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *keys[] = {"val1", NULL};
PyObject *val1;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!;my name is van, i am a artist, a performance artist", keys,
&PyFloat_Type, &val1)){
return NULL;
}
Py_INCREF(Py_None);
return Py_None;
}
然后我們來調用試試,看看會有什么結果:
import kagura_nana
try:
print(kagura_nana.f1())
except Exception as e:
print(e) # function missing required argument 'val1' (pos 1)
try:
print(kagura_nana.f1(123, 123))
except Exception as e:
print(e) # function takes at most 1 argument (2 given)
目前來看的話,似乎一切正常,但是往下看:
此時把整個報錯信息都給修改了,因此這個符號也不是很常用。
注意:
;
同樣需要放到結尾,並且和:
相互排斥,兩者不可同時出現。
占位符 $
老規矩,還是先來看一個常規的例子。
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *keys[] = {"val1", "val2", "val3", NULL};
PyObject *val1;
PyObject *val2;
PyObject *val3;
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OOO", keys,
&val1, &val2, &val3)){
return NULL;
}
Py_INCREF(Py_None);
return Py_None;
}
import kagura_nana
print(kagura_nana.f1(123, 123, 123))
print(kagura_nana.f1(123, val2=123, val3=123))
print(kagura_nana.f1(123, 123, val3=123))
print(kagura_nana.f1(val1=123, val2=123, val3=123))
以上都是沒有問題的,可以通過位置參數傳遞、也可以通過關鍵字參數傳遞,只要位置參數在關鍵字參數之前即可。但如果我們希望某個參數只能通過關鍵字的方式傳遞呢?
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
char *keys[] = {"val1", "val2", "val3", NULL};
PyObject *val1;
PyObject *val2;
PyObject *val3;
// 指定一個 $, 那么 $ 后面只能通過關鍵字參數的方式傳遞
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "OO$O", keys,
&val1, &val2, &val3)){
return NULL;
}
Py_INCREF(Py_None);
return Py_None;
}
重新編譯然后測試:
import kagura_nana
print(kagura_nana.f1(123, val2=123, val3=123))
print(kagura_nana.f1(123, 123, val3=123))
print(kagura_nana.f1(val1=123, val2=123, val3=123))
# 以上仍然是正常的, 都會打印 None
# 但是下面不行了, 因為 val3 必須通過關鍵字參數的方式傳遞
try:
kagura_nana.f1(123, 123, 123)
except Exception as e:
print(e) # function takes exactly 2 positional arguments (3 given)
# 其實這就等價於如下:
def f1(val1, val2, *, val3):
return None
不過有一點需要注意,目前來說,如果 |
和 $
同時出現的話,那么 |
必須要在 $
的前面。所以如果既有僅限關鍵字參數、又有可選參數,那么僅限關鍵字參數必須同時也是可選參數,所以 |
要在 $
的前面。如果我們把 |
寫在了 $
的后面,那么執行會拋異常。
並且,即便僅限關鍵字參數和默認參數相同,那也應該這么寫 OO|$O
,而不能這么寫 OO$|O
。
占位符 #
這個 # 不可以跟在 O 后面,它是跟在 s 或者 u 后面,用來限制長度,有興趣自己去了解一下。
Py_BuildValue
下面介紹一個非常方便的函數 Py_BuildValue,專門用來對數據進行打包的,返回一個 PyObject *,同樣是通過占位符的方式。
Py_BuildValue 的占位符和 PyArg_ParseTuple 里面的占位符是一致的,只不過功能相反。比如:i,PyArg_ParseTuple 是將 Python 中的 int 轉成 C 中的 int,而 Py_BuildValue 是將 C 中的 int 打包成 Python 中的 int。所以它們的占位符一致,功能正好相反,並且我們在介紹 PyArg_ParseTuple 的時候只介紹一部分占位符,其實支持的占位符不止我們上面說的那些,下面就來羅列一下。
再重復一次,PyArg_ParseTuple 和 Py_BuildValue 的占位符是一致的,但是功能相反。
我們只接用官方的栗子,因為官方給的栗子非常直觀。
Py_BuildValue("") None
Py_BuildValue("i", 123) 123
Py_BuildValue("iii", 123, 456, 789) (123, 456, 789)
Py_BuildValue("s", "hello") 'hello'
Py_BuildValue("y", "hello") b'hello'
Py_BuildValue("ss", "hello", "world") ('hello', 'world')
Py_BuildValue("s#", "hello", 4) 'hell'
Py_BuildValue("y#", "hello", 4) b'hell'
Py_BuildValue("()") ()
Py_BuildValue("(i)", 123) (123,)
Py_BuildValue("(ii)", 123, 456) (123, 456)
Py_BuildValue("(i,i)", 123, 456) (123, 456)
Py_BuildValue("[i,i]", 123, 456) [123, 456]
Py_BuildValue("{s:i,s:i}", "abc", 123, "def", 456) {'abc': 123, 'def': 456}
Py_BuildValue("((ii)(ii)) (ii)", 1, 2, 3, 4, 5, 6) (((1, 2), (3, 4)), (5, 6))
如果是多個符號,自動會變成一個元組。我們來測試一下:
#include "Python.h"
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
PyObject *lst = PyList_New(5);
PyList_SetItem(lst, 0,
Py_BuildValue("i", 123));
PyList_SetItem(lst, 1,
Py_BuildValue("is", 123, "hello matsuri"));
PyList_SetItem(lst, 2,
Py_BuildValue("[i, i]", 123, 321));
PyList_SetItem(lst, 3,
Py_BuildValue("(s)s", "hello", "matsuri"));
PyList_SetItem(lst, 4,
Py_BuildValue("{s: s}", "hello", "matsuri"));
return lst;
}
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_VARARGS | METH_KEYWORDS,
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
from pprint import pprint
import kagura_nana
pprint(kagura_nana.f1())
"""
[123,
(123, 'hello matsuri'),
[123, 321],
(('hello',), 'matsuri'),
{'hello': 'matsuri'}]
"""
我們看到結果是符合我們的預期的,另外除了 Py_BuildValue 之外,還有一個 PyTuple_Pack,這兩者是類似的,只不過后者只接收 PyObject *,舉個栗子就很清晰了:
Py_BuildValue("OO", a, b) 等價於 PyTuple_Pack(2, a, b)
這個是固定打包成元組,而且第一個參數是個數,不是 format,因此它不支持通過占位符來指定元素類型,而是只接收 PyObject *。
操作 PyDictObject
Python 中的字典在底層要如何讀取、如何設置,這個我們必須要好好地說一說。像整型、浮點型、字符串、元組、列表、集合,它們都比較簡單,我們就不詳細說了。比如列表:Python 中插入元素是調用 insert,那么底層則是 PyList_Insert;追加元素是 append,那么底層則是 PyList_Append;設置元素是 __setitem__,那么底層則是 PyList_SetItem;同理獲取元素是 PyList_GetItem,寫法非常具有規范性。所以如果不知道某個 API 的話,可以去查看解釋的源碼,比如你想查看元組,那么就去 Include/tupleobject.h 中查看:
像這些凡是以 PyAPI 開頭的都是可以直接用的,PyAPI_DATA 表示數據,PyAPI_FUNC 表示函數,至於它們的含義是什么,我們可以通過文檔查看。在 Python 的安裝目錄的 Doc 目錄下就有,點擊通過關鍵字進行檢索即可。當然基本數據類型的一些方法,相信通過函數名即可判斷,比如:PyTuple_GetItem,很明顯就是通過索引獲取元素的。還是那句話,Python 解釋器的整個工程,在命名方面都非常有規律。
所以我們的重點是字典的使用,因為字典比較特殊,它里面的鍵值對的形式,而列表、元組等容器里面的元素是單一獨立的。
PyDictObject 的讀取
先來介紹內部關於讀取的一些 API:
PyDict_Contains(dic, key):判斷字典中是否具有某個 key
PyDict_GetItem(dic, key):獲取字典中某個 key 對應的 value
PyDict_GetItemString(dic, key):和 PyDict_GetItem 作用相同,但這里的 key 是一個 char *
PyDict_Keys(dic):獲取所有的 key
PyDict_Values(dic):獲取所有的 value
PyDict_Items(dic):獲取所有的 key-value
下面我們來操作一波:
#include "Python.h"
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
PyObject *dic;
char *keys[] = {"dic", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!", keys, &PyDict_Type, &dic)){
return NULL;
}
PyObject *res; // 返回值
// 1. 檢查是否包含 "name" 這個 key
PyObject *name = PyUnicode_FromString("name");
if (!PyDict_Contains(dic, name)){
res = PyUnicode_FromString("key `name` does not exists");
} else {
res = PyDict_GetItem(dic, name);
// 注意:這一步很關鍵,因為我們下面返回了 res,而這個 res 是從 Python 傳遞過來的字典中獲取的
// 因此它的引用計數不會加 1,只是指向了某個已存在的空間,因此返回之前我們需要將引用計數加 1
// 至於 if 里面的 res,因為它是在 C 中創建了新的空間,所以不需要關心
Py_INCREF(res);
}
// 此時我們能直接返回 res 嗎? 很明顯是不能的,因為我們上面還創建了一個 Python 的字符串 name
// 這是在 C 中創建的,並且也沒作為返回值,那么我們就必須要手動將其引用計數減 1
// 因此這種時候更推薦使用 PyDict_GetItemString,它接收一個 C 字符串,函數結束時自動釋放
// 但是很明顯這個函數局限性比較大
Py_DECREF(name);
return res;
}
static PyMethodDef methods[] = {
{
"f1",
(PyCFunction) f1,
METH_VARARGS | METH_KEYWORDS,
NULL
},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named kagura_nana",
-1,
methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void)
{
return PyModule_Create(&module);
}
import kagura_nana
try:
print(kagura_nana.f1(""))
except Exception as e:
print(e) # argument 1 must be dict, not str
print(kagura_nana.f1({})) # key `name` does not exists
print(kagura_nana.f1({"name": "古明地覺"})) # 古明地覺
PyDictObject 的遍歷
首先我們說可以通過 PyDict_Keys、PyDict_Values、PyDict_Items 來進行遍歷,下面演示一下。
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
PyObject *dic;
char *keys[] = {"dic", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!", keys, &PyDict_Type, &dic)){
return NULL;
}
PyObject *res = PyList_New(3); // 返回值
PyList_SetItem(res, 0, PyDict_Keys(dic));
PyList_SetItem(res, 1, PyDict_Values(dic));
PyList_SetItem(res, 2, PyDict_Items(dic));
return res;
}
import kagura_nana
print(kagura_nana.f1({"name": "satori", "age": 17}))
"""
[['name', 'age'],
['satori', 17],
[('name', 'satori'), ('age', 17)]]
"""
而且我們看到 PyDict_Keys 等函數返回的是列表,這說明創建了一個新的空間,引用計數為 1。但我們沒有調用 Py_DECREF,這是因為我們將其放在了一個新的列表中,如果作為某個容器的元素,那么引用計數也應該要增加。但對於 PyListObject、PyTupleObject 而言,通過 PyList_SetItem、PyTuple_SetItem 是不會增加指向對象的引用計數的,所以結果正好抵消,我們不需要對引用計數做任何處理。
但如果我們是通過 PyList_Append 進行追加、或者 PyList_Insert 進行插入的話,那么是會增加引用計數的,這樣引用計數就增加了 2,因此我們還需要減去 1。所以這一點比較煩人,因為你光知道何時增加引用計數、何時減少引用計數還是不夠的,你還要看某一個操作到底有沒有增加、或者減少。就拿我們這里設置元素為例,本來作為容器內的一個元素,理論上是要增加引用計數的,但是結果卻沒有增加。而添加和插入元素,也是作為容器的一個元素,但是這兩個操作卻增加了。所以還是推薦 Cython,再度安利一波,寫擴展用 Cython 真的非常香。
這里我們將元素都獲取出來了,至於遍歷也很簡單,這里不測試了。
PyDictObject 的設置和刪除
PyDict_SetItem(dic, key, value):設置元素
PyDict_DelItem(dic, key, value):刪除元素
PyDict_Clear(dic):清空字典
static PyObject *
f1(PyObject *self, PyObject *args, PyObject *kwargs)
{
PyObject *dic;
char *keys[] = {"dic", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwargs, "O!", keys, &PyDict_Type, &dic)){
return NULL;
}
// 設置一個 "name": "satori"
PyObject *key = PyUnicode_FromString("name");
PyObject *value = PyUnicode_FromString("satori");
PyDict_SetItem(dic, key, value);
// 因為 key 和 value 是 C 中創建的,首先引用計數為 1
// 然后它們又放到了字典里,對於字典而言,設置元素是會增加引用計數的,所以這里引用計數變成了 2
// 因此我們需要手動將它們的引用計數減去 1,否則這個鍵值對永遠不會被回收。
// 所以最讓人煩的就是這個引用計數,非常的討厭,因為你不知道它到底有沒有增加
Py_XDECREF(key);
Py_XDECREF(value);
// 如果有 "age" 這個 key 就將其刪掉
key = PyUnicode_FromString("age");
if (PyDict_Contains(dic, key)) {
PyDict_DelItem(dic, key);
}
Py_XDECREF(key); // 同樣減少引用計數
Py_INCREF(Py_None);
return Py_None;
}
測試一下:
import kagura_nana
dic = {"name": "mashiro", "age": 17}
kagura_nana.f1(dic)
print(dic) # {'name': 'satori'}
當然還有很多其它 API,可以查看源代碼(Include/dictobject.h)自己測試一下。
編寫擴展類
我們之前在 C 中編寫的都是函數,但光有函數顯然是不夠的,我們需要實現類。而在 C 中實現的類被稱為擴展類,它和 Python 內置的類(int、dict、str等等)是等價的,都屬於靜態類,直接指向了 C 一級的數據結構。
下面來看看在 C 中如何實現擴展類,首先我們來實現一個最基本的擴展類,也就是只包含一些最關鍵的部分。然后再添加類參數、方法,以及繼承等等。
當然最重要的一點,我們還要解決類的循環引用、以及自定義垃圾回收。像列表、元組、字典等容器,它們也都會發生循環引用。
前面有一點我們沒有提,當一個容器(比如列表)引用計數減一的時候,里面的元素(指向的對象)的引用計數是不會發生改變的。只有當一個容器的引用計數為 0 被銷毀的時候,在銷毀之前會先將內部元素的引用計數都減 1,然后再銷毀這個容器。
而循環引用是引用計數機制所面臨的最大的痛點,所以 Python 中的 gc 就是來干這個事情的,通過分代技術根據對象的生命周期划分為三個鏈表,然后通過三色標記模型來找出那些具有循環引用的對象,改變它們的引用計數。所以在 Python 中一個對象是否要被回收,最終還是取決於它的引用計數是否為 0。如果是 Python 代碼的話,我們在實現類的時候,解釋器會自動幫我們處理這一點,但我們是做類擴展,因此這些東西就必須由我們來考慮了。
編寫擴展類前奏曲
我們之前編寫了擴展函數,我們說首先要創建一個模塊,這里也是一樣的,因為類也要在模塊里面。編寫函數是有套路的,編寫類也是一樣,我們還是先看看大致的流程,具體細節會在慢慢補充。
首先我們需要了解以下內容:
1. 一個類要有類名、構造函數、析構函數
2. 所有的類在底層都是一個 PyTypeObject 實例,而且類也是一個對象
3. PyType_Ready 對類進行初始化,主要是進行屬性字典的設置
4. PyModule_AddObject,將擴展類添加到模塊中
那么一個類在底層都有哪些屬性呢?很明顯,我們說所有的類都是一個 PyTypeObject 實例,那么我們就把這個結構體拷貝出來看一下就知道了。
// 下面我們來介紹一下內部成員都代表什么含義
typedef struct _typeobject {
// 頭部信息,PyVarObject ob_base; 里面包含了引用計數、類型、ob_size
// 而創建這個結構體實例的話,Python 提供了一個宏,PyVarObject_HEAD_INIT(type, size)
// 傳入類型和大小可以直接創建,至於引用計數則默認為 1
PyObject_VAR_HEAD
// 創建之后的類名
const char *tp_name; /* For printing, in format "<module>.<name>" */
// 大小,用於申請空間的,注意了,這里是兩個成員
Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */
/* Methods to implement standard operations */
// 析構方法__del__,當刪除實例對象時會調用這個操作
// typedef void (*destructor)(PyObject *); 函數接收一個PyObject *,沒有返回值
destructor tp_dealloc;
// 打印其實例對象是調用的函數
// typedef int (*printfunc)(PyObject *, FILE *, int); 函數接收一個PyObject *、FILE * 和 int
printfunc tp_print;
// 獲取屬性,內部的 __getattr__ 方法
// typedef PyObject *(*getattrfunc)(PyObject *, char *);
getattrfunc tp_getattr;
// 設置屬性,內部的 __setattr__ 方法
// typedef int (*setattrfunc)(PyObject *, char *, PyObject *);
setattrfunc tp_setattr;
// 在 Python3.5之后才產生的,這個不需要關注。
// 並且在其它類的注釋中,這個寫的都是tp_reserved
PyAsyncMethods *tp_as_async; /* formerly known as tp_compare (Python 2)
or tp_reserved (Python 3) */
// 內部的 __repr__方法
// typedef PyObject *(*reprfunc)(PyObject *);
reprfunc tp_repr;
// 一個對象作為數值所有擁有的方法
PyNumberMethods *tp_as_number;
// 一個對象作為序列所有擁有的方法
PySequenceMethods *tp_as_sequence;
// 一個對象作為映射所有擁有的方法
PyMappingMethods *tp_as_mapping;
/* More standard operations (here for binary compatibility) */
//內部的 __hash__ 方法
// typedef Py_hash_t (*hashfunc)(PyObject *);
hashfunc tp_hash;
// 內部的 __call__ 方法
// typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *);
ternaryfunc tp_call;
// 內部的 __repr__ 方法
// typedef PyObject *(*reprfunc)(PyObject *);
reprfunc tp_str;
// 獲取屬性
// typedef PyObject *(*getattrofunc)(PyObject *, PyObject *);
getattrofunc tp_getattro;
// 設置屬性
// typedef int (*setattrofunc)(PyObject *, PyObject *, PyObject *);
setattrofunc tp_setattro;
//作為緩存,不需要關心
/*
typedef struct {
getbufferproc bf_getbuffer;
releasebufferproc bf_releasebuffer;
} PyBufferProcs;
*/
PyBufferProcs *tp_as_buffer;
// 這個類的特點,比如:
// Py_TPFLAGS_HEAPTYPE: 是否在堆區申請空間
// Py_TPFLAGS_BASETYPE: 是否允許這個類被其它類繼承
// Py_TPFLAGS_IS_ABSTRACT: 是否為抽象類
// Py_TPFLAGS_HAVE_GC: 是否被垃圾回收跟蹤
// 這里面有很多,具體可以去 object.h 中查看
// 一般我們設置成 Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC 即可
unsigned long tp_flags;
// 這個類的注釋
const char *tp_doc; /* Documentation string */
//用於檢測是否出現循環引用,和下面的tp_clear是一組
/*
class A:
pass
a = A()
a.attr = a
此時就會出現循環引用
*/
// typedef int (*traverseproc)(PyObject *, visitproc, void *);
traverseproc tp_traverse;
// 刪除對包含對象的引用
inquiry tp_clear;
// 富比較
// typedef PyObject *(*richcmpfunc) (PyObject *, PyObject *, int);
richcmpfunc tp_richcompare;
// 弱引用,不需要關心
Py_ssize_t tp_weaklistoffset;
// __iter__方法
// typedef PyObject *(*getiterfunc) (PyObject *);
getiterfunc tp_iter;
// __next__方法
// typedef PyObject *(*iternextfunc) (PyObject *);
iternextfunc tp_iternext;
/* Attribute descriptor and subclassing stuff */
// 內部的方法,這個 PyMethodDef 不陌生了吧
struct PyMethodDef *tp_methods;
// 內部的成員
struct PyMemberDef *tp_members;
// 一個結構體,包含了 name、get、set、doc、closure
struct PyGetSetDef *tp_getset;
// 繼承的基類
struct _typeobject *tp_base;
// 內部的屬性字典
PyObject *tp_dict;
// 描述符,__get__ 方法
// typedef PyObject *(*descrgetfunc) (PyObject *, PyObject *, PyObject *);
descrgetfunc tp_descr_get;
// 描述符,__set__ 方法
// typedef int (*descrsetfunc) (PyObject *, PyObject *, PyObject *);
descrsetfunc tp_descr_set;
// 生成的實例對象是否有屬性字典
// 我們上一個例子中的實例對象顯然是沒有屬性字典的,因為我們當時沒有設置這個成員
Py_ssize_t tp_dictoffset;
// 初始化函數
// typedef int (*initproc)(PyObject *, PyObject *, PyObject *);
initproc tp_init;
// 為實例對象分配空間的函數
// typedef PyObject *(*allocfunc)(struct _typeobject *, Py_ssize_t);
allocfunc tp_alloc;
// __new__ 方法
// typedef PyObject *(*newfunc)(struct _typeobject *, PyObject *, PyObject *);
newfunc tp_new;
// 我們一般設置到 tp_new 即可,剩下的就不需要管了
// 釋放一個實例對象
// typedef void (*freefunc)(void *); 一般會在析構函數中調用
freefunc tp_free; /* Low-level free-memory routine */
// typedef int (*inquiry)(PyObject *); 是否被 gc 跟蹤
inquiry tp_is_gc; /* For PyObject_IS_GC */
// 繼承哪些類,這里可以指定繼承多個類
// 這個還是有必要的,因此這個可以單獨設置
PyObject *tp_bases;
//下面的就不需要關心了
PyObject *tp_mro; /* method resolution order */
PyObject *tp_cache;
PyObject *tp_subclasses;
PyObject *tp_weaklist;
destructor tp_del;
unsigned int tp_version_tag;
destructor tp_finalize;
} PyTypeObject;
這里面我們看到有很多成員,如果有些成員我們不需要的話,那么就設置為 0 即可。不過即便設置為 0,但是有些成員我們在調用 PyType_Ready 初始化的時候,也會設置進去。比如 tp_dict,這個我們創建類的時候沒有設置,但是這個類是有屬性字典的,因為在 PyType_Ready 中設置了;但有的不會,比如 tp_dictoffset,這個我們沒有設置,那么類在 PyType_Ready 中也不會設置,因此這個類的實例對象,就真的沒有屬性字典了。再比如 tp_free,我們也沒有設置,但是是可以調用的,原因你懂的。
雖然里面的成員非常多,但是我們在實現的時候不一定每一個成員都要設置。如果只需要指定某幾個成員的話,那么我們可以先創建一個 PyTypeObject 實例,然后針對指定的屬性進行設置即可。
下面我們來編寫一個簡單的擴展類,具體細節在代碼中體現。
#include "Python.h"
// 這一步是直接定義一個類,它就是我們在 Python 中使用的類,這里采用 C++,因此我們編譯時的文件要從 main.c 改成 main.cpp
class MyClass {
public:
PyObject_HEAD // 公共的頭部信息
};
/*
或者你直接使用結構體的方式也是可以的,這樣源文件還叫 main.c 不需要修改
typedef struct {
PyObject_HEAD // 頭部信息
} MyClass;
*/
// 這里我們實現 Python 中的 __new__ 方法,這個 __new__ 方法接收哪些參數來着
// 一個類本身,以及 __init__ 中的參數,我們一般會這樣寫 def __new__(cls, *args, **kwargs):
// 所以這里的第一個參數就不再是 PyObject *了,而是 PyTypeObject *
static PyObject *
MyClass_new(PyTypeObject *cls, PyObject *args, PyObject *kw)
{
// 我們說 Python 中的 __new__ 方法默認都干了哪些事來着
// 為創建的實例對象開辟一份空間,然后會將這份空間的指針返回回去交給 self
// 當然交給 __init__ 的還有其它參數,這些參數是 __init__ 需要使用的,__new__ 方法不需要關心
// 但是畢竟要先經過 __new__ 方法,所以 __new__ 方法中要有參數位能夠接收
// 最終 __new__ 會將自身返回的 self 連同其它參數組合起來一塊交給 __init__
// 所以 __init__ 中 self 我們不需要關心,我們只需要傳遞 self 后面的參數即可,因為在 __new__ 會自動傳遞self
// 另外多提一嘴:我們使用實例對象調用方法的時候,會自動傳遞 self,你有沒有想過它為什么會自動傳遞呢?
// 其實這個在底層是使用了描述符,至於底層是怎么實現的,我們在之前已經說過了
// 所以我們這里要為 self 分配一個空間,self 也是一個指針,但是它已經有了明確的類型,所以我們需要轉化一下
// 當然這里不叫 self 也是可以的,只是我們按照官方的約定,不會引起歧義
// 分配空間是通過調用 PyTypeObject 的 tp_alloc 方法,傳入一個 PyTypeObject *,以及大小,這里是固定的所以是 0
MyClass *self = (MyClass *)cls -> tp_alloc(cls, 0); // 此時就由 Python 管理了
// 記得返回 self,轉成 PyObject *,當然我們這里是 __new__ 方法的默認實現,你也可以做一些其它的事情來控制一下類的實例化行為
return (PyObject *)self;
}
// 構造函數接收三個 PyObject *, 但它返回的是一個 int, 0 表示成功、-1 表示失敗
static int
MyClass_init(PyObject *self, PyObject *args, PyObject *kw)
{
// 假設這個構造函數接收三個參數:name,age,gender
char *name;
int age;
char *gender;
char *keys[] = {"name", "age", "gender", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kw, "sis", keys, &name, &age, &gender)){
// 這里失敗了不能返回 NULL,而是返回 -1,__init__ 比較特殊
return -1;
}
//至於如何設置到 self 當中,我們后面演示,這里先打印一下
printf("name = %s, age = %d, gender = %s\n", name, age, gender);
// 我們說結果為 0 返回成功,結果為 -1 返回失敗,所以走到這里的話應該返回 0
return 0;
}
// 析構函數, 返回值是 void,關於這些函數的參數和返回值的定義可以查看上面介紹的 PyTypeObject 結構體
void
MyClass_del(PyObject *self)
{
// 打印一句話吧
printf("call __del__\n");
// 拿到類型,調用 tp_free 釋放,這個是釋放實例對象所占空間的。所以 tp_alloc 是申請、tp_dealloc 是釋放
Py_TYPE(self) -> tp_free(self);
}
static PyModuleDef module = {
PyModuleDef_HEAD_INIT, // 頭部信息
"kagura_nana", // 模塊名
"this is a module named hanser", // 模塊注釋
-1, // 模塊空間
0, // 這里是 PyMethodDef 數組,但是我們這里沒有 PyMethodDef,所以就是 0,也就是我們這里面沒有定義函數
NULL,
NULL,
NULL,
NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void) {
// 創建類的這些過程,我們也可以單獨寫,我們這里第一次演示就直接寫在模塊初始化函數里面了
// 實例化一個 PyTypeObject,但是這里面的屬性非常多,我們通過直接賦值的方式需要寫一大堆,所以先定義,然后設置指定的屬性
static PyTypeObject cls;
// 我們知道 PyTypeObject 結構體的第一個參數就是 PyVarObject ob_base;
// 需要引用計數(初始為1)、類型 &PyType_Type、ob_size(不可變,寫上0即可)
PyVarObject ob_base = {1, &PyType_Type, 0};
cls.ob_base = ob_base; // 類的公共頭部
// 這里是類名,但是這個 MyClass 是 Python 中打印的時候顯示的名字,或者說調用 __name__ 顯示的名字
// 假設我們上面的是 MyClass1,那么在 Python 中你就需要使用 MyClass1 來實例化
// 但是使用 type 查看的時候顯示的 MyClass,因為類名叫 MyClass,但是很明顯這兩者應該是一致的
cls.tp_name = "MyClass";
cls.tp_basicsize = sizeof(MyClass); // 類的空間大小
cls.tp_itemsize = 0; // 設置為 0
// 設置類的 __new__ 方法、__init__ 方法、__del__ 方法
cls.tp_new = MyClass_new;
cls.tp_init = MyClass_init;
cls.tp_dealloc = MyClass_del;
// 初始化類,調用 PyType_Ready,而且 Python 內部的類在創建完成之后也會調用這個方法進行初始化,它會對創建類進行一些屬性的設置
// 記得傳入指針進去
if (PyType_Ready(&cls) < 0){
// 如果結果小於0,說明設置失敗
return NULL;
}
// 這個是我們自己創建的類,所以需要手動增加引用計數
Py_XINCREF(&cls);
// 加入到模塊中,這個不需要在創建 PyModuleDef 的時候指定,而是可以單獨添加
// 我們需要先把模塊創建出來,然后通過 PyModule_AddObject 將類添加進去
PyObject *m = PyModule_Create(&module);
// 傳入 創建的模塊的指針 m、類名(這個類名要和我們上面設置的 tp_name 保持一致)、以及由 PyTypeObject * 轉化得到的 PyObject *
// 另外多提一嘴,這里的 m、和 cls 以及上面 module 都只是 C 中的變量,具體的模塊名和類名是 kagura_nana 和 MyClass
PyModule_AddObject(m, "MyClass", (PyObject *)&cls);
return m; // 將模塊對象返回
}
然后是用於編譯的 py 文件:
from distutils.core import *
setup(
name="kagura_nana",
version="1.11",
author="古明地盆",
author_email="66666@東方地靈殿.com",
# 這里改成 main.cpp
ext_modules=[Extension("kagura_nana", ["main.cpp"])],
)
注意:之前使用的都是自己住的地方的台式機,里面裝了相應的環境,因為機器性能比較好。但是春節本人回家了,現在使用的是自己的筆記本,而筆記本里面沒有裝 Visual Studio 等環境,因此接下來環境會選擇我阿里雲上的 CentOS。
編譯的方式跟之前一樣,只不過需要先執行一下 yum install gcc-c++
,否則編譯時會拋出:
gcc: error trying to exec 'cc1plus': execvp: No such file or directory
如果你已經裝了,那么是沒有問題的,但也建議執行確認一下。下面操作一波:
>>> import kagura_nana
>>> kagura_nana
<module 'kagura_nana' from '/usr/local/lib64/python3.6/site-packages/kagura_nana.cpython-36m-x86_64-linux-gnu.so'>
>>> try:
... # 然后實例化一個類
... # 我們說這個類的構造函數中接收三個參數,我們先不傳遞,看看會有什么表現
... self = kagura_nana.MyClass()
... except Exception as e:
... print(e)
...
call __del__
Required argument 'name' (pos 1) not found
盡管實例化失敗,但是這個對象在 __new__ 方法中被創建了,所以依舊會調用 __del__。然后我們傳遞參數,但是我們在構造函數中只是打印,並沒有設置到 self 中。
>>> self = kagura_nana.MyClass("mashiro", 16, "female")
name = mashiro, age = 16, gender = female
>>> self.name
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'MyClass' object has no attribute 'name'
我們看到調用失敗了,因為我們沒有設置到 self 中,然后再看看析構函數。
>>> del self
call __del__
>>>
成功調用,然后里面的 printf 也成功執行。
給實例對象添加屬性
整體流程我們大致了解了,下面看看如何給實例對象添加屬性。我們說 PyTypeObject 里面有一個 tp_members 屬性,很明顯它就是用來指定實例對象的屬性的。
#include "Python.h"
#include "structmember.h" // 添加成員需要導入這個頭文件
class MyClass {
public:
PyObject_HEAD
// 添加成員,這里面的參數要和 __init__ 中的參數保持一致,你可以把 name、age、gender 看成是要通過 self. 的方式來設置的屬性
// 假設這里面沒有 gender,那么即使 Python 中傳了 gender 這個參數、並且解析出來了
// 但是你仍然沒辦法設置,所以實例化的對象依舊無法訪問
PyObject *name;
PyObject *age;
PyObject *gender;
};
/*
// 你仍然可以使用結構體的方式定義
typedef struct{
PyObject_HEAD
PyObject *name;
PyObject *age;
PyObject *gender;
}MyClass;
*/
static PyObject *
MyClass_new(PyTypeObject *cls, PyObject *args, PyObject *kw)
{
MyClass *self = (MyClass *)cls -> tp_alloc(cls, 0);
return (PyObject *)self;
}
static int
MyClass_init(PyObject *self, PyObject *args, PyObject *kw)
{
// 這里不使用 C 的類型了,使用 PyObject *,參數和原來一樣
PyObject *name;
PyObject *age = NULL;
PyObject *gender = NULL;
// 注意:上面申明的三個 PyObject * 變量叫什么名字其實是沒有所謂的,重點是 MyClass 和 下面 keys
// keys 里面的字符串就是 __init__ 中的參數名,MyClass 中的變量則是實例對象的屬性名
// 假設把 MyClass 這個類中的 name 改成 NAME,那么最終的形式就等價於 self.NAME = name
char *keys[] = {"name", "age", "gender", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kw, "O!|O!O!", keys, &PyUnicode_Type, &name,
&PyLong_Type, &age, &PyUnicode_Type, &gender)){
return -1;
}
// 注意: 有一個很關鍵的點,在 __init__ 函數調用結束之后,name、age、gender 的引用計數會減一
// 而它們又是從 Python 傳遞過來的,所以為了保證不出現懸空指針,我們必須要將引用計數手動加 1
Py_XINCREF(name);
// 而 age 和 gender 是可以不傳的,我們需要給一個默認值。
// 當傳遞了 age,那么增加引用計數;沒有傳遞 age,我們自己創建一個,由於是創建,引用計數初始為 1,所以此時就無需增加了。gender 也是同理
if (age) Py_XINCREF(age); else age = PyLong_FromLong(17);
if (gender) Py_XINCREF(gender); else gender = PyUnicode_FromWideChar(L"萌妹子", 3);
// 這里就是設置 __init__ 屬性的,將解析出來的參數設置到 __init__ 中
// 注意 PyObject * 要轉成 MyClass *,並且考慮優先級,我們需要使用括號括起來
((MyClass *)self) -> name = name;
((MyClass *)self) -> age = age;
((MyClass *)self) -> gender = gender;
// 此時我們的構造函數就設置完成了
return 0;
}
void
MyClass_del(PyObject *self)
{
// 同樣的問題,當對象在銷毀的時候,實例對象的成員的引用計數是不是也要減去 1 呢
Py_XDECREF(((MyClass *)self) -> name);
Py_XDECREF(((MyClass *)self) -> age);
Py_XDECREF(((MyClass *)self) -> gender);
Py_TYPE(self) -> tp_free(self);
}
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named hanser",
-1,
0,
NULL,
NULL,
NULL,
NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void) {
static PyTypeObject cls;
PyVarObject ob_base = {1, &PyType_Type, 0};
cls.ob_base = ob_base;
cls.tp_name = "MyClass";
cls.tp_basicsize = sizeof(MyClass);
cls.tp_itemsize = 0;
cls.tp_new = MyClass_new;
cls.tp_init = MyClass_init;
cls.tp_dealloc = MyClass_del;
// 添加成員,這是一個 PyMemberDef 類型的數組,然后顯然要把數組名放到類的 tp_members 中
// PyNumberDef 結構體有以下成員:name type offset flags doc
static PyMemberDef members[] = {
//這些成員具體值是什么?我們需要在 MyClass_init 中設置
{
"name", // 成員名
T_OBJECT_EX, // 類型,關於類型我們一會兒介紹
// 接收結構體對象和一個成員
// 獲取對應值的偏移地址,由於 Python 中的類是動態變化的,所以 C 只能通過偏移的地址來找到對應的成員,offsetof 是一個宏
// 而這里面的 name 就是我們定義的 MyClass 里面的 name,所以如果 MyClass 里面不設置,那么這里會報錯
offsetof(MyClass, name),
0, // 變量的讀取類型,設置為 0 表示可讀寫,設置為 1 表示只讀
"this is a name" //成員說明
},
// 這里將 age 設置為只讀
{"age", T_OBJECT_EX, offsetof(MyClass, age), 1, "this is a age"},
{"gender", T_OBJECT_EX, offsetof(MyClass, gender), 0, "this is a gender"},
{NULL} // 結尾有一個{NULL}
};
// 設置成員,這一步很關鍵,否則之前的相當於白做
cls.tp_members = members;
if (PyType_Ready(&cls) < 0){
return NULL;
}
Py_XINCREF(&cls);
PyObject *m = PyModule_Create(&module);
PyModule_AddObject(m, "MyClass", (PyObject *)&cls);
return m;
}
我們來測試一下:
>>> import kagura_nana
>>> self = kagura_nana.MyClass("古明地覺")
>>> self.name, self.age, self.gender
('古明地覺', 17, '萌妹子')
>>>
>>> self = kagura_nana.MyClass("古明地戀", 16, "美少女")
>>> self.name, self.age, self.gender
('古明地戀', 16, '美少女')
>>>
>>> self.name, self.gender = "koishi", "びしょうじょ"
>>> self.name, self.age, self.gender
('koishi', 16, 'びしょうじょ')
>>>
>>> # 我們看到一些都沒有問題,但接下來重點來了
...
>>> self.age = 16
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: readonly attribute
>>>
一切正常,並且我們看到 age 是只讀的,因為我們在 PyMemberDef 中將其設置為只讀,我們來看一下這個結構體。該結構體的定義藏身於 Include/structmember.h 中。
typedef struct PyMemberDef {
const char *name; // 實例屬性的名字, 比如我們上面的 name、age、gender
int type; // 實例屬性的類型, 這一點很關鍵, 支持的類型我們一會說
Py_ssize_t offset; // 實例屬性的偏移量,通過 offsetof(TYPE, MEMBER) 這個宏來獲取
int flags; // 設置為 0 表示可讀可寫, 設置為 1 表示只讀
const char *doc; // 屬性說明
} PyMemberDef;
然后我們重點看一下里面的 type 成員,它表示屬性的類型,支持如下選項:
#define T_SHORT 0
#define T_INT 1
#define T_LONG 2
#define T_FLOAT 3
#define T_DOUBLE 4
#define T_STRING 5
#define T_OBJECT 6
#define T_CHAR 7
#define T_BYTE 8
#define T_UBYTE 9
#define T_USHORT 10
#define T_UINT 11
#define T_ULONG 12
#define T_STRING_INPLACE 13
#define T_BOOL 14
#define T_OBJECT_EX 16
#define T_LONGLONG 17
#define T_ULONGLONG 18
#define T_PYSSIZET 19
#define T_NONE 20
我們的類(MyClass)中的成員應該是 PyObject *,但是用來接收參數的變量可以不是,只不過在設置實例屬性的時候需要再轉成 PyObject *,如果接收的就是 PyObject *,那么就不需要再轉了。而上面這些描述的就是參數的類型,所以我們一般用 T_OBJECT_EX 即可,但是還有一個 T_OBJECT,這兩者的區別是前者如果接收的是 NULL(沒有接收到值),那么會引發一個 AttributeError。
到目前為止,我們應該感受到使用 C/C++ 來寫擴展是一件多么痛苦的事情,特別是引用計數,一搞不好就出現內存泄漏或者懸空指針。因此,關鍵來了,再次安利一波 Cython。
除了 __init__、__new__、__del__ 之外,你還可以添加其它的方法,比如 tp_call、tp_getset 等等。
給類添加成員
一個類里面可以定義很多的函數,那么這在 C 中是如何實現的呢?很簡單,和模塊中定義函數是一致的。
#include "Python.h"
#include "structmember.h" // 添加成員需要導入這個頭文件
class MyClass {
public:
PyObject_HEAD
// 添加成員,這里面的參數要和 __init__ 中的參數保持一致,你可以把 name、age、gender 看成是要通過 self. 的方式來設置的屬性
// 假設這里面沒有 gender,那么即使 Python 中傳了 gender 這個參數、並且解析出來了
// 但是你仍然沒辦法設置,所以實例化的對象依舊無法訪問
PyObject *name;
PyObject *age;
PyObject *gender;
};
static PyObject *
MyClass_new(PyTypeObject *cls, PyObject *args, PyObject *kw)
{
MyClass *self = (MyClass *)cls -> tp_alloc(cls, 0);
return (PyObject *)self;
}
static int
MyClass_init(PyObject *self, PyObject *args, PyObject *kw)
{
PyObject *name;
PyObject *age = NULL;
PyObject *gender = NULL;
char *keys[] = {"name", "age", "gender", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kw, "O!|O!O!", keys, &PyUnicode_Type, &name,
&PyLong_Type, &age, &PyUnicode_Type, &gender)){
return -1;
}
Py_XINCREF(name);
if (age) Py_XINCREF(age); else age = PyLong_FromLong(17);
if (gender) Py_XINCREF(gender); else gender = PyUnicode_FromWideChar(L"萌妹子", 3);
((MyClass *)self) -> name = name;
((MyClass *)self) -> age = age;
((MyClass *)self) -> gender = gender;
return 0;
}
void
MyClass_del(PyObject *self)
{
Py_XDECREF(((MyClass *)self) -> name);
Py_XDECREF(((MyClass *)self) -> age);
Py_XDECREF(((MyClass *)self) -> gender);
Py_TYPE(self) -> tp_free(self);
}
// 下面來給類添加成員函數啦,添加方法跟之前的創建函數是一樣的
static PyObject *
age_incr_1(PyObject *self, PyObject *args, PyObject *kw)
{
((MyClass *)self) -> age = PyNumber_Add(((MyClass *)self) -> age, PyLong_FromLong(1));
return Py_None;
}
//構建 PyMethodDef[], 方法和之前創建函數是一樣的,但是這是類的方法,記得添加到類的 tp_methods 成員中
static PyMethodDef MyClass_methods[] = {
{"age_incr_1", (PyCFunction)age_incr_1, METH_VARARGS | METH_KEYWORDS, "method age_incr_1"},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named hanser",
-1,
0,
NULL,
NULL,
NULL,
NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void) {
static PyTypeObject cls;
PyVarObject ob_base = {1, &PyType_Type, 0};
cls.ob_base = ob_base;
cls.tp_name = "MyClass";
cls.tp_basicsize = sizeof(MyClass);
cls.tp_itemsize = 0;
cls.tp_new = MyClass_new;
cls.tp_init = MyClass_init;
cls.tp_dealloc = MyClass_del;
static PyMemberDef members[] = {
{
"name",
T_OBJECT_EX,
offsetof(MyClass, name),
0,
"this is a name"
},
{"age", T_OBJECT_EX, offsetof(MyClass, age), 0, "this is a age"},
{"gender", T_OBJECT_EX, offsetof(MyClass, gender), 0, "this is a gender"},
{NULL}
};
cls.tp_members = members;
// 設置方法
cls.tp_methods = MyClass_methods;
if (PyType_Ready(&cls) < 0){
return NULL;
}
Py_XINCREF(&cls);
PyObject *m = PyModule_Create(&module);
PyModule_AddObject(m, "MyClass", (PyObject *)&cls);
return m;
}
我們看到幾乎沒有任何區別,那么下面就來測試一下:
>>> import kagura_nana
>>> self = kagura_nana.MyClass("古明地戀", 16, "美少女")
>>> self.age_incr_1()
>>> self.age
17
>>>
循環引用造成的內存泄漏
我們說 Python 的引用計數有一個重大缺陷,那就是它無法解決循環引用。
while True:
my = MyClass("古明地覺")
my.name = my
如果你執行上面這段代碼的話,那么你會發現內存不斷飆升,很明顯我們上面在 C 中定義的類是沒有考慮循環引用的,因為它沒有被 GC 跟蹤。
我們看到由於內存使用量不斷增加,最后被操作系統強制 kill 掉了,主要就在於我們沒有解決循環引用,導致實例對象不斷被創建、但卻沒有被回收(引用計數最大的缺陷)。如果想要解決循環引用的話,那么就需要 Python 中的 GC 出馬,而使用 GC 的前提是這個類的實例對象要被 GC 跟蹤,因此我們還需要指定 tp_flags。除此之外,我們還要指定 tp_traverse(判斷內部成員是否被循環引用)和 tp_clear(清理)兩個函數,至於具體細節編寫代碼時有所體現。最后我們上面的那個類也是不允許被繼承的,如果想被繼承,同樣需要指定 tp_flags。
>>> import kagura_nana
>>> class A(kagura_nana.MyClass):
... pass
...
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: type 'MyClass' is not an acceptable base type
>>>
我們看到 MyClass 不是一個可以被繼承的類,那么下面我們來進行修改。
#include "Python.h"
#include "structmember.h"
class MyClass {
public:
PyObject_HEAD
PyObject *name;
PyObject *age;
PyObject *gender;
};
static PyObject *
MyClass_new(PyTypeObject *cls, PyObject *args, PyObject *kw)
{
MyClass *self = (MyClass *)cls -> tp_alloc(cls, 0);
return (PyObject *)self;
}
static int
MyClass_init(PyObject *self, PyObject *args, PyObject *kw)
{
PyObject *name;
PyObject *age = NULL;
PyObject *gender = NULL;
char *keys[] = {"name", "age", "gender", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kw, "O!|O!O!", keys, &PyUnicode_Type, &name,
&PyLong_Type, &age, &PyUnicode_Type, &gender)){
return -1;
}
Py_XINCREF(name);
if (age) Py_XINCREF(age); else age = PyLong_FromLong(17);
if (gender) Py_XINCREF(gender); else gender = PyUnicode_FromWideChar(L"萌妹子", 3);
((MyClass *)self) -> name = name;
((MyClass *)self) -> age = age;
((MyClass *)self) -> gender = gender;
return 0;
}
static PyObject *
age_incr_1(PyObject *self, PyObject *args, PyObject *kw)
{
((MyClass *)self) -> age = PyNumber_Add(((MyClass *)self) -> age, PyLong_FromLong(1));
return Py_None;
}
static PyMethodDef MyClass_methods[] = {
{"age_incr_1", (PyCFunction)age_incr_1, METH_VARARGS | METH_KEYWORDS, "method age_incr_1"},
{NULL, NULL, 0, NULL}
};
// 判斷是否被循環引用,參數和返回的值的定義還是參考源碼,這里面的參數名要固定
static int MyClass_traverse(MyClass *self, visitproc visit, void *arg){
// 底層幫你提供了一個宏
Py_VISIT(self -> name);
Py_VISIT(self -> age);
Py_VISIT(self -> gender);
return 0;
}
// 清理
static int MyClass_clear(MyClass *self){
Py_CLEAR(self -> name);
Py_CLEAR(self -> age);
Py_CLEAR(self -> gender);
return 0;
}
void
MyClass_del(PyObject *self)
{
// 我們在 MyClass_clear 中使用了 Py_CLEAR,那么這里減少引用計數的邏輯就不需要了,直接調用 MyClass_clear 即可
MyClass_clear((MyClass *) self);
// 我們說 Python 會跟蹤創建的對象,如果被回收了,那么應該從鏈表中移除
PyObject_GC_UnTrack(self);
Py_TYPE(self) -> tp_free(self);
}
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named hanser",
-1,
0,
NULL,
NULL,
NULL,
NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void) {
static PyTypeObject cls;
PyVarObject ob_base = {1, &PyType_Type, 0};
cls.ob_base = ob_base;
cls.tp_name = "MyClass";
cls.tp_basicsize = sizeof(MyClass);
cls.tp_itemsize = 0;
cls.tp_new = MyClass_new;
cls.tp_init = MyClass_init;
cls.tp_dealloc = MyClass_del;
static PyMemberDef members[] = {
{
"name",
T_OBJECT_EX,
offsetof(MyClass, name),
0,
"this is a name"
},
{"age", T_OBJECT_EX, offsetof(MyClass, age), 0, "this is a age"},
{"gender", T_OBJECT_EX, offsetof(MyClass, gender), 0, "this is a gender"},
{NULL}
};
cls.tp_members = members;
cls.tp_methods = MyClass_methods;
// 解決循環引用造成的內存泄漏,通過 Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC 開啟垃圾回收,同時允許該類被繼承
cls.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC;
// 設置 tp_traverse 和 tp_clear
cls.tp_traverse = (traverseproc) MyClass_traverse;
cls.tp_clear = (inquiry) MyClass_clear;
// 如果想指定繼承的類的話,那么通過 tp_bases 指定即可,這里不再說了
if (PyType_Ready(&cls) < 0){
return NULL;
}
Py_XINCREF(&cls);
PyObject *m = PyModule_Create(&module);
PyModule_AddObject(m, "MyClass", (PyObject *)&cls);
return m;
}
下面我們來繼續測試一下,看看有沒有問題:
可以看到,此時類可以被繼承了,並且也沒有出現循環引用導致的內存泄漏。
真的想說,用 C 寫擴展實在是太不容易了,很明顯這還只是非常簡單的,因為目前這個類基本沒啥方法。如果加上描述符、自定義迭代器,或者我們再多寫幾個方法。方法之間互相調用,導入模塊(目前還沒有說)等等,絕對是讓人頭皮發麻的事情,所以寫擴展我一般只用 Cython。
全局解釋器鎖
我們使用 C / C++ 寫擴展除了增加效率之外,最大的特點就是能夠釋放掉 GIL,關於 GIL 也是一個老生常談的問題。我在前面系列已經說過,這里不再贅述了。
那么問題來了,在 C 中如何獲取 GIL 呢?
// 首先 Python 中的線程是對 C 線程的一個封裝,同時還會對應一個 PyThreadState(線程狀態) 對象,用來對線程狀態進行描述
// 而如果要使用 Python / C API 的話,那么就不能是 C 中的線程,而是 Python 中的線程
Py_GILState_STATE gstate;
// 所以 Python 為了簡便而提供了一個函數 PyGILState_Ensure,在 C 中創建了一個線程,那么調用這個函數后,C 線程就會被封裝成 Python 中的線程
// 不然的話,我們要寫好多代碼。這一步會對 Python 中線程進行初始化創建一個 PyThreadState 對象,同時獲取 GIL
//
gstate = PyGILState_Ensure();
// 做一些其它操作,注意:一旦使用 Python / C API,那么必須要獲取到 GIL
call_some_function();
// 釋放掉 GIL
PyGILState_Release(gstate);
一旦在 C 中獲取到 GIL,那么 Python 的其它線程都必須處於等待狀態,並且當調用擴展模塊中的函數時,解釋器是沒有權利迫使當前線程釋放 GIL 的,因為調用的是 C 的代碼,Python 解釋器能控制的只有 Python 的字節碼這一層。所以在一些操作執行結束后,必須要主動釋放 GIL,否則 Python 的其它線程永遠不會得到被調度的機會。
但有時我們做的是一些純 C / C++ 操作,不需要和 Python 進行交互,這個時候希望告訴 Python 解釋器,其它的線程該執行執行,不用等我,這個時候怎么做呢?首先Python 底層給我們提供了兩個宏:Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS。
// 將當前線程狀態給保存下來,然后其它線程就可以繼續執行了,從名字上也能看出,開始允許多個線程並行執行
#define Py_BEGIN_ALLOW_THREADS { \
PyThreadState *_save; \
_save = PyEval_SaveThread();
// 恢復線程狀態,回到解釋器的 GIL 調用中
#define Py_END_ALLOW_THREADS PyEval_RestoreThread(_save); \
}
從宏定義中我們可以看出,這兩個宏是需要成對出現的,當然你也可以使用更細的 API 自己控制。總之:當釋放 GIL 的時候,一定不要和 Python 進行交互,或者說不能有任何 Python / C API 的調用。
#include "Python.h"
#include <pthread.h>
// 子線程調用的函數, 要求接受一個 void *、返回一個 void*
void* test(void *lst) {
// 對於擴展而言,我們是通過 Python 調用里面的函數,所以調用它的是 Python 中的線程
// 但這是我們使用 pthread 創建的子線程進行調用,不是 Python 中的,因此它不能和 Python 有任何的交互
// 而我們是需要和 Python 交互的,這里面的參數 lst 就是由 PyObject * 轉化得到的,因此我們需要封裝成 Python 中的線程
PyGILState_STATE gstate;
gstate = PyGILState_Ensure();
// 這里面和 Python 進行交互
PyObject *lst1 = (PyObject *) lst;
// 我們往里面添加設置幾個元素
PyObject *item = PyLong_FromLong(123);
PyList_Append(lst1, item);
// 注意:以上引用計數變成了 2,我們需要再減去 1
Py_XDECREF(item);
item = PyUnicode_FromString("hello matsuri");
PyList_Append(lst1, item);
Py_XDECREF(item);
// 假設我們以上 Python 的邏輯就調用完了,那么我們是不是要將 GIL 給釋放掉呢?否則其它線程永遠沒有機會得到調度
// 干脆我們就不釋放了,看看效果吧
return NULL;
}
static PyObject* test_gil(PyObject *self, PyObject *args){
// 假設我們接受一個 list
PyObject *lst = NULL;
if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &lst)){
return NULL;
}
// 創建線程 id
pthread_t tid;
// 創建一個線程
int res = pthread_create(&tid, NULL, test, (void *)lst);
if (res != 0) {
printf("pthread_create error: error_code = %d\n", res);
}
return Py_None;
}
static PyMethodDef methods[] = {
{"test_gil", (PyCFunction) test_gil, METH_VARARGS, "this is a function named test_gil"},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named hanser",
-1,
methods,
NULL,
NULL,
NULL,
NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void) {
PyObject *m = PyModule_Create(&module);
return m;
}
我們來測試一下:
我們看了程序就無法執行了,因為 Python 只能利用單核,我們在 C 中開啟了子線程,然后創建對應的 Python 線程。此時就有兩個 Python 線程,只不過一個是主線程,另一個是在 C 中創建的子線程,然后這個子線程通過 Python / C API 獲取了 GIL,但是用完了不釋放,這就導致了主線程永遠得不到機會執行。當然也無法接收 Ctrl + C 命令,因此我們需要新啟一個終端 kill 掉它。
#include "Python.h"
#include <pthread.h>
void* test(void *lst) {
PyGILState_STATE gstate;
gstate = PyGILState_Ensure();
PyObject *lst1 = (PyObject *) lst;
PyObject *item = PyLong_FromLong(123);
PyList_Append(lst1, item);
Py_XDECREF(item);
item = PyUnicode_FromString("hello matsuri");
PyList_Append(lst1, item);
Py_XDECREF(item);
// 這里將 GIL 釋放掉
PyGILState_Release(gstate);
// 然后下面就不可以再有任何 Python / C API 的出現了
return NULL;
}
static PyObject* test_gil(PyObject *self, PyObject *args){
PyObject *lst = NULL;
if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &lst)){
return NULL;
}
pthread_t tid;
int res = pthread_create(&tid, NULL, test, (void *)lst);
if (res != 0) {
printf("pthread_create error: error_code = %d\n", res);
}
return Py_None;
}
static PyMethodDef methods[] = {
{"test_gil", (PyCFunction) test_gil, METH_VARARGS, "this is a function named test_gil"},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named hanser",
-1,
methods,
NULL,
NULL,
NULL,
NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void) {
PyObject *m = PyModule_Create(&module);
return m;
}
然后我們再來測試一下:
我們看到此時就沒有任何問題了,當 C 中的線程將 GIL 給釋放掉之后,此時它和 Python 線程就沒有關系了,它就是 C 的線程。那么下面可以寫純 C / C++ 代碼,此時可以實現並行執行。但是能不用多線程就不用多線程,因為多線程出現 bug 之后難以調試。
另外我們目前是在 C 中創建的 Python 線程,但是很明顯這需要你對 C 的多線程理解有一定要求。那么我也可以不在 C 中創建,而是在 Python 中創建子線程去調用。
#include "Python.h"
static PyObject* test_gil(PyObject *self, PyObject *args){
PyObject *lst = NULL;
if (!PyArg_ParseTuple(args, "O!", &PyList_Type, &lst)){
return NULL;
}
// 此時該函數要被 Python 的子線程進行調用,但是很明顯默認還是受到 GIL 的限制的
Py_BEGIN_ALLOW_THREADS // 釋放掉 GIL,此時調用該函數的 Python 線程將不再受到解釋器的制約,從而實現並行執行
// 但是很明顯,這里面不可以有任何的 Python / C API 調用
long a;
while (1) a ++; // 不停的對 a 進行自增,顯然程序會一直卡在這里
Py_END_ALLOW_THREADS // 獲取 GIL,此時會回到解釋器的線程調度中
// 下面就可以包含 Python 邏輯了,如果再遇到純 C / C++ 邏輯,那么就再通過這兩個宏繼續實現並行
// 當然為了演示,我們上面是個死循環
return Py_None;
}
static PyMethodDef methods[] = {
{"test_gil", (PyCFunction) test_gil, METH_VARARGS, "this is a function named test_gil"},
{NULL, NULL, 0, NULL}
};
static PyModuleDef module = {
PyModuleDef_HEAD_INIT,
"kagura_nana",
"this is a module named hanser",
-1,
methods,
NULL,
NULL,
NULL,
NULL
};
PyMODINIT_FUNC
PyInit_kagura_nana(void) {
PyObject *m = PyModule_Create(&module);
return m;
}
然后我們在 Python 中創建子線程去調用:
我們開啟了一個子線程,去調用擴展模塊中的函數,然后主線程也寫了一個死循環。下面看一下 CPU 的使用率:
我們看到成功利用了多核,此時我們就通過編寫擴展的方式來繞過了解釋器中 GIL 的限制。
所以對於一些 C / C++ 邏輯,它們不需要和 Python 進行所謂的交互,那么我們就可以把 GIL 釋放掉。因為 GIL 本來就是為了保護 Python 中的對象的,為了內存管理,CPython 的開發人員為了直接在解釋器上面加上了一把超級大鎖,但是當我們不需要和 Python 對象進行交互的時候,就可以把 GIL 給釋放掉。
GIL 是字節碼級別互斥鎖,當線程執行字節碼的時候,如果自身已經獲取到 GIL ,那么會判斷是否有釋放的 GIL 的請求(gil_drop_request):有則釋放、將 CPU 使用權交給其它線程,沒有則直接執行字節碼;如果自身沒有獲取到 GIL,那么會先判斷 GIL 是否被別的線程獲取,若被別的線程獲取就一直申請、沒有則拿到 GIL 執行字節碼。
總結
這一次我們聊了聊 Python 和 C/C++ 聯合編程,我們可以在 Python 中引入 C/C++,也可以在 C/C++ 中引入 Python,甚至還可以定制 Python 解釋器。只不過筆者是主 Python 的,因此在 C/C++ 中引入 Python 就不說了。
Python 引入 C/C++ 主要是通過編寫擴展的方式,這真的是一件痛苦的事情,需要你對 Python / C API 有很深的了解,最后仍然安利一波 Cython。
這應該是我有史以來寫過的最長的文章了。