Rust 學習之路─第十章:泛型、特性和生命周期


每個程式語言都有用一些有效的方式來處理重複的抽象概念,好比先前介紹過的集合,我們可以使用同一種集合結構體來產生出不同的實體,儲存不同型別的資料。舉例來說,String和i32這兩個不同型別的資料都可以被存到Vec結構體所建立的實體中,我們不需要特地針對不同資料的型別來分別實作出如「VecString」或是「Veci32」這樣的結構體。這是就因為Vec結構體使用了「泛型」(generic)。

在「類型論」(type theory)中,「泛型」就是「參數多型」(parametric polymorphism),在定義複合型別或函數的時候不去明確地指定具體的型別,而以參數的形式來傳入型別,如此一來這些複合型別或函數只需要實作一次,就可以透過參數傳入不同型別的方式來將其應用在各種型別的資料下。先前我們看到的Option列舉、Result列舉、Vec結構體、HashMap結構體都有泛型,所以它們可以和任何型別的資料一同使用。

我們就用一個找出陣列最大元素值的程式來練習泛型吧!程式碼如下:

這個程式實作了三個函數「largest_i32」、「largest_f64」、「largest_char」,可以分別從32位元的有號整數陣列切片、64位元的浮點數陣列切片以及字元陣列切片中找出最大的元素並回傳出來。我們可以發現,程式第2行到第10行、第14行到第22行、第26行到第34行是完全一樣的程式,這是因為我們需要處理不同型別的資料而重複寫了三次,如果要連i8、i16、i64、u8、u16、u32、u64、f32的陣列切片也都支援的話,那就要再多重覆寫8次!為了一勞永逸解決這個問題,我們可以使用Rust程式語言提供的泛型機制,在定義函數名稱時,在函數名稱右邊加上「<>」語法,來定義泛型要使用的參數,接下來的程式碼就可以使用這個參數來取代明確的型別名稱。程式如下:

在呼叫「largest<T>」函數時,編譯器會在編譯階段時自動判斷泛型的參數第一個接觸到的值是什麼型別,來決定參數「T」究竟是什麼型別。如果不想要讓編譯器自行推定,而是想要在呼叫函數時直接指定泛型參數的型別的話,可以使用先前已經提到過幾次的「turbofish」語法。程式如下:

然而,不論有沒有使用「turbofish」語法,以上程式第5行都會編譯錯誤。因為若要將值進行大於小於等邏輯判斷,該值的型別必須要實作「PartialOrd」特性。由於泛型的參數「T」可以是任意的型別,這個型別不一定會實作「PartialOrd」特性,所以程式無法編譯成功。為了要讓編譯器確定參數「T」有實作「PartialOrd」特性,我們必須事先明確定義泛型的參數「T」傳入的型別必須要實作「PartialOrd」特性。就像定義一般函數的參數型別一樣,泛型的參數型別也可以使用冒號「:」語法。程式如下:

再次嘗試編譯程式後,會發現這個程式還是無法通過編譯。這是因為程式第2行,我們使用直接透過索引值讀取陣列元素值的方式將其又指派給了「largest」變數,這裡的行為就跟「把變數指派給另一個變數」一樣,如果該型別有實作「Copy」特性,就會進行「複製」,否則就是「移動」。然而,由於我們只告訴編譯器泛型的「T」參數所表示的型別只確定有實作「PartialOrd」特性,而不確定有沒有實作「Copy」特性,在這個情況下「把變數指派給另一個變數」就會用「移動」的方式來進行,而複合型別內的資料是無法被移動的,因此在「複製」不知道可不可行且又確定「移動」不可行的情況下,就出現編譯錯誤啦!

為了讓程式能夠編譯成功,我們也需要告訴編譯器「T」參數所表示的型別,除了有實作「PartialOrd」特性之外,也還有實作「Copy」特性。我們可以直接使用加號「+」,來替泛型型別的參數加上更多需要實作的特性。程式如下:

程式執行結果如下:

The largest integer number is 100
The largest float number is 96.1
The largest char is y

除了函數或能夠使用泛型外,列舉、結構體、特性也是可以的。同樣都是在名稱右邊加上「<>」語法,來定義泛型要使用的參數。舉例來說:

在實體化「Point」結構體時,編譯器會在編譯階段時自動判斷泛型的參數第一個接觸到的值是什麼型別,來決定參數「T」究竟是什麼型別。以上程式第7行,泛型參數「T」先接觸到「5」這個「i32」型別的值,因此參數「T」被編譯器推論為「i32」;程式第8行,泛型參數「T」先接觸到「1.0」這個「f64」型別的值,因此參數「T」被編譯器推論為「f64」。

再看一個例子:

以上程式,第7行會編譯錯誤,因為泛型參數「T」先接觸到「5」這個「i32」型別的值,因此參數「T」被編譯器推論為「i32」,此時「Point」結構體的「x」和「y」欄位都是「i32」型別,所以無法儲存「4.0」這個「f64」型別的值。

如果要讓「Point」結構體的「x」和「y」欄位可以是不同型別,就要使用兩個泛型參數。舉例來說:

以上程式可以通過編譯,因為「x」和「y」欄位使用不同的泛型參數,型別不需要一樣。

列舉也可以搭配泛型來使用。例如之前我們用過的Option列舉和Result列舉,其定義如下:

關聯函數和方法也可以使用泛型,在使用「impl」關鍵字來實作關聯函數和方法時,「impl」關鍵字的右邊可以直接加上「<>」語法,來定義泛型要使用的參數。舉例來說:

另外「impl」關鍵字也可以不使用泛型,只針對特定型別來實作關聯函數和方法。舉例來說:

以上程式利用多個「impl」關鍵字,分別替「Point<f64>」和「Point<i32>」實作了「distance_from_origin」方法。

Rust的泛型不會有任何額外的運算效能的耗損,因為編譯器在編譯泛型的程式碼時,就會把泛型解開來,將原本的型別變數一個獨立的型別。舉例來說,我們用Option列舉的Some變體產生出以下兩個實體。

依照目前我們對Rust程式語言的理解,「interger」變數的型別應為「Option<i32>」,而「float」變數的型別應為「Option<f64>」。然而,事實上,Rust的編譯器會自動替「Option<i32>」和「Option<f64>」產生出不一樣的結構體,分別是「Option_i32」和「Option_f64」。編譯器將泛型解開後的程式碼如下:

因此,使用泛型的時候,程式在執行階段完全不需要使用額外的運算資源去進行型別的檢查,因為這些工作在編譯階段就已經自動完成了!

接下來來介紹Rust程式語言的「特性」吧!

「特性」可以定義一個抽象的型別,這種型別只會定義未經實作的關聯函數和方法,可交由其它多個型別來具體實現。之前在使用泛型的時候,我們也學到在定義泛型的參數時,可以限定這些參數所表示的型別必須要實作哪些特性。Rust的「特性」就像是其它程式語言的「介面」(interface),但僅僅只是很像而已,還是有許多不同的地方。

不同種類的型別可以藉由實作同一個特性,來用不同的方式實作同樣名稱的關聯函數和方法。在變數或參數的型別也可以使用特性來定義,在使用這類的變數或參數時,我們在乎的只是該特性所定義的關聯函數和方法的簽名和回傳值型別,而不是其實作的方式,甚至我們也不用去管那些資料到底是屬於哪個型別,我們只是很單純地要去用它們擁有的某個或某些特定的關聯函數和方法而已。

定義特性可以使用「trait」關鍵字。舉例來說:

這裡有個地方要注意的是,原先我們實作函數或是方法的時候,都會使用大括號「{}」來建立程式敘述區塊,作為函數或是方法的主體。但是使用「trait」關鍵字定義特性時,其抽象的函數或是方法不需要使用大括號「{}」來實作出來,只要將簽名和回傳值型別定義出來即可,最後要記得加上分號「;」,才會是一個完整的程式敘述。

假設我們現在要寫個程式,來處理不同類型的網路文章。簡單將文章分為新聞和推特貼文,我們先分別使用「NewsArticle」和「Tweet」結構體來描述它們。程式如下:

再假設「NewsArticle」和「Tweet」結構體所描述的新聞和推特貼文資訊,都可以用不同的形式來概括論述。應用我們剛才舉例的「Summarizable」特性,再利用之前學到的「impl」關鍵字,來分別替「NewsArticle」和「Tweet」結構體實作「fn summary(&self) -> String」方法吧!程式如下:

雖然這樣的方式可以讓「NewsArticle」和「Tweet」結構體的實體擁有「summary」方法,但是它們都跟「Summarizable」特性沒有什麼關係,只是「summary」方法的名稱、參數和回傳值型別一樣而已。為了讓「NewsArticle」和「Tweet」結構體能夠真的實作「Summarizable」特性,我們必須替它們實作「Summarizable」特性,這邊還必須再搭配一個「for」關鍵字來使用。程式如下:

利用「impl」關鍵字來替結構體實作特性中所定義的所有抽象函數和方法。如此一來,「NewsArticle」和「Tweet」結構體就有了「Summarizable」特性,也就可以使用「summery」方法了!舉例來說:

以上程式,「summarize_and_print」函數直接使用Summarizable特性作為其參數的型別,因此無論是NewsArticle結構實體還是Tweet結構實體,總之只要是有實作這個Summarizable特性的結構體的實體都可以被傳入。這邊要注意的是,特性無法直接作為變數或參數的型別,「a: Summarizable」是個不正確的用法,因為我們在編譯階段的時候並無法明確的知道變數「a」究竟要分配多少空間才可以存放有實作Summarizable特性的結構實體,它只能轉成參考型別才能被用來定義變數或參數的型別。

程式執行結果如下:

horse_ebooks: of course, as you probably already know, people
Fearless Concurrency in Firefox Quantum, by Manish Goregaokar (The Rust Programming Language Blog)

也可以將「summarize_and_print」函數使用泛型來改寫,可以使用法更加靈活。程式如下:

程式第49行,我們替「summarize_and_print」函數定義了泛型,其「T」參數必須要是有實作Summarizable特性的型別。如果我們之後要限制這個型別必須再有其他特性,只要利用加號「+」,就能替泛型型別的參數加上更多需要實作的特性。除了使用參考型別或是泛型來指定資料型別必須要實作的特性外,也可以將剛才提到的「a: Summarizable」這樣的錯誤用法,搭配「impl」關鍵字改成「a: impl Summarizable」來使用。

在定義泛型參數時,由於可能要限制該型別必須實作很多的特性,而使得「<>」內的程式碼變得很長,導致程式不易閱讀,此時我們可以考慮使「where」關鍵字,將限定泛型參數所表示的型別必須實作的特性,改寫到「where」關鍵字之後。舉例來說:

可以用「where」關鍵字改寫成:

「trait」關鍵字除了可以替特性定義抽象的函數和方法之外,也可以定義擁有主體的函數和方法。舉例來說,若將剛才的Summarizable特性改為:

則其它型別在實作Summarizable特性時,若不實作「summary」方法,就會直接使用Summarizable特性預設實作的「summary」方法。

我們之前在介紹擁有權的時候,忽略了一個細節,那就是「生命周期」(lifetime)。Rust程式語言中的每個參考都有生命周期,生命周期就是參考有效的scope。就像型別推論一樣,大多數的生命周期也都不用明確定義,可以交由編譯器來自動推論。但是在一些特別的情況下,我們會需要自行定義參考的生命周期。

生命周期主要是要解決懸置參考(dangling reference)的問題,也就是當一個參考指到的記憶體空間並不是正確的位址,或已經被釋放、挪做它用時,所引發的問題。 先來看看這個程式:

以上程式第6行會編譯失敗,因為我們之前學到,當離開scope時,屬於該scope底下的「擁有者」所持有的值都會被消滅,因此不能將「x」變數的值借給位在上層scope的變數「r」。如果這個程式能夠編譯成功的話,程式第8行之後的「r」變數就會有懸置引用的問題。以生命周期的角度來探討,我們將「r」變數的生命周期稱作「'a」,「x」變數的生命周期稱為「'b」,請看以下程式註解的部份:

不難發現到生命周期「'b」比生命周期「'a」還要早結束,且就算第4行和第7行的大括號拿掉,來向下延長生命周期「'b」,生命周期「'a」也還是比生命周期「'b」還要早開始,因此編譯器知道生命周期「'b」的變數不能借給生命周期「'a」的變數使用。

當我們把第4行和第7行的大括號拿掉,並在第6行才宣告出變數「r」,生命周期「'a」和「'b」就會變成:

以上程式,由於生命周期「'a」就在生命周期「'b」的範圍內,因此程式可以編譯成功。

生命周期的問題同樣也會在函數和方法上遇到,這邊有一個「longest」函數,這個函數可以透過參數輸入兩個字串切片,並找出最長的那個將其回傳。程式如下:

這個程式看起來很合理,但實際上會編譯錯誤。雖然編譯器知道函數的回傳值有參考型別,但編譯器並無法確定回傳的參考的生命周期。舉例來說:

藉由以上的方式來呼叫「longest」函數的話,假設程式可以編譯成功,「longest_str」變數在第16行之後就會有懸置引用的問題。

在稍微複雜條件下,我們必須明確地告訴編譯器,函數或方法的參數和回傳值如果有參考型別時的生命周期,否則編譯器就會不知道怎麼安全又正確地處理該函數或方法,而直接產生編譯錯誤。定義生命周期,需要透過泛型參數,且參數名稱必須以「'」開頭。要在參考型別名稱上加上泛型參數,只需要將生命周期的參數名稱接在「&」後面即可。程式如下:

我們替「longest」函數定義了泛型,其有一個生命周期的參數「'a」。然後將「x」參數,「y」參數和其回傳值的型別都定義為「&'a str」,也就是要將它們的生命周期限制在同樣的範圍下,也就是「x」參數、「y」參數和回傳值,都必須在至少「'a」的生命周期中存活。假設傳進「x」參數的參考的生命周期為「'i」,傳進「y」參數的參考的生命周期為「'j」,「x」參數和「y」參數都是指定要使用泛型「'a」參數所代表的生命周期,則泛型「'a」參數實際所代表的生命周期就要看「'i」和「'j」哪個大,就用哪個。如果要使用可變參考,要把「&'a 改為 &'a mut」。定義參考的生命周期不會影響到編譯出來的程式,這個是給編譯器看的資料,程式在執行階段不會去做額外的生命周期檢查。

將函數的回傳值型別定義明確的生命周期,代表著這個回傳值一定要使用定義的生命周期。舉例來說:

以上程式,我們直接讓「longest」函數回傳從函數主體中產生出來的字串切片,由於該字串切片的生命周期不是「'a」,因此會編譯失敗。

但是,如果直接回傳字串定數的話,就可以編譯成功。如下:

這是因為字串定數的字串資料是隨著程式本身儲存在記憶體中的某塊靜態的區塊,不會被消滅,因此不會有生命周期的問題。像這種存在於整個程式執行階段的資料,其生命周期有個特別的名稱,那就是「'static」。

除了函數和方法有生命周期的問題外,結構體也有。想要在結構體中儲存參考型別的值,就必須要替該欄位明確定義好生命周期。舉例來說:

注意這邊的「novel」變數並沒有使用「mut」關鍵字來宣告,程式卻依然可以通過編譯。但是結構體中儲存參考型別的值的欄位,即便有使用「mut」關鍵字將其定義為可變參考,並且儲存結構體的變數也有使用「mut」關鍵字來宣告,也還是無法在建立實體之後更改欄位所儲存的參考值。舉例來說:

以上程式會編譯錯誤,理由是:

泛型參數「'l」第一次接觸到的值為生命周期為「'a」的「title」變數,因此泛型參數「'l」所表示的生命周期為「'a」。生命周期「'a」比生命周期「'b」還要早開始,因此生命周期為「'b」的「title」變數無法指派給Book結構實體中生命周期為「'a」的「title」欄位。

生命周期的觀念大概就是這樣子,需要多花點心思去思考才能理解。以下的幾個規則,可以在定義回傳值含有參考型別的函數和方法時,不明確定義出參數和回傳值的生命周期,而是交給編譯器來自行推論。

1. 函數中沒有被明確定義生命周期的參數,每一個參考型別的參數則都有屬於自己的泛型生命周期參數。
2. 如果函數(或是方法除了「&self」外)只有一個參考型別的參數,且回傳值也有參考型別的值(參數和回傳值中的參考可以是不同型別),則回傳值參考的生命周期就是那唯一參考型別的參數的生命周期。
3. 如果方法除了「&self」外還有兩個以上的參考型別的參數,則回傳值參考的生命周期就是「&self」的生命周期。

舉例來說:

根據第一個規則,編譯器會自動將其加上泛型參數和函數參數的生命周期。如下:

再來根據第二個規則,編譯器會自動替回傳值加上生命周期。如下:

Rust的編譯器提供這樣的生命周期推論功能,使我們可以不明確寫出參數和回傳值的生命周期,就能定義出大部份的函數和方法,非常方便!

最後來看一下當泛型的型別參數和生命周期參數混在一起使用的狀況,舉個例子:

泛型同時擁有型別參數和生命周期參數時,生命周期的參數要先寫,並且在使用「turbofish」語法的時候,只需要替泛型的型別參數指定型別即可。

總結

我們在這個章節中將泛型、特性和生命周期這幾個Rust程式語言中最複雜的基本觀念給初步釐清了,現在我們已經有辦法使用Rust程式語言來設計出各式各樣的程式。在下一章節中,我們會學習如何測試我們寫出來的Rust程式,以確保程式真的是依照我們想要的方式來運行。

關於作者

Magic Len

Magic Len

各位好,我是Magic Len,是這網站的管理員。我是台灣台中大肚山上人,畢業於台中高工資訊科和台灣科技大學資訊工程系,曾在桃機航警局服役。我熱愛自然也熱愛科學,喜歡和別人分享自己的知識與經驗。如果你有興趣認識我,可以加我的Facebook,並且請註明是從MagicLen來的。

相關文章