物件導向程式設計 (Object-Oriented Programming) 目前仍然是最主流的編程範式 (Programming Paradigm),因此 Rust 也提供了物件導向程式語言最核心的幾項功能:封裝、繼承、多型。然而,在 Rust 中定義成員函式的語法與 C++ 有段差距,它的物件模型與實現繼承的方法也明顯異於 C++ 或大多數主流的物件導向程式語言。在這篇文章中,我將會詳細說明在 Rust 中實現物件導向的方法。
封裝
在之前文章的範例中,我們經常使用 struct,讀者可能會以為 Rust 就如同 C++ 那般,所有 struct 成員都是公開的 (public)。但實際上,Rust 的 struct 成員僅對同一個模組 (module) 內的程式碼公開。而所謂的模組,其實就是 C++ 的命名空間 (namespace):
|
|
mod
關鍵字非常接近 C++ 的 namespace
,它除了用來區分命名空間以避免撞名,還具備了封裝的功能。在 mylib
中的符號是不對外開放的,只有在同一個模組中的函式才能使用。
我們可以用 pub
關鍵字來公開符號:
|
|
成員函式與建構式
物件導向中的封裝原則告訴我們:使用者不需要也不應該去了解物件內部的實作細節,因此型別的設計者應該禁止使用者直接存取內部成員,而是讓他們透過公開的成員函式進行操作。
我們可以用 impl
關鍵字為某個型別定義成員函式:
|
|
只要對任何型別使用 impl
區塊定義函式,這些函式就會成為該型別的成員函式[1]。以這個例子來說,Rational
型別就有三個成員函式:new
、is_integer
與 reduce
。而成員函式的第一個參數,則代表的這個成員函式如何影響物件:
- 如果第一個參數是
&self
,表示這個成員函式不會改變物件內的狀態,相當於 C++ 中以const
修飾的成員函式 (const member function)。self
在 Rust 中也是個關鍵字,相當於 C++ 中的this
。需注意的是,在成員函式中也必需透過用self
才能存取物件的其它成員。 - 如果第一個參數是
&mut self
,表示這是一個會修改物件內部狀態的成員函式,相當於 C++ 中未以const
修飾的一般成員函式。 - 如果第一個參數不是
self
,表示這是一個靜態成員函式 (static member function)。 - 除了用
self
代表this
以外,你還可以用大寫的Self
來代表這些成員函式所屬的類別,以這個範例來說Self
就等於Rational
。
另外,在 Rust 中物件的建構式[2] (constructor) 其實就是回傳該物件的靜態成員函式,Rust 並未規定建構式要叫什麼名字,不過大部份人會依照慣例,使用 new
這個名字當作建構式。上述的成員函式定義,如果翻譯成 C++ 會長這個樣子:
|
|
繼承與多型
與大多數 OOP 語言不同的是,Rust 並沒有繼承結構成員的功能,你沒辦法如同 C++ 那樣直接讓某個新類別擁有另一個父類別的所有成員[3]。然而 Rust 可以宣告抽象介面,並且指定某個型別必需實作介面中的函式,這點非常接近 Java 或 C# 裡面的 interface
。
宣告抽象介面的方法,是使用 trait
關鍵字:
|
|
這相當於用 C++ 宣告一個抽象類別:
|
|
你不能在 trait 中宣告成員變數,或是為函式提供實作。Trait 的目的,是為編譯器提供這個型別的介面資訊,不同的 struct 可以提供介面相同,但內容不同的實作,這樣只需要抽換實作,就可以達到程式碼再利用的目的。
你可以用 impl
與 for
關鍵字,讓某個型別實作 trait:
|
|
當然,你可以用其它型別來實作 JsonObject
,它代表了任何可以輸出成 JSON 格式的型別。
|
|
你可以使用 &JsonObject
來代表一個實作出 json()
介面的物件,並且以多型的方式呼叫它:
|
|
因為我們不知道具體型別是什麼,因此虛擬函式呼叫 (virtual method invocation) 只能在指向某物件的指標上實現,這也是為什麼 dump_json
的兩個參數型別都是 &JsonObject
,而且在呼叫時,我們必需用 &
符號取得 r
與 c
的位址。
使用參考就會受到生命週期的限制,而改用智慧指標可以省去許多麻煩。Rust 的智慧指標與多型操作搭配良好,因此你可以用 Box<JsonObject>
來指向任何實作 JsonObject
介面的物件。
|
|
注意在兩個 push
操作中,我們實際上推了兩個不同型別的元素進去,但因為 Rational
與 Complex
都是 JsonObject
的子型別,因此 Box<Rational>
與 Box<Complex>
可以安全地轉型成 Box<JsonObject>
,並且放進同一個容器中。當然,因為我們推了兩個不同型別的元素,導致 Rust 無法正確推導出 Vec
容器的型別,因此我們在宣告時必需明確指出 v
的型別是 Vec<Box<JsonObject>>
。
擴充基本型別
Rust 的基本型別與自訂型別地位相同,你也可以替基本型別定義成員函式,甚至讓他實作某個 trait。比如說,我們可以讓最常見的 i32
實作 JsonObject
:
|
|
或是讓內建的 String
型別也實作 JsonObject
:
|
|
這意味 Box<i32>
與 Box<String>
可以安全地轉型為 Box<JsonObject>
:
|
|
為某個具體型別實作 trait 有一個限制:為了避免多個實作互相衝突,實作 trait 的 impl
區塊必需與 trait 或是具體型別擺在同一個函式庫 (Rust 稱之為 crate) 當中。簡而言之:
你寫的 trait | 別人寫的 trait | |
---|---|---|
你寫的型別 | 👌 | 👌 |
別人寫的型別 | 👌 | ⛔ |
Trait 的實作
Rust 允許我們擴充所有已存在的型別,讓它們可以實作出新的界面,這點對熟悉 C++ 的讀者來說頗為神秘:為了達成多型,物件中必需有額外欄位指向虛擬函式表 (vtable),才能在執行時期將同名的函式呼叫分派到不同類別的實作當中,如下圖:
在 C++ 中,由於虛擬函式表的位址固定儲存在物件實體上,因此沒有預留這個空間的內建型別,自然就沒辦法添加任何虛擬成員函式。而其它的類別雖然可以透過多重繼承的方式為其添加界面,但必然要修改既有的程式碼。
Rust 雖然也有虛擬函式表,但並不把它的位址儲存在物件中,而是把它與 trait 參考放在一起,如下圖:
從這張圖可以看出,任何指向 trait 的參考,其實會占用兩個指標,其中一個指向物件實體,另一個指向虛擬函式表。這樣的作法雖然會增加記憶體使用量,但換來了極佳的彈性。即使是其他人製作的型別,你也可以自由擴充它。
Trait 在泛型程式設計 (generic programming) 中也扮演重要角色,我會在後續的文章中詳細介紹。
解構式
你可以實作 Drop
trait,這麼一來型別就有了解構式,允許你使用 C++ 中常見的 RAII 手段管理資源。
|
|
所有實作 Drop
trait 的物件在生命周期結束時,編譯器會自動為它呼叫 drop()
以釋放資源。當然,即使你沒有實作 Drop
,但物件中包含了實作 Drop
的成員,那麼當物件的生命周期結束時,編譯器也會自動地呼叫這些成員的解構式。
在 C++ 中,只要你心臟夠大顆,可以直接呼叫物件的解構式,只是你得自行避免物件在解構後又被拿來用,或是出現重覆解構的情況。在 Rust 中你也可以手動呼叫 drop
來解構物件,但編譯器知道你解構了物件,因此會阻止你做出危險行為。
|
|
Move Semantics
在 C++ 中有所謂的「三位一體原則」(rule of three) 或「五位一體原則」(rule of five),意思是如果某個類別定義了解構式,那麼一定也要定義出複製建構式 (copy constructor) 並覆載等號賦值 (copy assignment),否則這個物件很容易因為複製出暫時物件,而導致解構式重覆釋放了內部資源。
|
|
Rust 沒有這樣的規則。在預設情況下,包括 struct 在內的所有自訂型別都具備 move semantics,因此使用等號賦值,或是用 by-value 方式傳遞到函式內,都會導致所有權轉移。只要變數失去了所有權,在它生命週期結束時就不會呼叫解構式,從而避免重覆釋放資源的問題。
|
|
如果你自己設計了某些方法來複製資源,比如說額外再增加一個連往相同 server 的連線,Rust 的慣例是實作 Clone
trait:
|
|
有些型別的成員都是單純資料 (POD, plain old data),實作 Clone
時也都只有單純的欄位複製,我們可以用 #[derive(Clone)]
讓編譯器自動幫我們實作出逐欄位複製的 clone()
:
|
|
Clone
trait 仍然會保留 move semantics,因此使用等號直接賦值時仍然會導致所有權轉移。如果我們想表達型別完全就是 POD,可以直接用等號直接複製其內容,而不需要轉移所有權,只要再加上 Copy
trait 即可:
|
|
Copy
的意思是該型別遇到等號賦值或 call-by-value 的函式傳遞時,會直接呼叫 clone()
創造出複本,因此 Copy
是 Clone
的子集合,實作 Copy
的型別一定要實作 Clone
。
運算子複載 (Operator Overloading)
Copy
與 Clone
其實就相當於 C++ 中覆載 (overload) 等號賦值的行為。那麼 Rust 可以覆載其它的運算子嗎?答案是肯定的,而且也是透過 trait。
比如說,實作 Add
trait,我們的型別就可以透過加號進行運算:
|
|
在 impl
區塊中,我們除了定義 Rational
的加法外,還定義了 Output
這個型別,代表加法的輸出型別。儘管這個 trait 的成員函式 add()
的回傳型別已經說明了 Rational
相加的結果仍然是相同的 Rational
,因此這邊定義 Output
似乎有點多此一舉,但我們等一下就會看到它的用處。
當然,我們可以讓 Rational
與其它型別相加:
|
|
當不同的型別可以透過運算子覆載進行操作時,往往會讓我們搞不清楚輸出型別,而難以在必要的地方標示型別。比如說我們寫了一個函式把許多 Rational
與 Complex
加起來:
|
|
我們知道這兩個型別可以相加,但相加後的型別又是什麼?當然我們可以翻閱文件後填一個正確的型別上去,但如果未來輸出型別有更改,那麼這段函式定義也得跟著改才行。所幸,Add
trait 中的 Output
可以幫我們解決這個問題:
|
|
回傳型別看起來很複雜,它想表達的是「在 Rational
對 Add<Complex>
的實作中,所定義的 Output
型別」。因此,編譯器會抓出對應的 impl
區塊,找到裡面的 Output
作為這個函式的回傳型別。
儘管運算子覆載是個大家爭論不休的語言功能,但它在泛型程式設計中確實占了重要地位,有興趣的讀者可以參考 std::ops
的文件,上面列出你可以覆載的運算子。幸運的是,C++ 中邪惡的逗號 (,
) 與邏輯運算 (&&
與 ||
) 並不在其中。[4]
結語
本文介紹了在 Rust 中實現物件導向程式設計的方法。Rust 引入了 trait、禁止類別繼承、又讓自訂型別擁有 move semantics,是與 C++ 相當不同的設計。然而在 RAII 與運算子覆載上,又可以處處看見 C++ 的影子。
在下一篇文章中,我會介紹另一種重要的自訂型別:列舉 (enum),以及搭配它的樣式比對 (pattern matching)。
Rust 使用 method 這個 OOP 中的主流用語來表達成員函式 (member function),不過這篇文章的主要對象是 C++ 使用者,因此我會繼續使用「成員函式」。 ↩
Constructor 這個字眼在 functional programming 中具有不同的含義,本篇文章中的 constructor 意指在 OOP 語言中,用來初始化物件的函式。 ↩
Rust 鼓勵你用組合 (composition) 代替繼承,但你硬要做的話還是辦得到,方法是直接把一個父類別物件放進成員中,並且用它實作
Deref
與DerefMut
trait。 ↩覆載這些運算子會改變運算式的求值順序,導致難以名狀的 bug,詳情可參考 C++ 知名教科書 Effective C++。 ↩