iT邦幫忙

2024 iThome 鐵人賽

DAY 20
0

Rust 以其高效能和記憶體安全聞名,而 Python 則因其簡單易用和廣泛的套件支持成為許多開發者的首選語言。在某些情況下,開發者可能希望將這兩個語言結合使用,利用 Python 的靈活性和 Rust 的高效能。這就需要用到 FFI(Foreign Function Interface,外部函數介面)。

在這篇文章中,我們將探討如何透過 FFI 讓 Rust 與 Python 進行雙向互操作。具體來說,我們會展示如何在 Rust 中調用 Python 函數,以及如何從 Python 調用 Rust 函數,實現兩者互通。


一、FFI 是什麼?

當我們提到 FFI(Foreign Function Interface,外部函數介面),其實是在說一個能夠讓不同程式語言「彼此溝通」的橋樑。簡單來說,FFI 是一個讓一個程式語言可以「借用」另一個語言所寫的函數或庫的技術。例如,使用 FFI,你可以在 Python 程式裡運行用 Rust 寫的高效能函數,或者從 Rust 調用用 Python 寫的函數。

背後的原理

FFI 的原理很簡單,就是把一個語言的函式轉換成另一個語言可以理解的格式。大部分程式語言都會把函式轉換成和 C 語言兼容的形式,因為 C 語言的 ABI(應用程式二進位介面)是大家普遍遵守的標準。所以,當我們在講 FFI 時,基本上就是利用這個 C 語言的標準,讓不同語言能夠順利交流。

來個更生活化的比喻:想像 Python 是一位擅長多樣料理的廚師,能快速做出很多不同的菜色;但當需要非常精細的刀工時,他可能會請專業的「刀工師傅」(也就是 Rust)來幫忙。這兩位專家怎麼配合呢?就是透過 FFI 這個「溝通橋梁」:廚師把需要處理的食材交給刀工師傅,師傅切完後再把食材交還給廚師,達到各司其職的作用,這樣的合作方式,就是 FFI 的運作模式!

理解了一些關於FFI的概念後,下面我們進入實際操作的部分。

二、為什麼要用 FFI 來互通 Rust 和 Python?

通過 FFI,就像大谷翔平可以玩二刀流,在需要高效能的地方使用 Rust ,在能夠使用 Python 函式庫支援的地方使用 Python ,讓 Python 的運算限制不再是開發的極限,也可以讓 Python 的廣大資源輔助 Rust 應用開發,這麼棒還有什麼理由不用呢,就讓我們看看簡單的範例吧。

三、Python 調用 Rust 函數

1. 建立 Rust 專案

首先,我們建立一個 Rust 專案,並定義我們要讓 Python 調用的函數。

cargo new rust_python_ffi --lib
cd rust_python_ffi

2. 配置 Cargo.toml

Cargo.toml 中,我們需要配置專案,將其編譯為共享套件,這樣 Python 才能加載並調用該套件。我們還需要加入 libc 來幫助處理與 C 的互通。

[package]
name = "rust_python_ffi"
version = "0.1.0"
edition = "2021"

[dependencies]
libc = "0.2"

[lib]
crate-type = ["cdylib"]

在這段配置中,有幾個關鍵點需要注意:

  1. crate-type = ["cdylib"]:這一行的作用是告訴 Rust 編譯器將專案編譯為一個C 動態庫cdylib),這樣 Python 或其他語言才能透過 FFI 來加載並調用 Rust 中的函數。如果沒有這行設定,Rust 會將專案編譯為靜態庫或執行檔,這樣就無法進行跨語言的互操作。

  2. libc 依賴libc 是一個常見的 Rust crate,它提供了與 C 標準庫的接口,幫助我們在 Rust 中更方便地與 C 語言互動。由於 Python 的 FFI 介面基於 C 語言的 ABI,因此我們需要 libc 來處理一些底層的數據型別轉換,如 c_intc_char 等。

  3. edition = "2021":Rust 的 Edition 是語言進化過程中的一種版本標識,它定義了該專案使用的語言功能和編譯器行為。這裡使用的是 Rust 2021 Edition,它包含了最新的語言改進和功能。這並不直接影響 FFI,但確保我們能夠使用到 Rust 最新的語言特性。

這些配置完成後,我們便可以開始編寫 Rust 函數並將其導出為共享庫,供 Python 程式加載和使用。

3. 編寫 Rust 函數

接下來,我們在 src/lib.rs 中編寫一個簡單的數學函數,並將其導出為 FFI 函數供 Python 調用。

明白了,我會在程式碼中加上簡單註解,並且在程式碼後面附上詳細的文字說明:

use libc::c_int; // 使用 C 的整數類型
use std::ffi::CString; // 使用 CString 處理 C 字串
use std::os::raw::c_char; // 使用 C 的字元類型

#[no_mangle] // 確保函數名稱不被編譯器修改
pub extern "C" fn add_two_numbers(a: c_int, b: c_int) -> c_int {
    a + b // 返回兩個整數相加的結果
}

#[no_mangle] // 確保函數名稱不被編譯器修改
pub extern "C" fn hello_from_rust() -> *const c_char {
    let hello = CString::new("Hello from Rust!").expect("CString creation failed"); // 創建 CString
    hello.into_raw() // 返回 CString 的指針,供其他語言使用
}

說明

  1. use libc::c_int

    • 在 FFI 中,我們需要與 C 語言進行互操作,而 C 語言的數據型別與 Rust 的數據型別有所不同。這行程式碼引入了 c_int,它是一個與 C 語言中的 int 相對應的數據型別。當我們在 Rust 中需要傳遞整數給 C 語言的函數或從 C 語言接收整數時,必須使用 c_int 來保證數據的正確性。
  2. use std::ffi::CString

    • CString 是 Rust 標準庫中的一個結構體,用於創建 C 語言格式的字串。C 語言的字串是以 null 結尾的字元陣列,而 Rust 的字串(&str)是 UTF-8 編碼且不以 null 結尾。因此,我們需要使用 CString 來將 Rust 的字串轉換成 C 語言格式,以便能夠通過 FFI 傳遞給其他語言使用。
  3. use std::os::raw::c_char

    • c_char 是 C 語言中的字元型別,它與 Rust 中的 char 不同。當我們要將 Rust 的字串傳遞給 C 語言或從 C 語言接收字串時,需要使用 c_char 來表示 C 語言中的單個字元。這行程式碼引入了 c_char,以便在 FFI 函數中使用它來處理 C 語言的字串。
  4. #[no_mangle]

    • Rust 編譯器會對函數名稱進行名稱修飾(name mangling),這是為了在編譯過程中保證函數名稱的唯一性。但是,當我們希望將這個函數導出給其他語言(如 Python 或 C)調用時,需要保持函數名稱不變。#[no_mangle] 指示編譯器不要對函數名稱進行修飾,這樣函數在編譯後的名稱仍然是我們在程式碼中定義的原始名稱。
  5. pub extern "C" fn add_two_numbers(a: c_int, b: c_int) -> c_int

    • 這行定義了一個外部 C 語言風格的函數 add_two_numbers,它接受兩個 C 語言的整數(c_int)作為參數,並返回它們的和。pub 關鍵字表示這個函數是公開的,任何其他模組都可以調用它。extern "C" 告訴編譯器這是一個外部函數,它使用 C 語言的調用約定,這樣我們可以通過 FFI 將它導出給 C 或 Python 調用。
  6. pub extern "C" fn hello_from_rust() -> *const c_char

    • 這個函數定義了一個返回 C 語言格式字串的外部函數。它使用了 CString 來創建一個包含 "Hello from Rust!" 的 C 語言字串,並將這個字串的裸指針返回。這樣,其他語言可以通過這個指針來訪問這個字串。CString::new 用來創建字串,expect 方法用來處理可能出現的錯誤(例如字串中包含 null 字元)。hello.into_raw()CString 轉換為裸指針,這是必需的,因為我們需要將這個字串傳遞給其他語言使用。

4. 編譯 Rust 動態庫

接下來,我們要將 Rust 函數編譯成跨語言的動態庫

cargo build --release

當我們執行 cargo build --release 指令後,Rust 會將專案編譯成為一個動態共享庫。這些動態庫的名稱和格式取決於操作系統的類型:在 Linux 系統上,編譯結果會是 librust_python_ffi.so;而在 Windows 系統上,則會是 librust_python_ffi.dll

編譯完成後的專案結構理論圖:

rust_python_ffi/
├── Cargo.toml            # 專案的配置文件
├── src/
│   └── lib.rs            # Rust 函數定義
└── target/
    └── release/
        ├── librust_python_ffi.so  # Linux 上生成的動態共享庫
        └── librust_python_ffi.dll # Windows 上生成的動態共享庫

5. Python 調用 Rust 函數

接下來讓我們建立一個 rustFunction.py 並使用 Python 的 ctypes 模組來調用這些 Rust 函數:

import ctypes

# rust_lib = ctypes.CDLL('./target/release/rust_python_ffi.so') # Linux
rust_lib = ctypes.CDLL('./target/release/rust_python_ffi.dll') # Windows

# 調用 add_two_numbers
rust_lib.add_two_numbers.argtypes = [ctypes.c_int, ctypes.c_int]
rust_lib.add_two_numbers.restype = ctypes.c_int
result = rust_lib.add_two_numbers(5, 10)
print(f"5 + 10 = {result}")

# 調用 hello_from_rust
rust_lib.hello_from_rust.restype = ctypes.c_char_p
greeting = rust_lib.hello_from_rust()
print(greeting.decode('utf-8'))

我們直接嘗試執行這個 Python 程式,會得到以下結果

5 + 10 = 15
Hello from Rust!

https://ithelp.ithome.com.tw/upload/images/20241003/20121176spHgjXQRm6.png

這就是我們使用 Python 執行 Rust 函數的一個簡單範例,接下來我們攻守互換。


四、在 Rust 中調用 Python 函數

1. 安裝 PyO3

首先,讓我們重新建立一個專案

cargo new rust_ffi_pyo3

準備 Rust 專案並安裝 PyO3,我們可以直接在 Cargo.toml 中加入依賴:

[dependencies]
pyo3 = { version = "0.22.3", features = ["auto-initialize"] }

這樣就可以讓 Rust 自動初始化 Python 環境,並準備好執行 Python 程式碼。

2. 在 Rust 中執行 Python 程式碼

現在,讓我們來看看如何在 Rust 程式中調用 Python 的 print 函數。這裡的目標是:讓 Rust 執行 Python 程式碼,就像平常在 .py 文件中執行一樣,我們建立一個 main.rs ,並寫入下面範例。

use pyo3::prelude::*; // 引入 PyO3 的功能

fn main() -> PyResult<()> {
    // 這裡的 Python::with_gil 就像進入 Python 環境一樣,確保我們可以執行 Python 程式碼
    Python::with_gil(|py| {
        // 在 Python 中執行內建的 print 函數,顯示 "Hello from Python!"
        py.eval_bound("print('Hello from Python!')", None, None)?;
        Ok(())
    })
}

說明

  1. Python::with_gil

    • 這相當於告訴 Rust:「我要進入 Python 環境,準備好執行 Python 程式了。」這會自動處理 Python 和 Rust 之間的資源鎖問題,確保兩者之間的交互是安全的。
  2. py.eval_bound()

    • 這段程式碼就像是在 Python 終端中執行 print('Hello from Python!')。簡單來說,這行程式碼是把一段 Python 程式碼字串直接丟給 Python 解釋器來執行。

3. 執行 Rust 程式

我們可以像平常一樣用 Rust 執行這段程式碼:

cargo run

結果就會在終端中顯示:

Hello from Python!

4. 調用自定義 Python 函數

我們還可以在 Rust 中調用 Python 的自定義函數。假設我們有一個簡單的 Python 程式 greet.py,它包含一個問候函數:

# greet.py
def greet(name):
    print(f"Hello, {name}!")

建立的 greet.py 位置如下

rust_ffi_pyo3/
├── Cargo.toml
├── src/
│   └── main.rs
└── greet.py  # Python 文件放在項目根目錄

在 Rust 中,我們可以調用這個 Python 函數,就像平常在 Python 中調用函數一樣:

use pyo3::prelude::*; // 引入 PyO3 的基本功能
use pyo3::types::PyModule; // 引入 PyModule,用於操作 Python 模組
use std::fs; // 引入標準庫的檔案系統模組,用於讀取檔案

fn main() -> PyResult<()> {
    Python::with_gil(|py| { // 進入 Python 的 GIL(全域鎖),確保安全地執行 Python 程式碼
        // 讀取 greet.py 文件,並將其內容以字串的形式載入
        let code = fs::read_to_string("./greet.py").expect("Failed to read greet.py");

        // 從讀取到的 Python 程式碼字串中創建一個 Python 模組
        let greet_module = PyModule::from_code(py, &code, "greet.py", "greet")?;

        // 獲取 Python 模組中的 greet 函數,並調用該函數,傳入 "Rust" 作為參數
        greet_module.getattr("greet")?.call1(("Rust",))?;

        Ok(())
    })
}

說明

  1. use pyo3::prelude::*use pyo3::types::PyModule

    • 引入 PyO3 的基本功能和 PyModule 類型,用於與 Python 互動。
  2. fs::read_to_string("./greet.py")

    • 從當前目錄讀取 greet.py 文件的內容,將其讀入為字串。如果讀取失敗,則拋出錯誤訊息 "Failed to read greet.py"。
  3. PyModule::from_code_bound

    • 將讀取到的 greet.py 檔案內容作為 Python 程式碼執行,並創建一個 Python 模組。
  4. getattr("greet")

    • 從 Python 模組中取得名為 greet 的函數。
  5. call1(("Rust",))

    • 調用 greet 函數並傳入一個參數 "Rust"

當你運行這段 Rust 程式時,結果會顯示:

Hello, Rust!

五、結論

用 FFI 把 Python 的自由度和 Rust 的高效能結合起來,是程式開發中的夢幻組合,你可以把重度運算交給 Rust 來加速,讓它跑得飛快,而複雜、靈活的邏輯處理,則交給 Python 來完成。這樣兩者互補,幫助你打造出高效又強大開發體驗。

不過,使用 FFI 時也要記得注意:數據類型的轉換要處理得當,記憶體管理也不能疏忽。而且,GIL(全域鎖)可能會在多執行緒下拖慢效能,要適時解決這些潛在問題。簡單來說:Rust 負責粗重工作(例如效能瓶頸的計算),Python 則掌控靈活的部分,這樣你就能享受兩者的優勢。

還等什麼呢?趕快開啟你的 FFI 之旅,感受 Rust 和 Python 合作的魔力吧!


上一篇
[Day 19] Rust 與 React 結合:建立簡單的 Web 應用
下一篇
[Day 21] 淺談 Rust 巨集(一):自建的程式工廠
系列文
從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言