身為 Rust 開發者,一定要知道的十個超實用 Macro

Larry Lu
Starbugs Weekly 星巴哥技術專欄
10 min readMar 16, 2021

--

Larry 我寫 Rust 也寫了好一陣子了,真心覺得雖然 Rust 有一些地方不太討喜(語法太醜、編譯太慢),但還是有很多不錯的優點,像是變數所有權、Macro、零成本抽象化等等,而今天我就要來跟大家介紹十個超厲害、超實用、不學會後悔的 macro

Rust 的 Macro 跟 C/C++ 很類似,雖然 Rust 的變化更多一點,不過原理上就是編譯器會幫你把 Macro 展開成一堆醜不拉機的程式碼,所以只要寫個幾行就可以做到很多事情,一種 Write less, Do more 的概念

為了讓大家體會一下什麼叫「編譯器會把 Macro 展開成醜不拉嘰的程式碼」,來看看這個 Hello World 的例子,寫起來非常輕鬆~

但經過編譯器展開後就會變成下面這一大坨XD,雖然大概知道他在幹嘛,但絕對不會有人想去讀他的

而且這還只是一個 Hello World 而已,真實在開發專案時用上的 macro 一旦展開,程式碼大概會直接多十倍XD,那也是為什麼 Rust 編譯出的執行檔總是有點肥

關於 Rust macro 的基本介紹就到這,接著就來看看是哪十個超實用 macro 吧~

1. format!

format! 這個 macro 在 C/C++/Go 裡面叫作 sprintf,基本上就是可以用 printf 的語法來拼字串,不用自己在那邊 "Hello, I am " + name + "," 慢慢拼(這樣超蠢XD),是我最常用的 macro 之一

而且除了原本 printf 就有的語法之外,format 還提供了類似 Python named parameter 的功能,可讀性也比較高一點

但寫了這麼多語言,到目前為止我最愛的還是 JS 的 Template Literals,可以直接寫成 `${a} + ${b} = ${a + b}`,看起來超舒服 XD

2. vec!

在 Rust 裡面想初始化一個 Vector,只能先 new 一個 Vector 再一一把元素 push 進去

但這樣做有兩個缺點:一來是寫起來很冗長;二來是我的 pow_of_10 初始化後就不會再修改了(只是想把十的次方數存起來,之後隨時可以用),所以希望 pow_of_10 是個 constant Vector。但因為我要自己把 1 到 1000 push 進去,所以只得宣告一個 mutable 的 Vector,提高了後續不小心改到他的風險

vec! 剛好完美解決了這兩個問題,一來寫起來很直觀;二來也不需要把 pow_of_10 宣告成 mutable,所以如果不小心 push 新東西進去就會被編譯器抱怨 cannot borrow as mutable 👍

3. maplit

這個 macro 跟 vec! 很類似,只不過這個是用來初始化 HashMap 的。在 Rust 裡面初始化 HashMapHashSet 這類的資料結構一樣非常麻煩,只能自己建一個空的再慢慢 insert 進去

如果不想這麼蠢的話也可以用官方推薦的方法,先寫成 Array of tuple 的形式,然後再把他們 collect 起來變成 HashMap。雖然有好一點但還是很冗XD,尤其是那個 .iter().cloned().collect() 誰會記得要這樣寫啊

因此就有善心人士寫了 maplit 這個 crate,可以直接用裡面的 hashmap! 跟 hashset! 來快速初始化,而且也不用再把變數宣告成 mutable 了~

4. unreachable!

unreachable! 這個 macro 很有趣,我覺得他主要是用來解決編譯器不夠聰明用的XD,怎麼說呢?

下面這個 count_digits 的功能是計算 n 是幾位數,例如 count_digits(50) 就是 2。那具體的作法就是不斷把 n 除以 10,同時不斷把 i 往上加,等 n 被除到變成 0 時,再回傳 i 就可以了

但這段看似完美無缺的演算法卻會被編譯器抱怨說「說不定一直跑到迴圈結束了,都沒有跑進 if n == 0 {...} 裡面啊,這樣就會沒有回傳值,不符合你宣告的回傳 u32」

|   fn count_digits(n: u32) -> u32 {
| / for i in 1.. { --- expected `u32`
| | n = n / 10;
| | if n == 0 {
| | return i;
| | }
| | }
| |_____^ expected `u32`, found `()`

但仔細想一想,邏輯上只要 n 是一個正整數,而且 n 不斷除以 10,那總有一天變成 0 的,所以根本不可能發生編譯器說的「跑到迴圈外面」的情況,顯然是編譯器太笨了不懂數學XD

認真說,這問題牽涉到數理邏輯而不光是語法,編譯器本來就很難處理這類的問題(不然就是要編譯很久),所以也不能怪他

那到底該怎麼辦呢?這時就要把 unreachable! 請出來,告訴編譯器說「以我的聰明才智判斷,程式是不可能跑到這裡的」,那編譯器就不會再糾結「如果程式跑出迴圈就會沒有回傳值」這件事,讓你順利通過編譯

是說如果人類的判斷出錯了,真的跑到 unreachable! 的部分,程式就會直接 panic 炸開哦,所以在使用時一定要特別小心

5. matches!

matches! 這 macro 跟 pattern match 很類似,直接看下面這判斷母音例子,寫起來很簡潔,不用自己在那邊 o == 'a' || o == 'e' || o == 'i' ...

而 matches! 展開之後也會真的變成 pattern match 的樣子,不過它只有兩種情況,算是 pattern match 中的特例

因為展開之後會變成 pattern match,所以 matches! 也支援用範圍作為輸入,像是 if n >= 1 && n <= 1000 可以寫成 matches(n, 1..=1000),不管是寫起來還是讀起來都很舒服XD

6. serde_json

不知道大家有沒有跟我一樣的困擾,就有時候只是想寫個小玩具,但為了要解析 JSON 格式的資料,不得不宣告一堆 struct,重點是那些 struct 還只用個一兩次,就覺得好浪費時間

後來我才發現 serde_json 裡有個 json! macro,可以用來解析手寫的 JSON 字串,要取值的時候也是直接拿就好了,不用做什麼額外的處理,而且 json! 還會在編譯期間幫你確認 JSON 語法,寫錯的話是編譯不過的

如果你的 JSON 字串不是自己手寫的,而是已經存在字串裡面,那也可以用 serde_json 提供的 from_str(),用起來就像 JavaScript 的 JSON.parse,不用再另外定義一個 struct 來接收 JSON 資料

7. lazy_static!

在 Rust 裡面,static 關鍵字是用來宣告全域變數的,像是這樣寫就可以在各個地方存取到 VAR 變數

但有個比較麻煩的地方,就是 Rust 的全域變數只能給一個靜態的值,沒辦法在宣告時進行初始化,譬如說我想要把 費氏數列第五項 算出來存在全域變數裡面

Rust 編譯器就會抱怨在宣告 static 變數時不能呼叫 function

error[E0015]: calls in statics are limited to constant functions, tuple structs and tuple variants
|
52 | static FIB5: u32 = fib(5);
|

所以真想要初始化就得寫成這樣:先把全域變數 FIB5 宣告為 mutable、初始值為 0,然後在 main() 開頭用 unsafe 模式把它改成 fib(5) 的值,非常麻煩而且很醜

但有了 lazy_static! 之後就可以直接把全域變數的初始化包在裡面,他會幫你產出一坨 Rust 可以接受的程式碼(實在太醜這邊就不貼了XD),那段程式碼會在你第一次去取值之前,幫你把 FIB5 初始化成 fib(5) 的值,寫起來就簡單很多

8. dbg!

接下來最後三個 macro 都是跟 debug 相關的,首先來看內建的 dbg!,他可以在不影響程式運作的情況下,把你寫的算式還有結果印出來

[src/main.rs:143] 1 + 2 + 3 + 4 = 10
[src/main.rs:144] (1..10).sum() = 45

我個人最常用的就是直接把他寫在 if 的條件裡面,這樣程式怎麼跑的只要看 log 就一清二楚,而且 dbg! 裡面又可以包 dbg!,所以可以把計算過程顯示得很清楚,比 print 好用多了

[src/main.rs:146] a * 2 + 1 = 21
[src/main.rs:147] c + a = 31
[src/main.rs:147] dbg!(c + a) > 10 = true

9. log

這個 crate 跟很多 logging library 一樣,他將 log 依序分成 trace、debug、info、warn、error 幾種等級,並且提供相對應的 macro 讓你快速輸出

輸出的結果就像這樣,會把時間、日期、等級都印出來

2021-03-15 16:34:31,942 ERROR [rust_macros] Some error occurs
2021-03-15 16:34:31,943 INFO [rust_macros] I am Larry
2021-03-15 16:34:31,943 TRACE [rust_macros] Hello World

只要再搭配 env_logger 用環境變數設定適合的 log 等級,就可以在平常開發時看到更多資訊(開發時我都設 debug、上線則是 info),而 production 上也不至於被滿滿的 debug 訊息淹沒

10. func_trace

trace 這個 macro 真的是 debug 神器,他可以用來追蹤 function 被呼叫時的參數還有回傳值,譬如說我在 fib 上面加一個 #[trace],表示我要追蹤這個 fib 這個 function 的呼叫狀況

到了 main 裡面執行 fib(4) 時就會印出這些訊息,可以看出最外層的 fib(4) 的回傳值是 3(最後一行),而且過程中又呼叫了 fib(3)fib(2),整個遞迴執行的順序一目瞭然

[+] Entering fib(n = 4)
[+] Entering fib(n = 3) // fib(4) 呼叫了 fib(3)
[+] Entering fib(n = 2)
[-] Exiting fib = 1
[+] Entering fib(n = 1)
[-] Exiting fib = 1
[-] Exiting fib = 2
[+] Entering fib(n = 2) // 再呼叫了 fib(2)
[-] Exiting fib = 1
[-] Exiting fib = 3 // fib(4) 回傳 3

如果是像下面這種一個 function 會呼叫其他 function,那只要在想追蹤的目標上都加上 #[trace] 就可以

印出來就會長這樣,真的很清楚吧~所以才說他是 debug 神器~

[+] Entering foo(n = 2)
[+] Entering bar(n = 1)
[+] Entering foo(n = 0)
[-] Exiting foo = ()
[+] Entering foo(n = -1)
[-] Exiting foo = ()
[-] Exiting bar = ()
[-] Exiting foo = ()

順帶一提,雖然目前 crates.io 上的 func_trace 是我發佈的,但最核心的部分並不是我寫的XD。我只是 fork 別人的,然後把他的輸出格式改成我喜歡的樣子,gsingh93/trace 才是原作者的 repository,大家覺得好用的話也可以去給他 star

總結

跟其他語言比起來,Rust 的 Macro 語法是我目前看到最彈性的,因此能做到的事情也非常多,相信大家都感覺到他的威力了

而且因為 macro 會在編譯期間完全展開,使用 macro 只會讓編譯變久,而不會像呼叫 function 那樣增加 overhead(簡單來說就是用編譯時間換取執行效能),所以可以放心的用好用滿~

今天的文章就到這,如果有你覺得很讚的 macro 也歡迎分享給我,真的不錯的話我就會把他加進去~

範例程式碼都放在 rust-macros,有需要可以實際 clone 下來執行看看~

延伸閱讀

--

--

Larry Lu
Starbugs Weekly 星巴哥技術專欄

我是 Larry 盧承億,傳說中的 0.1 倍工程師。我熱愛技術、喜歡與人分享,專長是 JS 跟 Go,平常會寫寫技術文章還有參加各種技術活動,歡迎大家來找我聊聊~