解密 Python 如何調(diào)用 Rust 編譯生成的動(dòng)態(tài)鏈接庫(kù)
楔子
Rust 讓 Python 更加偉大,隨著 Rust 的流行,反而讓 Python 的生產(chǎn)力提高了不少。因?yàn)橛性絹?lái)越多的 Python 工具,都選擇了 Rust 進(jìn)行開發(fā),并且性能也優(yōu)于同類型的其它工具。比如:
- ruff:速度極快的代碼分析工具,以及代碼格式化工具;
- orjson:一個(gè)高性能的 JSON 解析庫(kù);
- watchfiles:可以對(duì)指定目錄進(jìn)行實(shí)時(shí)監(jiān)控;
- polars:和 pandas 類似的數(shù)據(jù)分析工具;
- pydantic:數(shù)據(jù)驗(yàn)證工具;
- ......
總之現(xiàn)在 Rust + Python 已經(jīng)成為了一個(gè)趨勢(shì),并且 Rust 也提供了一系列成熟好用的工具,比如 PyO3、Maturin,專門為 Python 編寫擴(kuò)展。不過(guò)關(guān)于 PyO3 我們以后再聊,本篇文章先來(lái)介紹如何將 Rust 代碼編譯成動(dòng)態(tài)庫(kù),然后交給 Python 的 ctypes 模塊調(diào)用。
因?yàn)橥ㄟ^(guò) ctypes 調(diào)用動(dòng)態(tài)庫(kù)是最簡(jiǎn)單的一種方式,它只對(duì)操作系統(tǒng)有要求,只要操作系統(tǒng)一致,那么任何提供了 ctypes 模塊的 Python 解釋器都可以調(diào)用。
當(dāng)然這也側(cè)面要求,Rust 提供的接口不能太復(fù)雜,因?yàn)?nbsp;ctypes 提供的交互能力還是比較有限的,最明顯的問(wèn)題就是不同語(yǔ)言的數(shù)據(jù)類型不同,一些復(fù)雜的交互方式還是比較難做到的,還有多線程的控制問(wèn)題等等。
之前說(shuō)過(guò)使用 ctypes 調(diào)用 C 的動(dòng)態(tài)庫(kù),里面詳細(xì)介紹了 ctypes 的用法,因此本文關(guān)于 ctypes 就不做詳細(xì)介紹了。
舉個(gè)例子
下面我們舉個(gè)例子感受一下 Python 和 Rust 的交互過(guò)程,首先通過(guò)如下命令創(chuàng)建一個(gè) Rust 項(xiàng)目:
cargo new py_lib --lib
創(chuàng)建完之后修改 Cargo.toml,在里面加入如下內(nèi)容:
[lib]
# 編譯之后的動(dòng)態(tài)庫(kù)的名稱
name = "py_lib"
# 表示編譯成一個(gè)和 C 語(yǔ)言二進(jìn)制接口(ABI)兼容的動(dòng)態(tài)鏈接庫(kù)
crate-type = ["cdylib"]
cdylib 表示生成動(dòng)態(tài)庫(kù),如果想生成靜態(tài)庫(kù),那么就指定為 staticlib。
下面開始編寫源代碼,在生成項(xiàng)目之后,src 目錄下會(huì)有一個(gè) lib.rs,它是整個(gè)庫(kù)的入口點(diǎn)。我們的代碼比較簡(jiǎn)單,直接寫在 lib.rs 里面即可。
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]
pub extern "C" fn get_square_root(v: i32) -> f64 {
(v as f64).sqrt()
}
在定義函數(shù)時(shí)需要使用 pub extern "C" 進(jìn)行聲明,它表示創(chuàng)建一個(gè)外部可見(jiàn)、遵循 C 語(yǔ)言調(diào)用約定的函數(shù),因?yàn)?Python 使用的是 C ABI。
此外還要給函數(shù)添加一個(gè) #[no_mangle] 屬性,讓編譯器在將 Rust 函數(shù)導(dǎo)出為 C 函數(shù)時(shí),不要改變函數(shù)的名稱。確保在編譯成動(dòng)態(tài)庫(kù)后,函數(shù)名保持不變,否則在調(diào)用動(dòng)態(tài)庫(kù)時(shí)就找不到指定的函數(shù)了。
Rust 有個(gè)名稱修飾(Name Mangling)的機(jī)制,在跨語(yǔ)言操作時(shí),會(huì)修改函數(shù)名,增加一些額外信息。這種修改對(duì) Rust 內(nèi)部使用沒(méi)有影響,但會(huì)干擾其它語(yǔ)言的調(diào)用,因此需要通過(guò) #[no_mangle] 將該機(jī)制禁用掉。
代碼編寫完成,我們通過(guò) cargo build 進(jìn)行編譯,然后在 target/debug 目錄下就會(huì)生成相應(yīng)的動(dòng)態(tài)庫(kù)。由于庫(kù)的名稱我們指定為 py_lib,那么生成的庫(kù)文件名就叫 libpy_lib.dylib。
當(dāng)功能全部實(shí)現(xiàn)并且測(cè)試通過(guò)時(shí),最好重新編譯一次,并加上 --release 參數(shù)。這樣可以對(duì)代碼進(jìn)行優(yōu)化,當(dāng)然編譯時(shí)間也會(huì)稍微長(zhǎng)一些,并且生成的庫(kù)文件會(huì)在 target/release 目錄中。
編譯器生成動(dòng)態(tài)庫(kù)后,會(huì)自動(dòng)加上一個(gè) lib 前綴(Windows 系統(tǒng)除外),至于后綴則與操作系統(tǒng)有關(guān)。
- Windows 系統(tǒng),后綴名為 .dll;
- macOS 系統(tǒng),后綴名為 .dylib;
- Linux 系統(tǒng),后綴名為 .so;
然后我們通過(guò) Python 進(jìn)行調(diào)用。
import ctypes
# 使用 ctypes 很簡(jiǎn)單,直接 import 進(jìn)來(lái)
# 然后使用 ctypes.CDLL 這個(gè)類來(lái)加載動(dòng)態(tài)鏈接庫(kù)
# 或者使用 ctypes.cdll.LoadLibrary 也是可以的
py_lib = ctypes.CDLL("../py_lib/target/debug/libpy_lib.dylib")
# 加載之后就得到了動(dòng)態(tài)鏈接庫(kù)對(duì)象,我們起名為 py_lib
# 然后通過(guò)屬性訪問(wèn)的方式去調(diào)用里面的函數(shù)
print(py_lib.add(11, 22))
"""
33
"""
# 如果不確定函數(shù)是否存在,那么建議使用反射
# 因?yàn)楹瘮?shù)不存在,通過(guò) . 的方式獲取是會(huì)拋異常的
get_square_root = getattr(py_lib, "get_square_root", None)
if get_square_root:
print(get_square_root)
"""
<_FuncPtr object at 0x7fae30a2b040>
"""
# 不存在 sub 函數(shù),所以得到的結(jié)果為 None
sub = getattr(py_lib, "sub", None)
print(sub)
"""
None
"""
所以使用 ctypes 去調(diào)用動(dòng)態(tài)鏈接庫(kù)非常方便,過(guò)程很簡(jiǎn)單:
- 1)通過(guò) ctypes.CDLL 去加載動(dòng)態(tài)庫(kù);
- 2)加載動(dòng)態(tài)鏈接庫(kù)之后會(huì)返回一個(gè)對(duì)象,我們上面起名為 py_lib;
- 3)然后直接通過(guò) py_lib 調(diào)用里面的函數(shù),但為了程序的健壯性,建議使用反射,確定調(diào)用的函數(shù)存在后才會(huì)調(diào)用;
我們以上就演示了如何通過(guò) ctypes 模塊來(lái)調(diào)用 Rust 編譯生成的動(dòng)態(tài)庫(kù),但顯然目前還是遠(yuǎn)遠(yuǎn)不夠的,比如說(shuō):
from ctypes import CDLL
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
square_root = py_lib.get_square_root(100)
print(square_root) # 0
100 的平方根是 10,但卻返回了 0。這是因?yàn)?ctypes 在解析返回值的時(shí)候默認(rèn)是按照整型來(lái)解析的,但當(dāng)前的函數(shù)返回的是浮點(diǎn)型,因此函數(shù)在調(diào)用之前需要顯式地指定其返回值類型。
不過(guò)在這之前,我們需要先來(lái)看看 Python 類型和 Rust 類型之間的轉(zhuǎn)換關(guān)系。
數(shù)值類型
使用 ctypes 調(diào)用動(dòng)態(tài)鏈接庫(kù),主要是調(diào)用庫(kù)里面使用 Rust 編寫好的函數(shù),但這些函數(shù)是需要參數(shù)的,還有返回值。而不同語(yǔ)言的變量類型不同,Python 不能直接往 Rust 編寫的函數(shù)中傳參,因此 ctypes 提供了大量的類,幫我們將 Python 的類型轉(zhuǎn)成 Rust 的類型。
與其說(shuō)轉(zhuǎn)成 Rust 的類型,倒不如說(shuō)轉(zhuǎn)成 C 的類型,因?yàn)?Rust 導(dǎo)出的函數(shù)要遵循 C 的調(diào)用約定。
下面來(lái)測(cè)試一下,首先編寫 Rust 代碼:
#[no_mangle]
pub extern "C" fn add_u32(a: u32) -> u32 {
a + 1
}
#[no_mangle]
pub extern "C" fn add_isize(a: isize) -> isize {
a + 1
}
#[no_mangle]
pub extern "C" fn add_f32(a: f32) -> f32 {
a + 1.
}
#[no_mangle]
pub extern "C" fn add_f64(a: f64) -> f64 {
a + 1.
}
#[no_mangle]
pub extern "C" fn reverse_bool(a: bool) -> bool {
!a
}
編譯之后 Python 進(jìn)行調(diào)用。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
print(py_lib.add_u32(123))
"""
124
"""
print(py_lib.add_isize(666))
"""
667
"""
try:
print(py_lib.add_f32(3.14))
except Exception as e:
print(e)
"""
<class 'TypeError'>: Don't know how to convert parameter 1
"""
# 我們看到報(bào)錯(cuò)了,告訴我們不知道如何轉(zhuǎn)化第 1 個(gè)參數(shù)
# 因?yàn)?Python 的數(shù)據(jù)和 C 的數(shù)據(jù)不一樣,所以不能直接傳遞
# 但整數(shù)是個(gè)例外,除了整數(shù),其它數(shù)據(jù)都需要使用 ctypes 包裝一下
# 另外整數(shù)最好也包裝一下,因?yàn)椴煌麛?shù)之間,精度也有區(qū)別
print(py_lib.add_f32(c_float(3.14)))
"""
1
"""
# 雖然沒(méi)報(bào)錯(cuò),但是結(jié)果不對(duì),結(jié)果應(yīng)該是 3.14 + 1 = 4.14,而不是 1
# 因?yàn)?ctypes 調(diào)用函數(shù)時(shí)默認(rèn)使用整型來(lái)解析,但該函數(shù)返回的不是整型
# 需要告訴 ctypes,add_f32 函數(shù)返回的是 c_float,請(qǐng)按照 c_float 來(lái)解析
py_lib.add_f32.restype = c_float
print(py_lib.add_f32(c_float(3.14)))
"""
4.140000343322754
"""
# f32 和 f64 是不同的類型,占用的字節(jié)數(shù)也不一樣
# 所以 c_float 和 c_double 之間不可混用,雖然都是浮點(diǎn)數(shù)
py_lib.add_f64.restype = c_double
print(py_lib.add_f64(c_double(3.14)))
"""
4.140000000000001
"""
py_lib.reverse_bool.restype = c_bool
print(py_lib.reverse_bool(c_bool(True)))
print(py_lib.reverse_bool(c_bool(False)))
"""
False
True
"""
不復(fù)雜,以上我們就實(shí)現(xiàn)了數(shù)值類型的傳遞。
字符類型
字符類型有兩種,一種是 ASCII 字符,本質(zhì)上是個(gè) u8;一種是 Unicode 字符,本質(zhì)上是個(gè) u32。
編寫 Rust 代碼:
#[no_mangle]
pub extern "C" fn get_char(a: u8) -> u8 {
a + 1
}
#[no_mangle]
pub extern "C" fn get_unicode(a: u32) -> u32 {
let chr = char::from_u32(a).unwrap();
if chr == '憨' {
'批' as u32
} else {
a
}
}
我們知道 Rust 專門提供了 4 個(gè)字節(jié) char 類型來(lái)表示 unicode 字符,但對(duì)于外部導(dǎo)出函數(shù)來(lái)說(shuō),使用 char 是不安全的,所以直接使用 u8 和 u32 就行。
編譯之后,Python 調(diào)用:
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
# u8 除了可以使用 c_byte 包裝之外,還可以使用 c_char
# 并且 c_byte 里面只能接收整數(shù),而 c_char 除了整數(shù),還可以接收長(zhǎng)度為 1 的字節(jié)串
print(c_byte(97))
print(c_char(97))
print(c_char(b"a"))
"""
c_byte(97)
c_char(b'a')
c_char(b'a')
"""
# 以上三者是等價(jià)的,因?yàn)?char 說(shuō)白了就是個(gè) u8
# 指定返回值為 c_byte,會(huì)返回一個(gè)整數(shù)
py_lib.get_char.restype = c_byte
# c_byte(97)、c_char(97)、c_char(b"a") 都是等價(jià)的
# 因?yàn)樗鼈儽举|(zhì)上都是 u8,至于 97 也可以解析為 u8
print(py_lib.get_char(97)) # 98
# 指定返回值為 c_char,會(huì)返回一個(gè)字符(長(zhǎng)度為 1 的 bytes 對(duì)象)
py_lib.get_char.restype = c_char
print(py_lib.get_char(97)) # b'b'
py_lib.get_unicode.restype = c_wchar
print(py_lib.get_unicode(c_wchar("嘿"))) # 嘿
# 直接傳一個(gè) u32 整數(shù)也可以,因?yàn)?unicode 字符底層就是個(gè) u32
print(py_lib.get_unicode(ord("憨"))) # 批
以上就是字符類型的操作,比較簡(jiǎn)單。
字符串類型
再來(lái)看看字符串,我們用 Rust 實(shí)現(xiàn)一個(gè)函數(shù),它接收一個(gè)字符串,然后返回大寫形式。
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn to_uppercase(s: *const c_char) -> *mut c_char {
// 將 *const c_char 轉(zhuǎn)成 &CStr
let s = unsafe {
CStr::from_ptr(s)
};
// 將 &CStr 轉(zhuǎn)成 &str
// 然后調(diào)用 to_uppercase 轉(zhuǎn)成大寫,得到 String
let s = s.to_str().unwrap().to_uppercase();
// 將 String 轉(zhuǎn)成 *mut char 返回
CString::new(s).unwrap().into_raw()
}
解釋一下里面的 CStr 和 CString,在 Rust 中,CString 用于創(chuàng)建 C 風(fēng)格的字符串(以 \0 結(jié)尾),擁有自己的內(nèi)存。關(guān)鍵的是,CString 擁有值的所有權(quán),當(dāng)實(shí)例離開作用域時(shí),它的析構(gòu)函數(shù)會(huì)被調(diào)用,相關(guān)內(nèi)存會(huì)被自動(dòng)釋放。
而 CStr,它和 CString 之間的關(guān)系就像 str 和 String 的關(guān)系,所以 CStr 一般以引用的形式出現(xiàn)。并且 CStr 沒(méi)有 new 方法,不能直接創(chuàng)建,它需要通過(guò) from_ptr 方法從原始指針轉(zhuǎn)化得到。
然后指針類型是 *const 和 *mut,分別表示指向 C 風(fēng)格字符串的首字符的不可變指針和可變指針,它們的區(qū)別主要在于指向的數(shù)據(jù)是否可以被修改。如果不需要修改,那么使用 *const 會(huì)更安全一些。
我們編寫 Python 代碼測(cè)試一下。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
s = "hello 古明地覺(jué)".encode("utf-8")
# 默認(rèn)是按照整型解析的,所以不指定返回值類型的話,會(huì)得到臟數(shù)據(jù)
print(py_lib.to_uppercase(c_char_p(s)))
"""
31916096
"""
# 指定返回值為 c_char_p,表示按照 char * 來(lái)解析
py_lib.to_uppercase.restype = c_char_p
print(
py_lib.to_uppercase(c_char_p(s)).decode("utf-8")
)
"""
HELLO 古明地覺(jué)
"""
從表面上看似乎挺順利的,但背后隱藏著內(nèi)存泄露的風(fēng)險(xiǎn),因?yàn)?Rust 里面創(chuàng)建的 CString 還駐留在堆區(qū),必須要將它釋放掉。所以我們還要寫一個(gè)函數(shù),用于釋放字符串。
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn to_uppercase(s: *const c_char) -> *mut c_char {
let s = unsafe {
CStr::from_ptr(s)
};
let s = s.to_str().unwrap().to_uppercase();
CString::new(s).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn free_cstring(s: *mut c_char) {
unsafe {
if s.is_null() { return }
// 基于原始指針創(chuàng)建 CString,拿到堆區(qū)字符串的所有權(quán)
// 然后離開作用域,自動(dòng)釋放
CString::from_raw(s)
};
}
然后來(lái)看看 Python 如何調(diào)用:
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
s = "hello 古明地覺(jué)".encode("utf-8")
# Rust 返回的是原始指針,這里必須要拿到它保存的地址
# 所以指定返回值為 c_void_p,如果指定為 c_char_p,
# 那么會(huì)直接轉(zhuǎn)成 bytes 對(duì)象,這樣地址就拿不到了
py_lib.to_uppercase.restype = c_void_p
# 拿到地址,此時(shí)的 ptr 是一個(gè)普通的整數(shù),但它和指針保存的地址是一樣的
ptr = py_lib.to_uppercase(c_char_p(s))
# 將 ptr 轉(zhuǎn)成 c_char_p,獲取 value 屬性,即可得到具體的 bytes 對(duì)象
print(cast(ptr, c_char_p).value.decode("utf-8"))
"""
HELLO 古明地覺(jué)
"""
# 內(nèi)容我們拿到了,但堆區(qū)的字符串還沒(méi)有釋放,所以調(diào)用 free_cstring
py_lib.free_cstring(c_void_p(ptr))
通過(guò) CString 的 into_raw,可以基于 CString 創(chuàng)建原始指針 *mut,然后 Python 將指針指向的堆區(qū)數(shù)據(jù)拷貝一份,得到 bytes 對(duì)象。
但這個(gè) CString 依舊駐留在堆區(qū),所以 Python 不能將返回值指定為 c_char_p,因?yàn)樗鼤?huì)直接創(chuàng)建 bytes 對(duì)象,這樣就拿不到指針了。因此將返回值指定為 c_void_p,調(diào)用函數(shù)會(huì)得到一串整數(shù),這個(gè)整數(shù)就是指針保存的地址。
我們使用 cast 函數(shù)可以將地址轉(zhuǎn)成 c_char_p,獲取它的 value 屬性拿到具體的字節(jié)串。再通過(guò) c_void_p 創(chuàng)建原始指針交給 Rust,調(diào)用 CString 的 from_raw,可以基于 *mut 創(chuàng)建 CString,從而將所有權(quán)奪回來(lái),然后離開作用域時(shí)釋放堆內(nèi)存。
給函數(shù)傳遞指針
如果擴(kuò)展函數(shù)里面接收的是指針,那么 Python 要怎么傳遞呢?
#[no_mangle]
pub extern "C" fn add(a: *mut i32, b: *mut i32) -> i32 {
// 定義為 *mut,那么可以修改指針指向的值,定義為 *const,則不能修改
if a.is_null() || b.is_null() {
0
} else {
let res = unsafe {
*a + *b
};
unsafe {
// 這里將 *a 和 *b 給改掉
*a = 666;
*b = 777;
}
res
}
}
定義了一個(gè) add 函數(shù),接收兩個(gè) i32 指針,返回解引用后相加的結(jié)果。但是在返回之前,我們將 *a 和 *b 的值也修改了。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
a = c_int(22)
b = c_int(33)
# 計(jì)算
print(py_lib.add(pointer(a), pointer(b))) # 55
# 我們看到 a 和 b 也被修改了
print(a, a.value) # c_int(666) 666
print(b, b.value) # c_int(777) 777
非常簡(jiǎn)單,那么問(wèn)題來(lái)了,能不能返回一個(gè)指針呢?答案是當(dāng)然可以,只不過(guò)存在一些注意事項(xiàng)。
由于 Rust 本身的內(nèi)存安全原則,直接從函數(shù)返回一個(gè)指向本地局部變量的指針是不安全的。因?yàn)樵撟兞康淖饔糜騼H限于函數(shù)本身,一旦函數(shù)返回,該變量的內(nèi)存就會(huì)被回收,從而出現(xiàn)懸空指針。
為了避免這種情況出現(xiàn),我們應(yīng)該在堆上分配內(nèi)存,但這又出現(xiàn)了之前 CString 的問(wèn)題。Python 在拿到值之后,堆內(nèi)存依舊駐留在堆區(qū)。因此 Rust 如果想返回指針,那么同時(shí)還要定義一個(gè)釋放函數(shù)。
#[no_mangle]
pub extern "C" fn add(a: *const i32, b: *const i32) -> *mut i32 {
// 返回值的類型是 *mut i32,所以 res 不能直接返回,因此它是 i32
let res = unsafe {*a + *b};
// 創(chuàng)建智能指針(將 res 裝箱),然后返回原始指針
Box::into_raw(Box::new(res))
}
#[no_mangle]
pub extern "C" fn free_i32(ptr: *mut i32) {
if !ptr.is_null() {
// 轉(zhuǎn)成 Box<i32>,同時(shí)拿到所有權(quán),在離開作用域時(shí)釋放堆內(nèi)存
unsafe { let _ = Box::from_raw(ptr); }
}
}
然后 Python 進(jìn)行調(diào)用:
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
a, b = c_int(22), c_int(33)
# 指定類型為 c_void_p
py_lib.add.restype = c_void_p
# 拿到指針保存的地址
ptr = py_lib.add(pointer(a), pointer(b))
# 將 c_void_p 轉(zhuǎn)成 POINTER(c_int) 類型,也就是 c_int *
# 通過(guò)它的 contents 屬性拿到具體的值
print(cast(ptr, POINTER(c_int)).contents) # c_int(55)
print(cast(ptr, POINTER(c_int)).contents.value) # 55
# 釋放堆內(nèi)存
py_lib.free_i32(c_void_p(ptr))
這樣我們就拿到了指針,并且也不會(huì)出現(xiàn)內(nèi)存泄露。但是單獨(dú)定義一個(gè)釋放函數(shù)還是有些麻煩的,所以 Rust 自動(dòng)提供了一個(gè) free 函數(shù),專門用于釋放堆內(nèi)存。舉個(gè)例子:
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
#[no_mangle]
pub extern "C" fn to_uppercase(s: *const c_char) -> *mut c_char {
let s = unsafe {
CStr::from_ptr(s)
};
let s = s.to_str().unwrap().to_uppercase();
CString::new(s).unwrap().into_raw()
}
#[no_mangle]
pub extern "C" fn add(a: *const i32, b: *const i32) -> *mut i32 {
let res = unsafe {*a + *b};
Box::into_raw(Box::new(res))
}
這是出現(xiàn)過(guò)的兩個(gè)函數(shù),它們的內(nèi)存都申請(qǐng)?jiān)诙褏^(qū),但我們將內(nèi)存釋放函數(shù)刪掉了,因?yàn)?Rust 自動(dòng)提供了一個(gè) free 函數(shù),專門用于堆內(nèi)存的釋放。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
# 返回值類型指定為 c_void_p,表示萬(wàn)能指針
py_lib.to_uppercase.restype = c_void_p
py_lib.add.restype = c_void_p
ptr1 = py_lib.to_uppercase(
c_char_p("Serpen 老師".encode("utf-8"))
)
ptr2 = py_lib.add(
pointer(c_int(123)), pointer(c_int(456))
)
# 函數(shù)調(diào)用完畢,將地址轉(zhuǎn)成具體的類型的指針
print(cast(ptr1, c_char_p).value.decode("utf-8"))
"""
SERPEN 老師
"""
print(cast(ptr2, POINTER(c_int)).contents.value)
"""
579
"""
# 釋放堆內(nèi)存,直接調(diào)用 free 函數(shù)即可,非常方便
py_lib.free(c_void_p(ptr1))
py_lib.free(c_void_p(ptr2))
以上我們就實(shí)現(xiàn)了指針的傳遞和返回,但對(duì)于整數(shù)、浮點(diǎn)數(shù)而言,直接返回它們的值即可,沒(méi)必要返回指針。
傳遞數(shù)組
下面來(lái)看看如何傳遞數(shù)組,由于數(shù)組在作為參數(shù)傳遞的時(shí)候會(huì)退化為指針,所以數(shù)組的長(zhǎng)度信息就丟失了,使用 sizeof 計(jì)算出來(lái)的結(jié)果就是一個(gè)指針的大小。因此將數(shù)組作為參數(shù)傳遞的時(shí)候,應(yīng)該將當(dāng)前數(shù)組的長(zhǎng)度信息也傳遞過(guò)去,否則可能會(huì)訪問(wèn)非法的內(nèi)存。
我們實(shí)現(xiàn)一個(gè)功能,Rust 接收一個(gè) Python 數(shù)組,進(jìn)行原地排序。
use std::slice;
#[no_mangle]
pub extern "C" fn sort_array(arr: *mut i32, len: usize) {
assert!(!arr.is_null());
unsafe {
// 得到一個(gè)切片 &mut[i32]
let slice = slice::from_raw_parts_mut(arr, len);
slice.sort(); // 排序
}
}
然后 Python 進(jìn)行調(diào)用:
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
# 一個(gè)列表
data = [3, 2, 1, 5, 4, 7, 6]
# 但是列表不能傳遞,必須要轉(zhuǎn)成 C 數(shù)組
# Array_Type 就相當(dāng)于 C 的 int array[len(data)]
Array_Type = c_int * len(data)
# 創(chuàng)建數(shù)組
array = Array_Type(*data)
print(list(array)) # [3, 2, 1, 5, 4, 7, 6]
py_lib.sort_array(array, len(array))
print(list(array)) # [1, 2, 3, 4, 5, 6, 7]
排序?qū)崿F(xiàn)完成,這里的數(shù)組是 Python 傳過(guò)去的,并且進(jìn)行了原地修改。那 Rust 可不可以返回?cái)?shù)組給 Python 呢?從理論上來(lái)說(shuō)可以,但實(shí)際不建議這么做,因?yàn)槟悴恢婪祷氐臄?shù)組的長(zhǎng)度是多少?
如果你真的想返回?cái)?shù)組的話,那么可以將數(shù)組拼接成字符串,然后返回。
use std::ffi::{c_char, CString};
#[no_mangle]
pub extern "C" fn create_array() -> *mut c_char {
// 篩選出 1 到 50 中,能被 3 整除的數(shù)
// 并以逗號(hào)為分隔符,將這些整數(shù)拼接成字符串
let vec = (1..=50)
.filter(|c| *c % 3 == 0)
.map(|c| c.to_string())
.collect::<Vec<String>>()
.join(",");
CString::new(vec).unwrap().into_raw()
}
編譯之后交給 Python 調(diào)用。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
# 只要是需要釋放的堆內(nèi)存,都建議按照 c_void_p 來(lái)解析
py_lib.create_array.restype = c_void_p
# 此時(shí)拿到的就是指針保存的地址,在 Python 里面就是一串整數(shù)
ptr = py_lib.create_array()
# 由于是字符串首字符的地址,所以轉(zhuǎn)成 char *,拿到具體內(nèi)容
print(cast(ptr, c_char_p).value.decode("utf-8"))
"""
3,6,9,12,15,18,21,24,27,30,33,36,39,42,45,48
"""
# 此時(shí)我們就將數(shù)組拼接成字符串返回了
# 但是堆區(qū)的 CString 還在,所以還要釋放掉,調(diào)用 free 函數(shù)即可
# 注意:ptr 只是一串整數(shù),或者說(shuō)它就是 Python 的一個(gè) int 對(duì)象
# 換句話說(shuō) ptr 只是保存了地址值,但它不具備指針的含義
# 因此需要再使用 c_void_p 包裝一下(轉(zhuǎn)成指針),才能傳給 free 函數(shù)
py_lib.free(c_void_p(ptr))
因此雖然不建議返回?cái)?shù)組,但將數(shù)組轉(zhuǎn)成字符串返回也不失為一個(gè)辦法,當(dāng)然除了數(shù)組,你還可以將更復(fù)雜的結(jié)構(gòu)轉(zhuǎn)成字符串返回。
傳遞結(jié)構(gòu)體
結(jié)構(gòu)體應(yīng)該是 Rust 里面最重要的結(jié)構(gòu)之一了,它要如何和外部交互呢?
use std::ffi::c_char;
#[repr(C)]
pub struct Girl {
pub name: *mut c_char,
pub age: u8,
}
#[no_mangle]
pub extern "C" fn create_struct(name: *mut c_char, age: u8) -> Girl {
Girl { name, age }
}
因?yàn)榻Y(jié)構(gòu)體實(shí)例要返回給外部,所以它的字段類型必須是兼容的,不能定義 C 理解不了的類型。然后還要設(shè)置 #[repr(C)] 屬性,來(lái)保證結(jié)構(gòu)體的內(nèi)存布局和 C 是兼容的。
下面通過(guò) cargo build 命令編譯成動(dòng)態(tài)庫(kù),Python 負(fù)責(zé)調(diào)用。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
class Girl(Structure):
_fields_ = [
("name", c_char_p),
("age", c_uint8),
]
# 指定 create_struct 的返回值類型為 Girl
py_lib.create_struct.restype = Girl
girl = py_lib.create_struct(
c_char_p("S 老師".encode("utf-8")),
c_uint8(18)
)
print(girl.name.decode("utf-8")) # S 老師
print(girl.age) # 18
調(diào)用成功,并且此時(shí)是沒(méi)有內(nèi)存泄露的。
當(dāng)通過(guò) FFI 將數(shù)據(jù)從 Rust 傳遞到 Python 時(shí),如果傳遞的是指針,那么會(huì)涉及內(nèi)存釋放的問(wèn)題。但如果傳遞的是值,那么它會(huì)復(fù)制一份給 Python,而原始的值(這里是結(jié)構(gòu)體實(shí)例)會(huì)被自動(dòng)銷毀,所以無(wú)需擔(dān)心。
然后是結(jié)構(gòu)體內(nèi)部的字段,雖然里面的 name 字段是 *mut c_char,但它的值是由 Python 傳過(guò)來(lái)的,而不是在 Rust 內(nèi)部創(chuàng)建的,因此沒(méi)有問(wèn)題。
但如果將 Rust 代碼改一下:
use std::ffi::{c_char, CString};
#[repr(C)]
pub struct Girl {
pub name: *mut c_char,
pub age: u8,
}
#[no_mangle]
pub extern "C" fn create_struct() -> Girl {
let name = CString::new("S 老師").unwrap().into_raw();
let age = 18;
Girl { name, age }
}
這時(shí)就尷尬了,此時(shí)的字符串是 Rust 里面創(chuàng)建的,轉(zhuǎn)成原始指針之后,Rust 將不再管理相應(yīng)的堆內(nèi)存(因?yàn)?into_raw 將所有權(quán)轉(zhuǎn)移走了),此時(shí)就需要手動(dòng)堆內(nèi)存了。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
class Girl(Structure):
_fields_ = [
("name", c_char_p),
("age", c_uint8),
]
# 指定 create_struct 的返回值類型為 Girl
py_lib.create_struct.restype = Girl
girl = py_lib.create_struct()
print(girl.name.decode("utf-8")) # S 老師
print(girl.age) # 18
# 直接傳遞 girl 即可,會(huì)釋放 girl 里面的字段在堆區(qū)的內(nèi)存
py_lib.free(girl)
此時(shí)就不會(huì)出現(xiàn)內(nèi)存泄露了,在 free 的時(shí)候,將變量 girl 傳進(jìn)去,釋放掉內(nèi)部字段占用的堆內(nèi)存。
當(dāng)然,Rust 也可以返回結(jié)構(gòu)體指針,通過(guò) Box<T> 實(shí)現(xiàn)。
#[no_mangle]
pub extern "C" fn create_struct() -> *mut Girl {
let name = CString::new("S 老師").unwrap().into_raw();
let age = 18;
Box::into_raw(Box::new(Girl { name, age }))
}
注意:之前是 name 字段在堆上,但結(jié)構(gòu)體實(shí)例在棧上,現(xiàn)在 name 字段和結(jié)構(gòu)體實(shí)例都在堆上。
然后 Python 調(diào)用也很簡(jiǎn)單,關(guān)鍵是釋放的問(wèn)題。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
class Girl(Structure):
_fields_ = [
("name", c_char_p),
("age", c_uint8),
]
# 此時(shí)返回值類型就變成了 c_void_p
# 當(dāng)返回指針時(shí),建議將返回值設(shè)置為 c_void_p
py_lib.create_struct.restype = c_void_p
# 拿到指針(一串整數(shù))
ptr = py_lib.create_struct()
# 將指針轉(zhuǎn)成指定的類型,而類型顯然是 POINTER(Girl)
# 調(diào)用 POINTER(T) 的 contents 方法,拿到相應(yīng)的結(jié)構(gòu)體實(shí)例
girl = cast(ptr, POINTER(Girl)).contents
# 訪問(wèn)具體內(nèi)容
print(girl.name.decode("utf-8")) # S 老師
print(girl.age) # 18
# 釋放堆內(nèi)存,這里的釋放分為兩步,并且順序不能錯(cuò)
# 先 free(girl),釋放掉內(nèi)部字段(name)占用的堆內(nèi)存
# 然后 free(c_void_p(ptr)),釋放掉結(jié)構(gòu)體實(shí)例 girl 占用的堆內(nèi)存
py_lib.free(girl)
py_lib.free(c_void_p(ptr))
不難理解,只是在釋放結(jié)構(gòu)體實(shí)例的時(shí)候需要多留意,如果內(nèi)部有字段占用堆內(nèi)存,那么需要先將這些字段釋放掉。而釋放的方式是將結(jié)構(gòu)體實(shí)例作為參數(shù)傳給 free 函數(shù),然后再傳入 c_void_p 釋放結(jié)構(gòu)體實(shí)例。
回調(diào)函數(shù)
最后看一下 Python 如何傳遞函數(shù)給 Rust,因?yàn)?Python 和 Rust 之間使用的是 C ABI,所以函數(shù)必須遵循 C 的標(biāo)準(zhǔn)。
// calc 接收三個(gè)參數(shù),前兩個(gè)參數(shù)是 *const i32
// 最后一個(gè)參數(shù)是函數(shù),它接收兩個(gè) *const i32,返回一個(gè) i32
#[no_mangle]
pub extern "C" fn calc(
a: *const i32, b: *const i32,
op: extern "C" fn(*const i32, *const i32) -> i32
) -> i32
{
op(a, b)
}
然后看看 Python 如何傳遞回調(diào)函數(shù)。
from ctypes import *
py_lib = CDLL("../py_lib/target/debug/libpy_lib.dylib")
# 基于 Python 函數(shù)創(chuàng)建 C 函數(shù),通過(guò) @CFUNCTYPE() 進(jìn)行裝飾
# CFUNCTYPE 第一個(gè)參數(shù)是返回值類型,剩余的參數(shù)是參數(shù)類型
@CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
def add(a, b): # a、b 為 int *,通過(guò) .contents.value 拿到具體的值
return a.contents.value + b.contents.value
@CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
def sub(a, b):
return a.contents.value - b.contents.value
@CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
def mul(a, b):
return a.contents.value * b.contents.value
@CFUNCTYPE(c_int, POINTER(c_int), POINTER(c_int))
def div(a, b):
return a.contents.value // b.contents.value
a = pointer(c_int(10))
b = pointer(c_int(2))
print(py_lib.calc(a, b, add)) # 12
print(py_lib.calc(a, b, sub)) # 8
print(py_lib.calc(a, b, mul)) # 20
print(py_lib.calc(a, b, div)) # 5
成功實(shí)現(xiàn)了向 Rust 傳遞回調(diào)函數(shù),當(dāng)然例子舉得有點(diǎn)刻意了,比如參數(shù)類型指定為 i32 即可,沒(méi)有必要使用指針。
小結(jié)
以上我們就介紹了 Python 如何調(diào)用 Rust 編譯的動(dòng)態(tài)庫(kù),再次強(qiáng)調(diào)一下,通過(guò) ctypes 調(diào)用動(dòng)態(tài)庫(kù)是最方便、最簡(jiǎn)單的方式。它和 Python 的版本無(wú)關(guān),也不涉及底層的 C 擴(kuò)展,它只是將 Rust 編譯成 C ABI 兼容的動(dòng)態(tài)庫(kù),然后交給 Python 進(jìn)行調(diào)用。
因此這也側(cè)面要求,函數(shù)的參數(shù)和返回值的類型應(yīng)該是 C 可以表示的類型,比如 Rust 函數(shù)不能返回一個(gè) trait 對(duì)象??傊谡{(diào)用動(dòng)態(tài)庫(kù)的時(shí)候,庫(kù)函數(shù)內(nèi)部的邏輯可以很復(fù)雜,但是參數(shù)和返回值最好要簡(jiǎn)單。
如果你發(fā)現(xiàn) Python 代碼存在大量的 CPU 密集型計(jì)算,并且不怎么涉及復(fù)雜的 Python 數(shù)據(jù)結(jié)構(gòu),那么不妨將這些計(jì)算交給 Rust。
以上就是本文的內(nèi)容,后續(xù)有空我們介紹如何用 Rust 的 PyO3 來(lái)為 Python 編寫擴(kuò)展。PyO3 的定位類似于 Cython,用它來(lái)寫擴(kuò)展非常的方便,后續(xù)有機(jī)會(huì)我們?cè)敿?xì)聊一聊。