Rust 的內建 Traits, 使用場景, 方式, 和原因

2020-08-13 12:07:18

[rust! #004] [譯] Rust 的內建 Traits, 使用場景, 方式, 和原因

如標題中明示的, 今天我要寫一下Rust標準庫中帶來的 traits, 特別是從標準庫作者的角度, 向使用者提供一個好的體驗.

注意, 我將"內建"定義爲"Rust安裝包中所自帶的". 這些 traits 沒有特殊的語言機制 機製.

Rust 在很多地方使用了 traits, 從非常淺顯的操作符過載, 到 Send, Sync 這種非常微妙的特性. 一些 traits 是可以被自動派生的(你只需要寫#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Hash, ...)]就能得到一個神奇的實現, 它通常是對的. 至於 Send 和 Sync, 你必須主動選擇關閉它們的實現.

所以, 我會試着從淺顯具體的部分開始, 直到那些模糊甚至(也許)令人驚訝的部分.

(Partial)-Eq/Ord

PartialEq 定義了部分相等. 這種關係具有對稱性(對於該型別的任意a,b, 有 a == b → b == a)以及傳遞性(對於該型別的任意a,b,c, 有 a == b ∧ b == c → a == c).

Eq 被用來表示 PartialEq 並且是自反關係(對於該型別的所有a, 有 a == a).反例: f32 和 f64 型別實現了 PartialEq, 但沒有 Eq, 因爲 NAN != NAN.

實現這兩個 traits 是很有用的, 標準庫中很多的型別會使用它們來作爲辨別界限, 例如: Vec的 dedup() 函數. 自動派生出的 PartialEq 將會對你的型別中的所有部分做相等檢查(例如, 對於 structs, 所有部分都會被檢查, 對於 enum 型別, 將檢查該變體及其所有內容).

由於 Eq 的實現通常是空的(除了預先定義的marker方法, 以確保自動派生邏輯可以工作, 它不應該在其它地方被使用), 所以自動派生不會做一些有趣的事情.

PartialOrd 定義了部分順序, 並通過 Ordering 關係擴充套件了 PartialEq 的相等性. 這裏的 Partial 意味着你的型別的某些範例可能不能被有意義地比較.

Ord 需要完整的順序關係. 與 PartialEq/Eq 相反, 這兩個 trait 實際上使用了不同的介面(partial_cmp(...) 方法返回 Option<Ordering>, 所以它可以返回不可比較的範例 None, 而 Ord 的 cmp(...) 方法直接返回 Ordering), 它們唯一的關係就是, 如果你想實現 Ord, 那麼你也必須實現 PartialOrd, 因爲後者在前者的 trait 範圍內.

自動派生將按照字典序來排列 structs, 按照定義中變體出現的順序來排列 enums(除非你爲變體定義了值).

如果你選擇手動實現關係, 請注意確保穩定的排序關係, 以免你的程式因混亂而崩潰.

(Partial)Ord 作用於 <, <=, => 和 > 運算子.

算數運算子

下表是算數運算子與 traits 的關係:

Operator     Trait
a + b         Add
a - b         Sub
-a           Neg
a * b         Mul
a / b         Div
a % b         Rem

Rem 是 Remainder(求餘)的縮寫, 在有些語言中也叫 mod. 二元運算子都必須有一個預設爲 Self 的RHS(右手邊) 泛型系結, 而且實現必須宣告相關的 Output 型別.

這意味着你可以實現例如: Foo 加上 Bar 返回 Baz. 注意, 雖然操作符不會以任何方式限制它們的語意, 但強烈建議不要使它們的含義與所示的算數運算完全不同, 以免你的實現給其他開發者造成隱患.

另外: 在 Rust 1.0.0 之前, 有人爲 String 和 Vec 實現了 Add, 作用是連線. 直到(許多人)心痛地請求了 Rust 的神之後, 才爲Vec修復了這個錯誤. 也就是說, 你仍然可以寫 my_string + " etc." 只要 my_string 是一個 String ---- 注意, 這會消費掉 my_string 的值, 可能會使一些人感到困惑.

位運算子

以下操作符被定義用於位運算. 不同於!運算子, 短路運算子&&||不可以被過載----因爲這要求它們不能 eager 地執行它們的參數, 這在 Rust 中不容易做到 ---- 即使這是可能的, 例如: 使用閉包作爲解決方法, 也只會讓其他開發者感到困惑.

Operator     Trait
!a             Not
a & b         BitAnd
a | b         BitOr
a ^ b         BitXor
a << b         Shl
a >> b         Shr

就像所有的操作符一樣, 當你有特殊的理由要爲你的型別實現它們時, 要格外小心. 例如: 爲 BitSets(從1.3.0起它不再是標準庫的一部分) 重定義它們中一些是有意義的, 或者是對錶示巨大整數的型別.

Index 和 IndexMut

Index 和 IndexMut traits 制定了對不可變和可變型別的索引操作. 前者是隻讀的, 後者允許賦值和修改, 即呼叫一個參數爲 &mut 的函數(注意這不一定是 self ).

你可以對任何 collection 型別來實現它們. 在其它情況下使用這些 traits 都會變成絆腳石.

Fn, FnMut 和 FnOnce

Fn* 類的 traits 是對呼叫某些東西的抽象. 它們的差別僅僅是如何處理 self: Fn 使用參照, FnMut 使用可變參照, FnOnce 消費掉它的值(這就是爲什麼只能被呼叫一次, 因爲之後沒有 self 可供呼叫了).

注意, 這些區別只針對與 self, 與其它任何參數無關. 用可變參照甚至是 owned/moved 的值作爲參數來呼叫 Fn 是完全正確的.

這些 traits 是爲函數和閉包自動派生出來的, 我還沒有見過它們其它的使用場景. 它們實際上不可以在穩定版的 Rust 中被實現.

Display 和 Debug

Display 和 Debug 用於格式化值. 前者是爲了產生面向使用者的輸出, 所以不可以自動派生, 而後者通常會產生類似JSON的表示, 並且可以安全地爲大多數型別自動派生.

如果你確定手動實現 Debug, 你可能需要區分正常的{:?}和整潔格式的{:#?}表示符. 最簡單的方式是使用 Debug Builder 方法. Formatter 型別具有許多非常有用的方法, 例如 debug_struct(&mut self, &str), debug_tuple(&mut self, &str) 等等.

或者, 你可以通過查詢 Formatter::flags() 方法來做到這一點, 該方法將返回4位元位. 因此, 如果(f.flags() & 4) == 4爲真, 那麼呼叫者就是在請求你輸出整潔格式.

說真的, 如果你有需要, 請使用自動派生 Debug 或者是 debug builders.

除此之外: 這不是很常見, 但Rust中有可能會出現回圈物件圖, 它會把 debug 邏輯發送到無限回圈裡(通常, 應用會因爲棧溢位而崩潰). 在大多數情況下, 這是可以接受的, 因爲回圈非常罕見. 當你懷疑你的型別比平均更頻繁地形成回圈時, 你可能需要處理這些.

Copy 和 Clone

這兩個 traits 用於複製物件.

Copy 表明你的型別可以被安全地複製. 這意味這如果你複製了型別的值所在的記憶體, 就會得到一個新的有效的值, 而不會參照原始數據. 它可以是自動派生的(需要 Clone, 因爲根據定義, 所有可 Copy 的型別都可 Clone). 事實上, 從來不需要手動地實現它.

這裏有三個不實現 Copy 的理由:

  1. 你的型別不可以被 Copy, 因爲它包含了可變參照, 或者已實現了 Drop.

  2. Rust Guru eddyb 指出, 除非你使用參照, 否則 Rust 會複製所有東西, 而它們的體積可能很大.

  3. 事實上你需要的是 move 語意.

第三個原因需要進一步的解釋. 預設情況下, Rust 已經有了 move 語意---- 如果你將a的值從a賦值爲b, a不再具有原來的值. 然而, 對於實現了 Copy 的型別, 其值實際上被複制了(除非原來的值不再被使用, 這時LLVM可能會刪除副本以提高效能). Copy 的文件中有更多的細節.

Clone 是一個更通用的解決方案, 會顧及到所有的參照. 你可能想要在大多數情況下自動派生(因爲能夠 Clone 的值是非常有用的), 只有類似自定義參照計數規則, 垃圾回收等等情況下, 纔會需要手動實現它.

Copy 實際上改變了賦值語意, 而 Clone 是顯式的: 它定義了 .clone() 方法, 你必須要手動呼叫.

Drop

Drop trait 的作用是當事物到達範圍之外時, 歸還所佔用的資源. 關於此已經有過很多討論了, 以及你爲什麼不應該直接呼叫它. 不過, 這十分適用於包裝 FFI 結構, 它們需要在稍後才被回收, 同時也適用於檔案, sockets, 數據庫控制代碼以及廚房水槽.

除非你有一個合適的場景, 否則你應該避免自己實現 Drop ---- 預設情況下, 你的值總會被正確地 Drop. 一個(臨時的)異常是插入一些輸出來跟蹤特定的值是何時被 drop 的.

Default

Default 用於宣告型別的預設值. 它可以被自動派生, 但只適用於所有成員都有 Default 實現的結構.

它在標準庫中被許多型別實現了, 並且可以在很多地方使用. 所以如果你的型別有一個可以被認爲是"預設"的值, 那麼實現這個 trait 是一個好主意.

Default 很棒的地方在於, 當你初始化一個值時, 你只需要設定非預設的部分就可以了:

let x = Foo { bar: baz, ..Default::default() }

然後 Foo 的其餘42個欄位都會被預設值填充. 很酷吧? 事實上, 不實現 Default 唯一的理由是你的型別並沒有一個可以作爲預設值的值.

Error

Error 是 Rust 中所有表示錯誤的值的基本 trait. 對Java熟悉的人來說, 相當於是 Throwable, 它們的行爲也類似(除了我們既不 catch 也不 throw 它們).

爲之後在 Result 中所使用的任何型別都實現 Error, 是一個好主意. 這會使你的函數更加便於組合, 尤其是當你可以簡單地用 Box 來把 Error 變爲一個 trait 物件.

檢視 Rust book 的 Using try! 章節以獲得更多資訊.

Hash

雜湊是將一包數據減少爲單個值的過程,不同的數據雜湊後的值依然不同,相同的依然相同,而不需要比較雜湊前的數據那麼多的位.

在Rust中, Hash trait 表示該型別是否可以被雜湊。 請注意,這個特性並不涉及任何有關雜湊演算法的資訊(這是封裝在 Hasher trait中),它基本上只是命令這些位元位被雜湊.

另外:這也是爲什麼HashMap自己沒有實現Hash的原因,因爲兩個相同的雜湊對映仍然可以以不同的順序儲存它們的內容,導致不同的雜湊,這會破壞雜湊合約。 即使這些專案是有序的(參見上面的Ord ),對它們進行雜湊處理也需要進行排序,這樣做太昂貴了,無法使用。 也可以使用入口雜湊值,但是這需要重新使用 Hasher,至少需要一個Clone界限,缺少這樣的介面。 無論如何,如果您必須用 map 作爲雜湊對映的key,請使用BTreeMap。 在這種情況下,你也應該考慮效能因素。

你可以安全地自動派生Hash, 除非你對於平等的一些非常具體的限制。 如果您選擇手動實施,請小心不要違約,以免程式出人意料,難以偵錯。

Iterator 和它的朋友們

Rust的 for 回圈可以遍歷所有實現了 IntoIterator 的型別。 是的,這包括Iterator本身。 除此之外, Iterator特性有很多很酷的方法來處理迭代的值,比如filter, map, enumerate, fold, any, all, sum, min等等。

我有沒有告訴你我喜歡迭代器? 如果你的型別包含一個以上的值,並且對所有的Iterator都做同樣的事情是有意義的,考慮爲它們提供一個Iterator以防萬一。 :-)

實現Iterator實際上很簡單 - 只需要宣告Item型別並寫入next(&mut self) -> Option<Self::Item>方法。 只要你有值,這個方法應該返回Some(value) ,然後返回None來停止迭代。

請注意,如果你有一個值(或一個數組或vec,你可以從borrow一個切片)的一部分,你可以直接得到它的迭代器,所以你甚至不需要自己實現它。 這可能不像自動派生那樣酷,但它仍然很好。

While writing optional, I found that using a const slice’s iterator is faster in the boolean case, but creating a slice of the value is still slower than copying it for most values. Your mileage may vary.

From, Into 和 各種變化

我之前說過,設計了 From 和 Into 的人是一個天才。 它們抽象了型別之間的轉換(經常使用),並允許庫作者使它們的庫更加容易互操作,例如通過使用Into<T>而不是T作爲參數。

由於顯而易見的原因,這些 traits 不能自動生成,但是在大多數情況下實現它們應該是微不足道的。 如果您選擇實現它們 - 當你找到值得轉換的地方時就應該這樣做! - 儘可能先實現 From, 如果失敗再實現 Into。

爲什麼? 對於U: From<T> 有一攬子的 Into<U> 的實現。 這意味着如果你已經實現了From ,你可以免費得到 Into。

爲什麼不在所有地方實現 From ? 不幸的是,孤兒規則禁止在其他 crates 中實現某型別的 From。 例如,我有一個Optioned<T>型別,我可能想要轉換成Option<T> 。 試圖實現 From :

impl<T: Noned + Copy> From<Optioned<T>> for Option<T> {
    #[inline]
    fn from(self) -> Option<T> { self.map_or_else(|| none(), wrap) }
}

我得到一個錯誤:型別參數T必須用一些本地定義的型別參數(例如MyStruct<T> ); 只有當前 crate 中定義的 trait 才能 纔能用於型別參數[E0210]

請注意,你可以使用多個類實現From和Into ,對於相同的型別,可以有From<Foo>和From<Bar> 。

有很多以Into - IntoIterator開始的 trait,它們是穩定的,我們已經在上面討論過了。 也有FromIterator ,它的作用相反, 即從專案的迭代器構造你的型別的值。

然後有FromStr可以用於任何可以由字串轉換而來的型別,這對於你想要從任何文字原始檔中讀取的型別非常有用,例如設定或使用者輸入。 請注意,它的介面不同於From<&str> ,因爲它返回一個Result ,因此允許將解析錯誤與呼叫者相關聯。

Deref(Mut), AsRef/AsMut, Borrow(Mut) 和 ToOwned

這些都與 references 和 borrowing 有關,所以我把它們分成一個部分。

字首操作符表示對一個參照的參照消除*,得到它的值。 這直接代表了Deref trait; 如果我們需要一個可變值(例如分配什麼東西或呼叫一個變化函數),我們會呼叫DerefMut trait。

請注意,這並不一定意味着消耗這個值 - 也許我們可以在同一個表達式中參照它,例如 &*x (在處理特殊型別的指針的程式碼中,您可能會發現它,例如 syntax::ptr::P 在clippy和其他lints /編譯器外掛中被廣泛使用,也許as_ref()在這些情況下會更清晰(見下文)。

Deref trait只有一個方法: fn deref(&'a self) -> &'a Self::Target; 其中 Target 是 trait 的相關型別。返回值的生命週期要與自己一樣長。 這個要求將可能的實施策略限製爲兩個選擇:

  1. 參照消除得到你的型別中的值,例如,如果您有一個struct Foo { b: Bar } ,則可以參照消除Bar 。 請注意,這並不意味着你應該這樣做,但這是可能的,在某些情況下可能會有用。 只要這個部分的整個生命週期是一個整體,這就是Rust中生命週期的預設設定。

  2. 參照消除得到一個常數'static值 - 我在optional做到這一點,使OptionBool取消參照一個常數Option<bool> 。 這是有效的,因爲結果是保證能夠在程式的其餘部分活着的。 這隻有在你有一個有限的值域時纔有用。 即使如此,使用Into代替Deref也許更爲清楚。 我懷疑我們會經常看到這一點。

DerefMut 只有第一種策略。 它的用處僅限於實現特殊型別的指針。

爲了明白爲什麼沒有其他可能的實現,讓我們進行一個思考實驗:如果我們有一個返回值, 它既不是靜態的,也不是被系結到我們要參照消除的值的生命週期'a,那麼定義中就有一個'b 不同於 'a 。 我們無法統一這兩個生命週期 - QED。

至於其他 traits,它們主要是爲了抽象某些型別的借用/參照行爲(因爲例如Vec, 可以借用它們的切片)。 因此,它們與From / Into屬於同一類別 - 它們不會被幕後呼叫,但是存在某些更好用的介面。

Borrow , AsRef / AsMut和ToOwned之間的關係如下:

From↓/To→     Reference         Owned
Reference     AsRef/AsMut     ToOwned
Owned         Borrow(Mut)   (也許是Copy或Clone?)

可以看看我更早的關於std::borrow::Cow偵探故事, 裏面有一些具體的例子 。

如果您決定實現 Borrow 和/或 BorrowMut ,則需要確保borrow()的結果與借入的原始值具有相同的雜湊值,以免程式以奇怪和混亂的方式失​​敗。

事實上,除非你的型別對所有權做了一些有趣的事情(比如Cow或owning_ref ),否則你應該避免 Borrow , BorrowMut和ToOwned ,如果你想抽象擁有/借用的值,就使用Cow 。

我還沒有找到在什麼情況下, AsRef/AsMut可能是有用的,除非你算上std已經提供的預定義的impl 。

Send 和 Sync

這兩個trait表明, 該型別可以線上程之間傳遞.

你永遠不需要實現它們----事實上, 除非你明確地拒絕(或者你的型別包含非執行緒安全的部分), 否則Rust會預設爲你實現.你可以這樣拒絕:

impl !Send for MyType {} // this type cannot be sent to other threads
impl !Sync for MyType {} // nor can it be used by two of them

注意, 目前在穩定版的Rust 中這是不可能的(也就是隻有 std 可以使用這個技巧).

Send 表示可以再執行緒之間 move 你的型別, 而 Sync 允許線上程之間共用一個值. 讓我們退一步看看這是什麼意思, 這可能是最好的例子.

假設我們有一些問題, 我們打算通過並行計算一些值來解決(因爲併發就是這樣!). 爲此, 我們需要一些在所有執行緒中都是相同的不可變數據----我們需要共用數據, 這些數據需要 Sync.

接下來, 我們要分配給每個執行緒. 要做到這一點, 我們需要 Send 給它們. 但是等等! 我們如何獲取分享到執行緒的數據呢? 很簡單: 我們 Send 的是參照----因爲在標準庫中有以下定義:

impl < 'a, T > Send & 'a T where T: Sync + ? Sized

這意味着如果有東西可以被 Sync, 你就可以線上程間 Send 它的參照.酷.

關於這些, 可以看看 Manish Goregaokar 的 Rust 如何實現執行緒安全 或者是 Sync docs