Rust程式語言融合了多種程式設計法(programming paradigm),以指令式程式設計(imperative programming)為主,支援函數式程式設計(functional programming),不必明確定義出函數名稱的「閉包」和for迴圈所使用的「迭代器」和即是衍生自函數式程式設計的特性。



在開始介紹閉包之前,我們可以先看一下這個程式碼:

以上程式,第9行將「hello_something」函數指派給變數「h」儲存,接著在第10行直接使用「h」作為函數來呼叫。這個是合法的語法,可以成功編譯,程式執行結果如下:

Hello world!

我們可以將任意的函數或是方法使用變數或是參數儲存,將其作為「函數指標」(function pointer)來使用。當然,變數和參數一定會需要有個型別才有辦法分配記憶體空間,那麼函數或方法是屬於什麼型別呢?以上面這個例子來說,「h」變數的型別為「fn(&str) -> String」,函數或是方法的型別語法就和定義它們的語法一樣,且該型別都會實作「Fn」、「FnMut」和「FnOnce」特性。

此外,函數也可以被定義在某個程式區塊內來使用。舉例來說:

但是,被定義在程式區塊內的函數無法直接使用其所在的scope的資源。舉例來說:

以上程式會編譯失敗,因為我們不能夠在「main」函數裡的「hello_something」函數中,使用「main」函數裡的「hello」變數。

接著來談談閉包吧!閉包和函數非常相似,但它不像函數一樣有著具體的型別(concrete type),不必定義出函數名稱,也不必明確定義出參數的型別。閉包甚至還可以在主體內使用其所在的scope的資源。

舉例來說:

以上程式,我們實作出一個能夠計算「x + y + 1」的閉包,並指派給「plus_one」變數儲存,我們雖然無法具體的定義「plus_one」變數的型別是什麼,但可以確定的是它的型別必定有實作「Fn」、「FnMut」或「FnOnce」特性。也就是說,我們依然可以透過使用泛型來傳遞閉包。

整理一下,閉包與函數不同的地方有以下幾點:

1. 閉包的參數使用「||」語法來定義,且參數和回傳值型別可以被推論,不需要明確定義出來。
2. 當閉包的主體只有一行敘述時,且閉包沒有明確定義回傳值型別,則程式區塊可以省略大括號「{}」。
3. 閉包主體的程式敘述可以使用其所在的scope的資源。
4. 閉包沒有具體的型別。

這裡比較需要注意到的是第3點,在閉包主體內使用其所在的scope的資源,實際上會去「借用」該資源。因此以下這個程式會因為「num」變數已經借給「plus_num」變數所指的閉包使用,而無法再使用可變參考。如以下程式會編譯錯誤:

如果該資源會直接被閉包回傳,其行為和「將變數指派給另一個變數」一樣,會先嘗試「複製」,如果不行就進行「移動」。如以下程式會編譯錯誤:

閉包可以與「move」關鍵字搭配使用,可以強制將其主體內所使用到的其所在的scope的資源先嘗試「複製」,如果不行就進行「移動」,而非原先的借用。因此,以下程式可以編譯成功:

而以下程式會編譯失敗:

因為String結構體並沒有實作「Copy」特性,所以變數「s」會被移動給閉包來使用。

接著來談談「Fn」、「FnMut」和「FnOnce」特性。

Fn特性

當閉包並沒有使用到其所在的scope的資源,或是只使用到其所在的scope的不可變資源時,且這個閉包可以被呼叫多次,就會實作這個特性。舉例來說:

「double」變數所儲存的閉包並沒有使用到其所在的scope的資源,因此它會實作「Fn」特性。這裡要注意到以上程式第2行,我們在指定泛型要實作的「Fn」特性時,也要去指定其閉包參數和回傳值的型別,是比較特別的用法,而「FnMut」、「FnOnce」特性的泛型使用方式也是跟「Fn」特性一樣。

FnMut特性

當閉包有使用到其所在的scope的可變資源時,且這個閉包可以被呼叫多次,就會實作這個特性。舉例來說:

「Fn」特性是「FnMut」特性的子特性,多了資源不可變的限制。

FnOnce特性

只能被呼叫一次的閉包只會實作這個特性。舉例來說:

「get_word」變數所儲存的閉包,直接將「word」變數儲存的String結構實體回傳出去,擁有權會發生變化,因此這個閉包在被呼叫過一次之後就不能再被呼叫第二次了。

整理一下,「FnOnce」特性是所有閉包都會實作的特性,當閉包只能夠被呼叫一次,就只會實作「FnOnce」特性;「FnMut」特性繼承(inheritance)自「FnOnce」特性,當閉包有使用到其所在的scope的可變資源時,就會實作這個特性;「Fn」特性繼承自「FnMut」特性,當閉包並沒有使用到任何其所在的scope的資源,或是只使用到其所在的scope的不可變資源時就會實作這個特性。

也就是說,如果我們讓泛型型別明確定義為有實作「Fn」特性的閉包時,其也允許有實作「FnMut」和「FnOnce」的閉包;如果我們讓泛型型別明確定義為有實作「FnMut」特性的閉包時,其也允許有實作「FnOnce」的閉包;如果我們讓泛型型別明確定義為有實作「FnOnce」特性的閉包時,其只允許有實作「FnOnce」的閉包。利用這個觀念,我們可以對要傳遞的閉包進行一些特別的篩選。

我們也可以使用「Fn」、「FnMut」或「FnOnce」特性的參考型別來傳遞閉包。如下:

一般的函數和方法都會去實作「Fn」、「FnMut」和「FnOnce」特性,所以也可以像閉包這樣被變數或參數儲存以及傳遞。如下:

需要傳入閉包作為參數的函數,其閉包的參數或回傳值的生命周期,可以基於函數本身,而非函數的呼叫者。如下:

注意以上程式第2行,使用了「for」關鍵字來定義泛型型別的生命周期參數,而非使用其所在函數的泛型參數。如此一來就可以套用函數內scope的生命周期,而非呼叫這個函數的呼叫者所在的scope的生命周期。

迭代器

運用迭代器可以依照順序對陣列或集合進行一些處理,由迭代器負責決定走訪元素的順序以及該次迭代回傳結果的時機。Rust程式語言的標準函式庫所提供的最基本的迭代器會依照元素在資料結構中的儲存順序,走訪每個元素,並在每次迭代結束時回傳該次走訪到的元素。

舉例來說:

以上程式執行結果為:

Got: 1
Got: 2
Got: 3
Got: 4
Got: 5

善用迭代器可以省下很多撰寫重複程式碼的時間,Rust程式語言的標準函式庫提供了多種常用的迭代器相關功能。例如若要篩選出以上程式,Vec結構體所儲存的偶數元素,可以利用迭代器提供的「filter」方法,並搭配閉包使用,產生新的迭代器,使其只會回傳符合某些條件的元素。程式改寫如下:

以上程式執行結果為:

Got: 2
Got: 4

也可以使用「map」方法,並搭配閉包使用,產生新的在迭代器,使舊的迭代器在迭代的同時能夠透過閉包對元素進行一些改變,在交由新的迭代器回傳。例如將以上程式改成,取得Vec結構體內所儲存的偶數元素,並將它們通通乘2。程式改寫如下:

以上程式執行結果為:

Got: 4
Got: 8

如果不想要一次走訪所有元素,就不需要使用for迴圈,可以使用迭代器提供的「next」方法,來進行一次迭代,並回傳Option列舉實體,如果該次迭代有可回傳的元素,就會用Some變體將元素包裹起來回傳;如果沒有了,則會回傳None變體。舉例來說:

注意此處的「v1_iter」變數必須使用「mut」關鍵字,因為在使用迭代器的「next」方法時,其當下所指的元素位置會有變化。

以上程式執行結果為:

Got: Some(1)
Got: Some(2)
Got: Some(3)
Got: None

迭代器除了能夠讓我們自行處理每次迭代的邏輯外,也有提供一些方便的方法能直接在方法內完成走訪,來達成某個目的。例如「sum」這個方法,可以快速地將陣列或是集合內的元素做加總。舉例來說:

利用迭代器提供的「sum」方法,來快速完成Vec結構實體中所有元素的加總。以上程式執行結果為:

6

我們先前使用過的「collect」方法,也是跟「sum」方法一樣,會直接在方法內完成陣列或是集合的走訪,直接回傳新的集合。

我們也可以自行實作出迭代器,只需要讓結構體去實作「Iterator」特性,完成「Iterator」特性的「next」函數即可。舉例來說:

以上程式,實作了一個「ColorIterator」結構體作為「Color」結構體的迭代器,能夠依照「r」、「g」、「b」的順序來走訪「Color」結構體的欄位。這邊要注意到的是程式第13行的部份,實作「Iterator」特性的時候,必須使用「type」關鍵字來指定「Item」的型別,這個「Item」為「Iterator」特性的關聯型別(Associated Type),代表每次迭代時要被包裹在Option列舉的Ok變體回傳的元素型別。關聯型別的詳細用法會在之後的章節介紹。

另外還有「IntoIterator」特性,可以用來替結構體實作其在遇到for迴圈的時候要如何取得迭代器來使用。通常「IntoIterator」特性會實作在結構體的不可變參考型別和可變參考型別上,而不會實作在結構體本身。像是陣列的參考型別有實作「IntoIterator」特性,因此假設要用for迴圈走訪一個陣列,可以使用「for e in &array」這樣的寫法。而Vec結構體本身也有實作「IntoIterator」特性,因此假設要用for迴圈走訪一個Vec結構使體,可以使用「for e in vec」這樣的寫法,但是這會發生「變數指派給另一個變數」的行為,而導致「vec」變數被「移動」了,故通常還是會使用參考型別作為進入迭代器的方式。

將以上程式的「Color」結構體的不可變參考,加上「IntoIterator」特性後,程式如下:

改寫grep程式

我們來用閉包和迭代器來改寫上一章節完成的grep程式吧!

在我們原先的grep程式,「lib.rs」檔案的第18行和第19行使用了字串的「clone」方法來複製字串。原則上,如非必要,應儘量地避免使用「clone」方法複製相同的資料。複製記憶體中的資料不但會需要花費時間進行,還會在記憶體中耗用不同的空間去儲存相同的東西,賠了夫人又折兵。我們之所以在這邊使用「clone」方法來複製字串,是因為我們無法直接將某資料結構欄位的資料之擁有權交給其它變數儲存。

為了要解決這個問題,我們必須改良讀取透過命令列傳進來的參數的方式。原先在「main.rs」檔案的第8行,我們取得命令列參數的迭代器後,直接使用「collect」方法將其走訪完,並產生Vec結構實體。如今的我們,已經學會了迭代器的用法,要想辦法善用迭代器的特性來讀取資料。

首先,我們需要修改「Config」結構體的「new」關聯函數,使它可以透過參數傳入命令列參數的迭代器,接著就可以利用該迭代器的「next」方法來逐一讀取參數了!「next」方法只能用在可變的資料上,因此在定義「new」關聯函數的參數時,必須加上「mut」關鍵字。

修改後的程式如下:

「lib.rs」檔案的部份有個地方要注意的是,原先我們並沒有對Config結構體的「new」關聯函數的回傳值,也就是Result列舉的泛型參數值「&str」設定生命周期,那是因為當時函數的泛型生命周期參數顯然是可以被編譯器推論的(只有一個參考型別的參數)。而此時因為「new」關聯函數沒有參考型別的參數了,「&str」的生命周期就無法被自動推論,但我們知道這個「&str」就是我們寫在程式碼內的字串定數,因此它的生命周期就是「'static」。

修改過的grep程式,已經解決了在記憶體無謂地複製相同資料的問題。但它還是有個我們可以明顯改進的地方,那就是for迴圈和「if」關鍵字的連用,可以用迭代器的「filter」方法和閉包來處理。Vec結構體的部份也可以直接用迭代器的「collect」方法來產生。將所有被找出來的該行印出的部份也可以使用迭代器的「for_each」方法來完成。

於是我們可以再將「lib.rs」修改如下:

迭代器和迴圈,哪個效能好?

在大多數的情況下,迭代器都擁有比迴圈更好的效能。如果有興趣深入了解的話,請參考這篇文章的分析:

https://magiclen.org/rust-for-iterator-measurement

結論

受到函數式程式設計的啟發,閉包和迭代器已是Rust程式語言的一大特色,可以增加程式碼的利用率,不會佔用程式執行階段的運算資源,還可以增進程式的效能。下一個章節,我們會深入學習Cargo和crates.io的用法。

下一章:Cargo和Crates.io