iT邦幫忙

2024 iThome 鐵人賽

DAY 22
0
自我挑戰組

從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用系列 第 22

[Day 22] 淺談 Rust 巨集(二):不再重覆製造輪子

  • 分享至 

  • xImage
  •  

在上一篇文章中,我們探討了 Rust 巨集的基本概念及其如何幫助減少程式碼的重複。現在,我們將進一步了解巨集在 Rust 中的分類,並深入討論 程序式巨集 的主題。

一、Rust 巨集的分類

在 Rust 中,巨集主要可以分為三種類型:

  1. 宣告式巨集(Declarative Macros):

    • 這是我們在上一篇文章中主要討論的類型,使用 macro_rules! 定義,通過模式匹配自動生成程式碼。它們對於處理重複的邏輯非常有用。例如,可以用來自動生成函數或方法,簡化重複的程式碼。
  2. 程序式巨集(Procedural Macros):

    • 程序式巨集則是更高級的巨集類型,允許開發者撰寫自定義的程式碼生成邏輯。這類巨集可以進行更靈活的操作,並且能夠處理更複雜的情況。與宣告式巨集相比,程序式巨集能夠更精細地控制生成的程式碼結構和邏輯。
  3. 屬性巨集(Attribute-like Macros):

    • 這是一種特殊類型的程序式巨集,可以用來修飾結構體、函數等,通常用於簡化程式碼或添加特徵。它們可以作為註解來使用,使程式碼更具可讀性。

接下來,我們將專注於 程序式巨集,並探討其工作原理、使用方式及實際應用場景。

二、什麼是程序式巨集?

程序式巨集讓你能用 Rust 程式碼來寫自己的程式碼生成器,和宣告式巨集不一樣,程序式巨集給你更多的自由和靈活性。它允許你寫下任何 Rust 程式碼,然後在需要的時候自動生成新的程式碼。

你可以把它和 Python 的 exec 函數作比較。兩者都能動態生成和執行程式碼,提供豐富的靈活性。不同的是,exec 是在運行時執行的,而程序式巨集則是在編譯時期處理,這意味著生成的程式碼會在編譯過程中進行靜態檢查,讓你的程式更安全、更高效。因此,程序式巨集的強大之處在於它的靈活性和可擴展性,讓你可以針對特定需求隨意調整生成的程式碼。

程序式巨集的簡單範例

步驟 1:創建一個新的 Rust 專案

使用以下命令創建一個新的 Rust 專案:

cargo new hello_macro
cd hello_macro

這將創建一個名為 hello_macro 的新專案,並自動進入該目錄。

步驟 2:更新 Cargo.toml 檔案

打開 Cargo.toml 檔案,添加 proc-macro 特性以啟用程序式巨集支持。在 [lib] 部分下添加以下內容:

[lib]
proc-macro = true

最終的 Cargo.toml 應該看起來像這樣:

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

[lib]
proc-macro = true

[dependencies]

步驟 3:編寫程序式巨集

現在,打開 src/lib.rs 檔案,並將其內容替換為以下程式碼:

// 在 lib.rs 中定義程序式巨集
extern crate proc_macro;  // 引入 proc_macro crate
use proc_macro::TokenStream; // 引入 TokenStream 結構

// 定義一個程序式巨集
#[proc_macro]
pub fn hello_world(_input: TokenStream) -> TokenStream {
    // 生成的程式碼
    "fn hello() { println!(\"Hello, world!\"); }".parse().unwrap()
}

這段程式碼的功能如下:

  • 使用 extern crate proc_macro; 引入 Rust 的內建 proc_macro ,這是處理程序式巨集所需的。
  • 使用 use proc_macro::TokenStream; 引入 TokenStream 結構,這是用來表示 Rust 程式碼結構的類型。TokenStream 是一種資料類別,用來表示 Rust 程式碼的語法樹(syntax tree)。
  • 定義了公共函數,名為 hello_world 的程序式巨集,這個巨集不接受任何輸入,並返回一段 Rust 的程式碼。
  • 其生成的程式碼是 hello 函數,它會打印 "Hello, world!"。

步驟 4:編寫主函數以使用程序式巨集

打開 src/main.rs 檔案,並添加以下程式碼:

// 在 main.rs 中使用程序式巨集
use hello_macro::hello_world; // 從 hello_macro 專案中引入我們的程序式巨集

fn main() {
    hello_world!(); // 調用程序式巨集,這將生成並執行 `hello` 函數
    hello(); // 這將打印 "Hello, world!"
}

步驟 5:運行應用程式

使用以下命令編譯並運行應用程式:

cargo run

如果一切正常,你應該會看到輸出:

Hello, world!

可以看出上面的範例中,當我們用 hello_world!() 呼叫巨集後,它實際上會產生一段 Rust 程式碼,生成的程式碼包含一個被定義的 hello() 函數,以便於後面的程式運行當中都可以使用該函數。這是一個簡單的範例,接下來我們再介紹一些進階的應用方法。

三、程序式巨集的使用場景

程序式巨集可以在多種情境下使用,以下是一些典型的應用範例:

  1. 自動生成程式碼

    • 當你需要生成大量重複的程式碼時,程序式巨集可以大大簡化這個過程。例如,你可以根據結構體的定義自動生成相應的序列化和反序列化程式碼。這不僅提高了開發效率,還降低了出錯的風險。
  2. DSL(領域特定語言)

    • 使用程序式巨集可以實現領域特定語言,這讓開發者能夠在 Rust 中使用更自然的語法來表達特定的操作。比如說,如果你在做數據庫操作,可以自定義一套語法來簡化常見的查詢操作。這樣可以使程式碼更易於理解,並能更好地符合業務需求。
  3. 測試生成

    • 程序式巨集可以用來自動生成測試程式碼,根據不同的輸入自動產生相應的測試案例,這樣可以提升測試的覆蓋率與一致性。通過自動生成測試程式碼,你可以專注於測試邏輯而不必擔心重複的樣板程式碼。
  4. 特徵實現

    • 程序式巨集可以自動為結構體或枚舉生成特徵的實現,減少手動撰寫的麻煩。例如,對於多個結構體需要相同的特徵實現,可以使用程序式巨集來自動化這個過程,節省時間和精力。

四、實際範例:自動生成簡單的加法函數

上面的範例當中,我們並未加入任何參數,現在我們要嘗試在建立程序式巨集的時候,加入參數來動態改變生成的程式碼內容。

步驟 1:編寫程序式巨集

打開 src/lib.rs 檔案,並將其內容替換為以下程式碼:

// 在 lib.rs 中定義程序式巨集
extern crate proc_macro; // 引入 proc_macro crate
use proc_macro::TokenStream; // 引入 TokenStream 結構
use quote::quote; // 引入 quote 巨集,用於生成 Rust 程式碼
use syn::{parse_macro_input, Ident}; // 引入所需的類型

// 定義一個名為 MyAdd 的程序式巨集
#[proc_macro]
pub fn my_add(input: TokenStream) -> TokenStream {
    // 解析輸入的 TokenStream 以獲取函數名稱
    let input_name = parse_macro_input!(input as Ident);

    // 生成加法函數的程式碼
    let gen = quote! {
        fn #input_name(a: i32, b: i32) -> i32 {
            a + b
        }
    };
    gen.into() // 返回生成的程式碼
}
  1. 引入必要的 套件

    • 使用 extern crate proc_macro; 引入 Rust 的內建 proc_macro ,這是編寫程序式巨集的基礎。
    • use proc_macro::TokenStream; 使我們能夠使用 TokenStream 這個資料型別,這是處理傳入和返回程式碼的核心結構。
    • use quote::quote; 引入 quote 巨集,這讓我們能方便地生成 Rust 程式碼。
    • use syn::{parse_macro_input, Ident}; 引入 syn crate 的相關類型,以便於解析輸入的程式碼,特別是獲取函數名稱。
  2. 定義程序式巨集

    • 使用 #[proc_macro] 標記來告訴 Rust 編譯器這是一個程序式巨集。
    • 函數 my_add 接收一個 TokenStream 作為輸入,並返回一個新的 TokenStream
    • let input = parse_macro_input!(input as Ident); 解析傳入的 TokenStream,將其轉換為一個識別符(Ident),這樣我們就可以提取函數名稱。
    • 使用 quote! 巨集生成一段 Rust 程式碼,這段程式碼實現了加法邏輯。具體來說,它將創建一個名為 input_name 的函數,該函數可以接收兩個整數參數,並返回它們的和。

步驟 2:使用程序式巨集

打開 src/main.rs 檔案,並添加以下程式碼以使用我們定義的程序式巨集:

// 在 main.rs 中使用程序式巨集
use hello_macro::my_add; // 引入 hello_macro 專案中自定義的程序式巨集 my_add

// 呼叫程序式巨集來生成名為 add 的加法函數
my_add!(add); // 這將生成一個函數 `add`

fn main() {
    let sum = add(5, 3); // 調用自動生成的加法函數
    println!("5 + 3 = {}", sum); // 輸出結果
}
  1. 引入程序式巨集

    • 使用 use hello_macro::my_add; 將我們剛剛定義的程序式巨集引入到主函數所在的檔案中,這樣就可以在 main 函數中使用它。
  2. 生成加法函數

    • 調用 my_add!(add);,這行程式碼將會觸發 my_add 巨集的執行,生成一個名稱為 add 的加法函數。這個函數的內容是自動生成的,不需要手動編寫。
  3. 使用自動生成的加法函數

    • main 函數中,我們可以直接使用 add(5, 3) 來調用我們生成的加法函數,並將結果存儲在 sum 變數中。
    • 最後,使用 println! 輸出加法的結果,顯示 5 + 3 = 8。這樣,我們就完成了一個完整的流程,利用程序式巨集自動生成了加法函數並進行計算。

步驟 3:運行應用程式

在終端中,使用以下命令編譯並運行應用程式:

cargo run

如果一切正常,你應該會看到類似以下的輸出:

5 + 3 = 8

在這個範例中,我們成功地使用程序式巨集來自動生成一個加法函數。這樣,我們可以在不同的地方調用這個函數,而無需每次都手動編寫加法邏輯。透過這樣的方式,當你需要進行相似的計算時,可以大幅提高開發效率,同時保持程式碼的整潔性。

五、屬性巨集的簡單範例

在這個範例中,我們將定義一個簡單的屬性巨集,用來自動為結構體添加一個打印方法。這樣,我們可以很方便地使用該方法來輸出結構體的內容。透過這個過程,我們將學會如何利用屬性巨集來簡化程式碼並提高開發效率。

步驟 1:定義屬性巨集

首先,打開 src/lib.rs 檔案,並將其內容替換為以下程式碼:

extern crate proc_macro; // 引入 proc_macro crate
use proc_macro::TokenStream; // 引入 TokenStream 結構
use quote::quote; // 引入 quote 巨集,用於生成 Rust 程式碼
use syn::{parse_macro_input, DeriveInput}; // 引入所需的類型

// 定義一個名為 Print 的屬性巨集
#[proc_macro_derive(Print)]
pub fn print_derive(input: TokenStream) -> TokenStream {
    // 解析輸入的 TokenStream,以獲取結構體的名稱
    let input = parse_macro_input!(input as DeriveInput);
    let name = input.ident; // 獲取結構體的識別符

    // 生成打印方法的程式碼
    let gen = quote! {
        impl #name {
            pub fn print(&self) {
                // 直接打印結構體的欄位
                let name = &self.model; // 假設結構體有一個 `model` 欄位
                let year = self.year; // 假設結構體有一個 `year` 欄位
                println!("Model: {}, Year: {}", name, year); // 使用自定義格式打印
            }
        }
    };
    gen.into() // 返回生成的程式碼
}

說明

  1. 引入必要的庫:使用 extern crate proc_macro; 引入 Rust 的內建 proc_macro crate,這是編寫屬性巨集的基礎。接下來引入 TokenStreamquote 庫,以便於生成程式碼結構。

  2. 解析輸入:使用 parse_macro_input!(input as DeriveInput); 來解析輸入的程式碼,以獲取結構體的名稱。這使我們可以針對具體的結構體進行操作。DeriveInput 可以視為一種結構體輸入的表示方式,它專門用來表示輸入的結構體或枚舉,其成分包括名稱識別符(ident)、欄位(fields)、特徵(attributes)等資訊。此處以結構體識別符套用至結構體的實作方法上,這樣一來就會適用於各種名稱的結構體。

  3. 生成打印方法:利用 quote! 來生成打印方法的實現,並命名為函數 print。這個方法將直接從結構體的欄位中讀取資料並格式化輸出。

步驟 2:使用屬性巨集

接下來,我們來看看如何在結構體中使用這個屬性巨集。假設我們有一個 Car 結構體,並希望能夠自動生成一個打印方法。透過使用 #[derive(Print)],我們可以輕鬆實現這一點:

use hello_macro::Print; // 引入程序式巨集的實現

#[derive(Print)] // 使用屬性巨集
struct Car {
    model: String, // 車型
    year: u32,     // 年份
}

fn main() {
    let car = Car {
        model: "Toyota".to_string(),
        year: 2020,
    };

    car.print(); // 使用自動生成的 print 方法來輸出車輛資訊
}
  1. 導入巨集:在 main.rs 中,使用 use hello_macro::Print; 將我們之前定義的屬性巨集引入。這樣,我們就可以在後面的程式碼中使用它。

  2. 定義結構體:使用 #[derive(Print)] 標註 Car 結構體,這樣會自動生成 print 方法,使得我們可以輕鬆地打印這個結構體的內容。

  3. 創建實例:在 main 函數中,我們創建了一個 Car 結構體的實例,並用 car.print(); 調用自動生成的打印方法來輸出車輛資訊。

步驟 3:運行應用程式

在終端中,使用以下命令編譯並運行應用程式:

cargo run

如果一切正常,你應該會看到類似以下的輸出:

Model: Toyota, Year: 2020

在這個範例當中,我們建立了一種屬於 屬性巨集Print 巨集,它可以被使用在對結構體加入一個打印的實作功能,當然這是一個最簡單易懂的例子,可以讓我們看出屬性巨集在引用的時候能為開發所帶來的便利性。

六、程序式巨集的注意事項

雖然程序式巨集功能強大,但在使用時也需要注意以下幾點:

  1. 性能考量

    • 程序式巨集的運行會在編譯時進行,這可能會增加編譯時間。在設計巨集時,應評估其對編譯性能的影響。
  2. 錯誤處理

    • 使用程序式巨集時,錯誤信息可能不如常規程式碼那麼清晰。應該考慮在巨集中添加錯誤處理,以便在出現問題時能給出清晰的提示。
  3. 維護性

    • 雖然程序式巨集可以減少重複程式碼,但過於複雜的巨集可能會影響程式碼的可讀性和維護性。因此,保持巨集的簡單性和易理解性是非常重要的。

七、總結

程序性巨集在 Python 開發者的經驗當中,可以被理解為類似於 exec@ 修飾器的概念,但在 Rust 開發中卻存在一些差異。以下是 Python 和 Rust 的 exec、修飾器與程序性巨集之間的差異:

特性 Python (exec 和修飾器) Rust (程序性巨集)
執行時機 運行時執行 編譯時處理
產生的程式碼 動態生成程式碼 靜態生成程式碼
使用場景 通常用於動態行為與裝飾函數 用於自動化程式碼生成與特徵實現

從此篇與上篇文章內容中,不難看出 Rust 的巨集提供了多種開發效能提升的潛力,但是實務操作上不論是使用宣告式巨集或者程序式巨集,都仍要符合 Rust 開發的語法規則,因此巨集使用可看作是 Rust 程式開發的綜合能力測驗,在越來越熟悉語法規則的情況下,才能用出一朵花來,在這兩篇文章中僅提供簡單的範例,有一個概念式的印象即可,後續更多的延伸實作與練習就讓我們一起加油囉!

附錄、常用的 Rust 內建巨集

在這裡,我們補充一些 Rust 中常用的內建巨集,幫助讀者在開發過程中更有效地使用它們:

常用巨集

  1. println!:

    • 用於輸出到標準輸出,能夠格式化字符串。這個巨集讓我們可以輕鬆地顯示訊息到控制台。
    • 例子: println!("Hello, {}", name); // 這樣就可以打印 "Hello, John"(假設 name 是 "John")。
  2. print!:

    • println! 類似,但不會自動換行。當你不想在訊息後換行時,這個巨集很有用。
    • 例子: print!("This is a message."); // 會打印消息但不換行,適合需要連續輸出的場景。
  3. format!:

    • 用於創建格式化字符串,並返回一個新的字符串。這個巨集非常適合在你想要生成字符串但不立即打印時使用。
    • 例子: let s = format!("Hello, {}", name); // 會創建一個新的字符串 s,內容為 "Hello, John"。
  4. vec!:

    • 用於創建一個新的 Vec,非常方便,讓你可以輕鬆地建立一個可變長度的數組。
    • 例子: let numbers = vec![1, 2, 3, 4]; // 這樣就創建了一個包含 1 到 4 的數字的向量。
  5. assert!:

    • 用於進行斷言測試,若條件不滿足則會造成 panic,這在測試時特別有用,可以幫助你捕捉錯誤。
    • 例子: assert!(x > 0, "x must be positive"); // 如果 x 小於或等於 0,就會報錯並顯示消息。
  6. debug_assert!:

    • assert! 相似,但只在 debug 模式下檢查條件。這讓你在測試期間進行更嚴格的檢查,但在生產模式中不會影響性能。
    • 例子: debug_assert!(x < 100); // 只有在 debug 模式下,若 x 大於或等於 100 會報錯。
  7. todo!:

    • 用於標記尚未實現的功能,會造成 panic 並顯示消息。這在開發過程中非常實用,幫助你標記需要完成的部分。
    • 例子: fn unimplemented_function() { todo!(); } // 這樣就可以清楚地知道這個函數還沒完成。
  8. unimplemented!:

    • 用於標記一個未實現的功能,和 todo! 類似,但語義略有不同。它在代碼中可以明確表示還沒有開發出某個功能。
    • 例子: fn my_function() { unimplemented!(); } // 這行代碼會顯示函數尚未實現的提示。

內建屬性巨集

在 Rust 中,有一些內建的屬性巨集可以用來簡化代碼和提供特定功能。以下是幾個常見的內建屬性巨集的例子:

  1. #[derive]:

    • 用來自動生成結構體或枚舉的特徵實現。這是一個非常常用的屬性巨集,讓我們能夠輕鬆地為結構體實現像 DebugClone 等特徵,省去手動實現的麻煩。
    • 例子:
      #[derive(Debug, Clone, Default, PartialEq)]
      struct Person {
          name: String,
          age: u32,
      }
      
  2. #[repr]:

    • 用來指定結構體或枚舉的記憶體佈局,這對於與 C 語言的外部函式接口(FFI)特別重要,幫助確保跨語言的兼容性。
    • 例子:
      #[repr(C)] // 使用 C 語言的佈局
      struct MyStruct {
          a: i32,
          b: f64,
      }
      
  3. #[allow]#[warn]:

    • 用來控制編譯器的警告,#[allow] 取消特定警告,而 #[warn] 則讓特定的警告變為錯誤,幫助開發者更好地管理代碼質量。
    • 例子:
      #[allow(dead_code)] // 允許未使用的代碼
      fn unused_function() {}
      
      #[warn(unused_variables)] // 對未使用的變量產生警告
      fn example() {
          let x = 42; // 這裡會產生警告
      }
      
  4. #[inline]:

    • 用來建議編譯器在適當的情況下內聯函數,這可以提高性能,但也可能導致代碼膨脹。
    • 例子:
      #[inline]
      fn add(x: i32, y: i32) -> i32 {
          x + y
      }
      
  • 作用:#[inline] 是一個建議性指令,告訴編譯器「如果可能的話,請將這個函數內聯(inline)」。內聯函數的概念是,編譯器會將函數的實現插入到函數被調用的地方,而不是通過函數調用來執行。這樣可以消除函數調用的開銷,從而提升性能。

  • 使用場景:通常用在經常被調用的簡單函數上,例如數學運算或是小的輔助函數。內聯有助於減少函數調用的開銷,特別是在迴圈中頻繁調用的情況下。

  • 注意事項:內聯不保證一定會發生;編譯器會根據其內部的優化策略決定是否內聯。過多的內聯可能導致編譯後的程式碼體積增大,反而影響性能,所以應謹慎使用。

  1. #[cfg]:
    • 用來根據配置條件編譯代碼,這在編寫可移植的庫時非常有用,能根據目標平台選擇性地包含代碼。
    • 例子:
      #[cfg(target_os = "windows")]
      fn windows_specific_function() {
          // Windows 專用的實現
      }
      
      #[cfg(target_os = "linux")]
      fn linux_specific_function() {
          // Linux 專用的實現
      }
      
  • 作用:#[cfg] 是一個條件編譯巨集,它允許你根據不同的配置條件來包含或排除某些程式碼。在這個例子中,target_os = "windows" 和 target_os = "linux" 是條件檢查,根據編譯的目標操作系統來選擇性地編譯某個函數。

  • 使用場景:當你在寫一個跨平台的應用程式時,可以使用 #[cfg] 巨集來針對不同的操作系統提供不同的實現。例如,某些函數可能只在 Windows 系統下有效,而在 Linux 系統下則不適用。

  • 如何工作:當你使用 cargo build --target 指令編譯你的程式時,Rust 編譯器會根據指定的目標環境檢查這些條件。這樣,只有符合條件的函數才會被編譯,這有助於保持程式碼的清晰和可讀性,並確保你的程式在不同平台上能正常運作。

這些內建的屬性巨集可以幫助你編寫更簡潔、更具可讀性的 Rust 代碼,並能在特定場景下提高開發效率。希望這些例子能幫助你在日常開發中更好地利用 Rust 的強大功能!


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

尚未有邦友留言

立即登入留言