Rust 的宣告宏機制

Koshkaaa發表於2024-04-12

背景

Rust 宏程式設計是這門語言比較有趣但又難以掌握的知識點,而且在大多數專案中使用頻度並不算高。本文嘗試性地總結 Rust 宣告宏的原理和使用,目的是為了能更好地看懂一些專案中 macro_rules! 的邏輯。

所謂宏程式設計,我理解本質上就是超程式設計(據說最早源自 LISP 的「Code is Data, Data is Code」的提法)。超程式設計可以讓開發者將原生語言寫的程式碼作為資料輸入,經過自定義的邏輯,重新輸出為新的程式碼並作為整體程式碼的一部分。這個過程一般在編譯時期完成(對於編譯型語言來說),所以讓人覺得這是一種神奇的 “黑魔法”。

其他程式語言常見的超程式設計方式有:

  • Go 的 ast 包和 go generate 機制:Go 沒有顯示提供超程式設計的相應機制,轉而提供了一些相對不那麼優雅的機制來實現類似於超程式設計的效果。比如 ast 包可以暴露 Go 程式的語法樹,從而讓開發者可在編譯時期對原始碼進行修改或者根據模版生成其他型別程式碼。
  • C++ 的 Template 程式設計:據說 C++ 的 Template 程式設計是圖靈完備的,可在編譯時期完成很多讓人瞠目結舌的邏輯。由於 C++ 的 Template 程式設計非常複雜且難以掌握,所以易用性非常差。
  • C 語言的宏:這估計是大多數程式設計師對於宏的最初體驗。個人覺得, C 語言中的宏本質上是發生在預處理過程的文字替換,是一種非常簡單原始的超程式設計機制。而正是這種原始能力,導致 C 語言的宏結合編譯器的各種擴充套件充滿了各種奇技淫巧,可讀性和可除錯性都非常差,而且稍不小心就很容易寫出錯誤的宏。

不同的程式語言對超程式設計的支援不太一樣,有些可能有顯式宏的概念(比如 C 和 Rust),有些可能只是提供一些工具包(比如 Go)讓使用者自行組裝邏輯,但是其核心思想還是超程式設計的範疇。

由於 Rust 提供了更加智慧的宏機制,所以 Rust 可以實現更高階的超程式設計。我們可以在編譯時期將程式碼當成資料一樣去改寫,所以我們等於變相地擴充套件了 Rust 標準語言編譯器的能力,從而去支援一些原本編譯器還不支援的語言特性,比如 Rust 的非同步機制在未以 asyc/await 等關鍵詞引入語言標準時,社群就以 Rust 宏的形式提供對非同步能力的支援。這其實是一種非常好的語言擴充套件手段。Rust 的語言開發者可以先用宏來擴充套件語言特性,驗證某種特性是否可行,以期獲得社群使用者的反饋,從而再決定是否納入到語言標準中。

Rust 中宏的分類

目前 Rust 支援兩種型別的宏:

  1. 宣告宏(declarative macro):可認為就是 macro_rules!,也是本文敘述的重點
  2. 過程宏(procedural macro):過程宏又可以繼續分為函式宏屬性宏派生宏 3 種,形式上不太一樣,但是本質上都是將宏的編寫變成了一個類似於編寫函式的過程,將輸入的 TokenStream 處理轉化為輸出的 TokenStream。

Rust 的宏本質上就是一種語法擴充套件,從形式上看,Rust 的宏有以下 4 種形式:

  1. #[ $arg ]:比如 #[derive(Clone)]#[no_mangle] 等;
  2. #! [ $arg ]:比如 #![allow(dead_code)] 等;
  3. $name ! $arg:比如 println!("Hi")concat!("a", "b") 等;
  4. $name ! $arg0 $arg1:比如 macro_rules! dummy { () => {}; },其實就只有 macro_rules!

1 和 2 可認為是屬性宏,即過程宏的一種,而 3 和 4 可理解為同一種類。採用第 3 種形式,Rust 還提供了不少內建宏,比如 include!file!line! 等。內建宏是硬編碼在 rustc 中實現的。

快速入門體驗

一個最簡單的 my_vec!

按照大多數文章的做法,我們也來一步步實現一下我們的 vec!,此時我們將其命名為 my_vec!。整個專案的目錄如下所示(使用 2018 版本):'

.
├── Cargo.lock
├── Cargo.toml
├── examples
│   └── main.rs
├── src
│   ├── lib.rs
│   └── my_vec.rs

我們將在 my_vec.rs 中實現一個最簡單的宏。#[macro_export] 註解意味著宏將被引入作用域可見,缺少了這個註解宏則不能被引入作用域。這個宏最終會轉化成一句 println!,相當於我們在宏裡呼叫其他宏:

#[macro_export]
macro_rules! my_vec {
    () => {
        println!("Hello, 'my_vec!'");
    };
}

然後在 examples/main.rs 中使用我們的 my_vec!。我們之所以用以下這 3 種形式,其實是想表達 my_vec! 之後可以跟 []/{}/()

fn main() {
    my_vec![];
    my_vec! {};
    my_vec!();
}

編譯構建執行:

$ cargo run --example main
Hello, 'my_vec!'
Hello, 'my_vec!'
Hello, 'my_vec!'

一切正常!看起來非常好。可是,我們呼叫 my_vec! 到底發生了什麼?宏一個非常重要的概念就是展開,本質上就是在對應呼叫點展開成真正的 Rust 程式碼。Rust 的宏將在 Rust 程式的語法分析之後進行遞迴展開(如果宏裡面呼叫其他宏,將會被進一步展開),最終的效果是生成合法的 Rust 程式碼。

為了能更方便地觀察到宏展開的過程,我們可以使用一個 Cargo 外掛 cargo-expand 工具:

# 直接安裝 cargo-expand 外掛
$ cargo install cargo-expand

安裝完成後,我們執行:

$ cargo expand --example main

我們可以看到如下輸出(省去一些 use 語句):

fn main() {
    {
        ::std::io::_print(::core::fmt::Arguments::new_v1(
            &["Hello, \'my_vec!\'\n"],
            &[],
        ));
    };
    {
        ::std::io::_print(::core::fmt::Arguments::new_v1(
            &["Hello, \'my_vec!\'\n"],
            &[],
        ));
    };
    {
        ::std::io::_print(::core::fmt::Arguments::new_v1(
            &["Hello, \'my_vec!\'\n"],
            &[],
        ));
    };
}

很明顯,my_vec! 被展開成了一個 {...} 語句,而 {...} 中透過呼叫 std::io::_print() 實現了標準輸出的列印功能。expand 不僅展開了 my_vec!,也把 println! 展開了。

cargo-expand 其實只是將對 rustc 的呼叫包裝成了一個 Cargo 外掛:假如我們有一個 Rust 檔案,想對其進行宏展開,最原始簡單的方式是:

$ rustc +nightly -Zunpretty=expanded use_macro.rs

雖然 Rust 還提供了其他一些除錯宏的方式,但是展開宏是最直觀簡單的方式。

macro_rules! 的基本結構

macro_rules! 最基本的結構如下所示:

macro_rules! $name {
    $rule0 ;
    $rule1 ;
    //...
    $ruleN ;
}

至少得有一條規則,且最後一條規則後面的分號可被省略。

而每一條 rule 其實就是模式匹配程式碼擴充套件生成

( $matcher ) => { $expansion };

當一個宏被呼叫時,將根據 macro_rules! 寫的模式逐一進行匹配(其中 $expansion 最外層用 () 也可以),一旦匹配,將擴充套件生成 {...} 的程式碼。

回到上面的例子,我們的 my_vec! 只有一條匹配 rule,那就是:

() => { println!("Hello, 'my_vec!'"); };

() 表示為匹配空輸出。因此,當我們使用 my_vec![] 時,由於是一個空輸入,所以對應的宏呼叫將被展開成 print 操作。

瞭解到這裡,我們其實就能基本知道 macro_rules! 的基本結構:寫一系列匹配規則來轉化 Rust 程式碼

由於當 vec![] 為空輸入時,則表示建立一個新的 vector,所以我們可將我們 my_vec! 改寫為:

#[macro_export]
macro_rules! my_vec {
    () => {
        std::vec::Vec::new()
    };
}

稍微注意一點:對 crate 的使用最好帶上完整的路徑。因為宏將在呼叫之處被展開,所以我們無法感知當前呼叫環境是否已經有了相關的 use 語句。

捕獲元變數

$matcher 可以基於某種語法類別匹配輸入,並將結果捕獲到元變數(metavariable )中使用。比如,當我們想實現類似 vec![0; 10] 的功能時,如何寫這個 $matcher

我們仔細觀察呼叫引數: 0; 10 ,其中 ; 左邊是元素初始值 0; 右邊是個數 10。那麼匹配似乎可以為:

($elem ; $n) => { ... }

但是這種描述是不精確的,我們還需要加上捕獲方式,即捕獲的是一個表示式:

($elem:expr ; $n:expr) => { ... }

所以此時整個 $matcher 含義為:匹配到以 ; 分隔的兩個表示式; 左邊的表示式的值將被捕獲匹配到 $elem; 右邊的表示式的值將被捕獲匹配到 $n。後續的 $expansion 中將可以直接使用 $elem$n,如下所示:

( $elem: expr ; $n: expr ) => {
    std::vec::from_elem($elem, $n)
};

expr 可稱為片段型別,更詳細的分類可參考文件

重複

標準的 vec! 一般是如下方式使用:

let v = vec![1, 2, 3];

如果我們用人腦模擬一下,vec![1, 2, 3] 應該被展開為:

let v = {
    let mut v = std::vec::Vec::new();
    v.push(1);
    v.push(2);
    v.push(3);
    v
};

這段語句如何用宏模式來表達呢 ?首先我們先看 $matcher 部分,即 1, 2, 3。像這種需要匹配一系列 token 的模式,我們需要使用宏裡的重複匹配模式。比如要想匹配 1,2,3,可以寫成:

( $( $elem:expr ),*) => { ... }

$(...),* 模式,而 (...) 則是和上節中變數捕獲的方式是一樣的,即 $elem:expr, 表示為分隔符,* 表示匹配 0 或者多次。這樣一來,$( $elem:expr ),* 表示為:匹配 0 或者多次以逗號分隔的表示式,並將變數捕獲到 $elem

其實,重複捕獲的一般形式為 $ ( ... ) sep rep,這裡:

  • (...) 就是反覆匹配的模式;

  • sep 是可選的分隔標記,常見的有 ,;

  • rep
    

    是必須的重複運算子,可以為:

    • ?:最多一次重複;
    • *:0 次或多次重複;
    • +:1 次或多次重複;

解決了 $matcher,這時候我們可以用類似的方式寫 $expansion。重複匹配模式其實隱含提供了一種迴圈模式,可以生成多條符合某種模式的語句,比如我們可以用:

$( v.push($elem); )*

這句程式碼將會根據匹配到的 $elem 來生成 0 句或者多句 vector 的 push 語句。

因此完整的匹配邏輯可為:

// 匹配類似於 vec![1, 2, 3] 的輸入
( $( $elem:expr ),* ) => {
    // 由於我們將生成多條語句,因此必須再用 {} 包起來
    {
        let mut v = std::vec::Vec::new();
        $( v.push($elem); )*
        v
    }
};

但是,如果末尾還有 ,,比如 my_vec![1,2,3,] 這種情況,我們的匹配模式還能符合嗎 ?

很遺憾,$( $elem:expr ),* 只能匹配以 , 分隔的表示式,無法匹配結尾還有一個 , 的場景。但其實,我們只要稍微基於這個例子再增加一個匹配模式即可:

// 匹配類似於 vec![1, 2, 3] 的輸入
( $( $elem: expr ),* ) => {
    // 由於我們將生成多條語句,因此必須再用 {} 包起來
    {
        let mut v = std::vec::Vec::new();
        $( v.push($elem); )*
        v
    }
};
// 匹配類似於 vec![1, 2, 3, ] 的輸入
( $( $elem: expr, )* ) => {
    // 由於我們將生成多條語句,因此必須再用 {} 包起來
    {
        let mut v = std::vec::Vec::new();
        $( v.push($elem); )*
        v
    }
};

我們將 $( $elem:expr ),* 修改為了 $( $elem:expr, )*,從而支援尾部帶 ,。可是看上面的例子,會感覺程式碼重複度比較高。由於宏可以遞迴呼叫自己,所以我們可以在最後一個匹配模式中自己再次呼叫 my_vec!,比如:

// 匹配類似於 vec![1, 2, 3] 的輸入
( $( $elem: expr ),* ) => {
    // 由於我們將生成多條語句,因此必須再用 {} 包起來
    {
        let mut v = std::vec::Vec::new();
        $( v.push($elem); )*
        v
    }
};

// 匹配類似於 vec![1, 2, 3, ] 的輸入
( $( $elem: expr, )* ) => {
    // 遞迴呼叫
    my_vec![ $( $elem ),* ]
};

完整的例子

讓我們將上述的幾個例子拼在一起,就可以得到完整的 my_vec!

#[macro_export]
macro_rules! my_vec {
    // 匹配空輸入,建立一個新的 vector
    () => {
        std::vec::Vec::new()
    };

    // 匹配類似於 vec![0; 10] 的輸入
    ( $elem: expr ; $n: expr ) => {
        std::vec::from_elem($elem, $n)
    };

    // 匹配類似於 vec![1, 2, 3] 的輸入
    ( $( $elem: expr ),* ) => {
        // 由於我們將生成多條語句,因此必須再用 {} 包起來
        {
            let mut v = std::vec::Vec::new();
            $( v.push($elem); )*
            v
        }
    };

    // 匹配類似於 vec![1, 2, 3, ] 的輸入
    ( $( $elem: expr, )* ) => {
        // 遞迴呼叫
        my_vec![ $( $elem ),* ]
    };
}

此時我們可以像使用 vec! 一樣地使用 my_vec!

此時我們也許會好奇,真實的 vec! 是怎麼實現的呢 ?我們可以從程式碼中一窺究竟:

macro_rules! vec {
    () => (
        $crate::vec::Vec::new()
    );
    ($elem:expr; $n:expr) => (
        $crate::vec::from_elem($elem, $n)
    );
    ($($x:expr),*) => (
        $crate::slice::into_vec(box [$($x),*])
    );
    ($($x:expr,)*) => (vec![$($x),*])
}

其中 $crate 是一個特殊的元變數,用來指代當前 crate。

透過對比實現,我們其實可以發現二者的主體邏輯幾乎都是一致的。但對於 vec![1,2,3] 這種型別的匹配,vec! 採用了更高效into_vec() 方法,將一個 [T] 切片轉化為相應的 vector,而不是簡單的像 my_vec! 這樣先建立 vector 後多次進行 push 動作。

什麼時候使用宣告宏

在大多數時候,我們可以將一些冗餘的程式碼用宣告宏的形式進行邏輯上的封裝。從實現功能的角度來看,macro_rules! 與普通函式在某些方面是比較類似的。如果遇到既可以用普通函式又可以用 macro_rules!宏進行邏輯抽象的場景,個人覺得應該優先使用普通函式,因為這樣可讀性和可維護性更強。但是,由於 macro_rules! 可以接觸到程式碼語法分析後的標記樹,因此可以實現不少用普通函式無法實現的邏輯,一個典型的例子就是在 ? 還未引入 Rust 標準中時,社群就提供了一個 try! 來實現類似的能力。讓我們來看看 try! 想實現一個什麼樣的功能,以及為什麼用普通函式無法實現。

在 Rust 的 API 設計中,我們大多會使用 Result<T, E> 來表示一個函式返回值

如果返回值是 Ok(T),則表示函式呼叫成功;如果返回值是 Err(E),則表示呼叫失敗,其中 E 大多數時候是實現了 std::error::Error trait 的物件。

在一個函式中,如果我們連續呼叫多個這樣的 API,並且希望如果返回值為 Ok(T)取出 T 並繼續呼叫下一個 API;如果某個 API 返回 Err(E),則立即返回錯誤並結束當前函式的呼叫。比如:

use std::fs::File;
use std::io::{self, Read};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut f = {
        match File::open("hello.txt") {
            Ok(file) => file,
            Err(err) => return Err(From::from(err)),
        };
    };

    let mut s = String::new();
    let num_read = match f.read_to_string(&mut s) {
        Ok(num) => num,
        Err(err) => return Err(From::from(err)),
    };

    println!("read {} bytes", num_read);

    Ok(())
}

很明顯,這裡的錯誤處理存在一個 pattern:

match Result {
    Ok(val) => val,
    Err(err) => return Err(err)
}

當我們想用普通函式(稱之為 try() )去實現這個 pattern 的時候,會發現竟然實現不了:

  • 當函式輸入是 Ok(T) 時,返回 T;當函式輸入是 Err(err),返回 Err(err),但這二者卻不是同一個型別,Rust 此時沒法正常地推斷返回值的具體型別;
  • try()return 僅結束了對 try() 的呼叫,而我們是希望從呼叫 try() 的那個父親函式中直接返回錯誤,這在普通函式中是無法辦到的;

這時候就是 macro_rules! 的用武之地了,讓我們寫一個 my_try!

#[macro_export]
macro_rules! my_try {
    ($result:expr) => {
        match $result {
            Ok(v) => v,
            Err(e) => {
                return std::result::Result::Err(std::convert::From::from(e));
            }
        }
    };
}

利用 my_try!,則上面的程式碼就可變為:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut f = my_try!(File::open("hello.txt"));

    let mut s = String::new();
    let num_read = my_try!(f.read_to_string(&mut s));

    println!("read {} bytes", num_read);

    Ok(())
}

是不是感覺頓時清爽了很多!這便是宏的魅力!社群對 try! 的嘗試獲得了成功,於是就新增了 ? 運算子來表達與 try! 類似的語義,即:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut f = File::open("hello.txt")?;

    let mut s = String::new();
    let num_read = f.read_to_string(&mut s)?;

    println!("read {} bytes", num_read);

    Ok(())
}

這裡不禁又要 “嘲笑” 一下 Go 的錯誤處理。由於 Go 只提供了基於返回值的 error,既沒有類似於 Result<T, E> 的 sum type,也沒有類似於 ? 的錯誤處理簡化寫法,所以類似的程式碼在 Go 裡必須用一堆 if err != nil {...} 來實現,比如:

func main() {
	file, err := os.OpenFile("hello.txt", os.O_RDONLY, 0644)
	if err != nil {
		panic(err)
	}

	buf := make([]byte, 1024)
	numRead, err := file.Read(buf)
	if err != nil {
		panic(err)
	}

	fmt.Printf("read %d bytes\n", numRead)
}

一旦函式相互間呼叫複雜度上升,則 Go 程式碼就會充斥著非常多的囉嗦的錯誤返回值判斷,所以一直有人詬病 Go 程式碼中有一半是這類返回值判斷語句。Go 薄弱的超程式設計能力也沒法讓開發者很好地包裝出一個類似於 Rust 的 try! 宏。這其實也可以從側面看出兩門語言設計上的哲學差異:Go 比較追求簡單務實,而 Rust 更追求高效和零成本抽象,但也因此帶來了比 Go 更為複雜的語言特性。儘管 Go 的錯誤處理看起來比較囉嗦,但是幾乎有效地將 “所有” 的錯誤處理都做成了統一的模式,從而更便於開發者掌握和使用。

宣告宏的衛生性

宏的衛生性(hygiene)是一個比較奇怪的說法,總讓人摸不著頭腦(其實 “宏” 這個詞同樣是一個奇怪的翻譯)。有些書(比如參考文件 [2])則翻譯為 “自淨”。本文沿用參考文件 [1] 的中文翻譯,因為這是最直譯的做法。

所謂宏的衛生性,其實的就是宏在上下文工作不影響或不受周圍環境的影響。或者換句話來說,就是宏的呼叫是沒有 side effect。對於 macro_rules!,它是部分衛生的(partially hygienic)。我們目前階段可以不用太關注 macro_rules! 在哪些場景是 “不衛生” 的,而是瞭解一下 macro_rules! 是如何在大多數場景做到 “衛生” 的。

我們來看一看這樣一個簡單的例子:

#[macro_export]
macro_rules! make_local {
    () => {
        let local = 0;
    };
}

fn main() {
    let local = 42;
    make_local!();
    assert_eq!(local, 42);
}

理論上,make_local! 是一個意圖有副作用的宏,但實際上,main() 中的 localmake_local! 呼叫前後依然為 42。這時候讓我們用 cargo expand 展開宏來看一看:

fn main() {
    let local = 42;
    let local = 0;
    {
        match (&local, &42) {
            // 省略 assert_eq! 的展開
        }
    };
}

展開後的程式碼竟然有兩個同名的 local,而且 assert_eq! 比較智慧地選擇了第一個 local。之所以能做到這一點,Rust 其實是為每個識別符號都賦予了一個看不見的句法上下文(syntax context),或者按照參考文件 [2] 的一種更形象的說法,Rust 會為其“分別染上不同的顏色”。在 Rust 看來,macro_rules 中的 localmain() 裡的 local 分別有著不同的顏色,所以不會將其混淆。

總結

雖然 macro_rules! 有相對比較多不那麼直接的規則,但整體上結構上還是比較簡單的,而且實際專案中,macro_rules! 的程式碼大多比較簡短。當我們遇到看不太懂的 macro_rules!,一個比較好的方式用工具將其展開,一展開基本了無秘密,然後我們再就著手冊(比如《Rust 宏小冊》 就是一本翻譯和原作都非常不錯的書)和文件讀懂匹配模式轉換規則,一般都可以把 macro_rules! 給整明白。

祝大家玩得愉快

參考文件

  1. Rust 宏小冊
  2. Rust 程式設計

相關文章