原文鏈接https://doc.rust-lang.org/nomicon/subtyping.html
最近正在學習Rust語言的一些相關特性,讀到一篇關於lifetime並且比較難理解的文檔,所以靜下心來好好梳理了一遍,最后把其中比較重要的內容整理成博客發表在這里。
子類型機制(Subtyping)
Subtyping的存在是為了使得編寫靜態類型的語言時可以享受相對較高的靈活性和自由度,由於Rust中不存在繼承這一概念,所以我們很難直觀地給出一個Rust中Subtype的定義。
trait Animal {
fn snuggle(&self);
fn eat(&mut self);
}
trait Cat: Animal {
fn meow(&self);
}
trait Dog: Animal {
fn bark(&self);
}
需要注意的是,盡管從邏輯的角度來講,Dog和Cat作為trait應該是Animal的子類型,並且這種冒號的語法特征很容易讓人回想起C++中關於繼承的定義,但我們應該始終牢記Rust中並沒有繼承這一概念,Dog和Cat實際上並沒有從Animal中獲得任何的信息,這種語法更多的是對開發人員的一種約束,它要求開發人員在實現Dog或Cat之前必須先為該類型實現Animal。
隨后我們考慮如下函數:
fn love(pet: Animal) {
pet.snuggle();
}
這里pet:Animal這種聲明參數的方式無法通過Rust編譯器的檢查,因為Trait並不是一個具體的實例,他只有在作為Trait Object時才能作為參數傳入一個函數中。但為了更好地簡介地說明這個例子,我們假設Rust中允許Trait作為一個單獨的實例存在。
根據Rust語言對類型的限制,這個函數中只接受Animal作為參數,但是這顯然是一種違反直覺的做法,既然Cat和Dog是Animal的一種,那么他們顯然可以"被love",我們沒有理由也沒有必要為了Animal、Cat和Dog分別實現各自版本的love函數。所以Subtyping機制的存在實際上就是允許我們將parameter的子類型作為argument傳入函數中,這樣可以大大優化代碼的復用能力。
原文中將Rust中的Subtyping機制總結為如下一句話:
Anywhere a value of type
Tis expected, we will also accept values that are subtypes ofT.
盡管可能存在一些額外的情況,但對於編寫Safe Rust的程序員而言,這一原則適用於絕大部分的場景。
但對於Unsafe Rust而言,情況會變得尤為復雜,許多錯誤也隨之而來。
如下是一個Meowing Dogs的案例:
fn evil_feeder(pet: &mut Animal) {
let spike: Dog = ...;
// `pet` is an Animal, and Dog is a subtype of Animal,
// so this should be fine, right..?
*pet = spike;
}
fn main() {
let mut mr_snuggles: Cat = ...;
evil_feeder(&mut mr_snuggles); // Replaces mr_snuggles with a Dog
mr_snuggles.meow(); // OH NO, MEOWING DOG!
}
在上述的pseudo-code中,在先前提到的約束條件下,evil_feeder函數被允許接受Cat類型(Animal的子類型)作為參數傳入函數中。而在函數的內部邏輯中,Cat被視為和Animal同等的存在,於是,將一個Dog類型賦值給Animal變量的操作也將被允許。這樣做的最終結果即是我們得到了一只會喵喵叫的狗。
上述案例盡管相對很直觀,但其實這種問題永遠不會發生在Rust實際的編寫過程中,因為Trait並不可以被視為一個單獨的實例看待。事實上,Subtyping機制在Rust中真正的應用場景是在lifetime中,由於lifetime這一概念本身並不直觀,所以原文借助先前的例子作為一個引入。
接下來我們對上述Meowing Dog問題的討論將全部基於lifetime展開。
但在此之前,我們需要先澄清lifetime是基於何種方式反應Subtyping的思想的:概括地來講,lifetime實際上就是一塊一塊的代碼區域,並且這些區域之間存在着偏序關系,如果引用big的lifetime完全覆蓋了引用small,那么我們說big比small活得更長(a outlives b),或者說,big是small的一個subtype。對於一些剛剛接觸lifetime和subtype的novice來說這種定義方式多少有一些counterintuitive。從如下的角度來思考可能更有助於理解一些:貓的定義等於動物的定義附加一些額外的信息,那么同樣的,引用big也是引用small附加上額外lifetime后的結果。
換一種說法,當我們的函數想要一個存活期為small的引用作為參數時,接受一個存活期為big的引用作為參數顯然是可以接受的。二者的lifetime並不需要完全匹配。
於是對於lifetime而言,Meowing Dog問題就轉變成了試圖向一個存活期長的引用中存入一個存活期短的引用,進而導致懸掛指針等問題的出現。
此外,還需要要特別注明一點,由於static可以存活於整個程序運行過程的上下文中,所以lifetime為static的引用是任何引用的subtype。
在接下來的部分我們將探討Rust中用於處理上述問題的機制:Variance
型變(Variance)
Variance在Rust中的作用即在於規定了一套限制類型與類型間是否存在subtype關系的評判標准,從而限制了subtyping機制的適用場景。
所以簡單來說,Variance和subtyping不同,后者是作用於lifetime的機制,而前者是作用於type constructor的機制。所謂type constructor就是可以被賦予任何類型的泛型,如Vec<T>, &, &mut等,其中對於&和&mut來說,不僅接受類型T,也可以接受lifetime,在接下來的討論中,我們將以F<T>來表示一個type constructor。
一個type constructor的variance解釋了其輸入的subtyping機制是如何影響輸出的subtyping機制的。
Rust中一共有三種variance,假設Sub是Super的subtype,那么:
-
如果
F<Sub>仍然是F<Super>的subtype,那么F服從covariant(協變),即subtyping機制被傳遞了。 -
如果
F<Super>是F<Sub>的subtype,那么F服從contravariant(逆變),即subtyping機制被逆轉了。 -
在其他的情況下則
Finvariant(不變),即subtypying機制不復存在。
如果一個F接受多個類型作為參數,如F<U, V>, 則我們可以對每一個類型單獨討論,如F<U, V>對於U服從協變,對於V不變。
事實上,我們完全可以把covariance直接視為的variance,因為covariance和invariance已經覆蓋了Rust中絕大多數的場景,我們只會在很有限的情況下遇到contravariance。
如下是常見的一些型變情況,我們將會在接下來的介紹中涉及到其中標注了*號的部分:

在進入介紹部分之前,讓我們先回顧一下Meowing Dog問題:
fn evil_feeder(pet: &mut Animal) {
let spike: Dog = ...;
// `pet` is an Animal, and Dog is a subtype of Animal,
// so this should be fine, right..?
*pet = spike;
}
fn main() {
let mut mr_snuggles: Cat = ...;
evil_feeder(&mut mr_snuggles); // Replaces mr_snuggles with a Dog
mr_snuggles.meow(); // OH NO, MEOWING DOG!
}
結合先前的表格再來討論evil_feeder函數,我們會發現&mut T對於T來說是不變的。即&mut Cat和&mut Animal之間並不存在我們先前所假定的subtype關系,如此一來Meowing Dog的問題就解決了,靜態類型檢查器將阻止我們將&mut mr_snuggles作為參數傳入evil_feeder中。
subtyping機制的優越性在於對於某些特定的問題,我們可以忽略一些不必要的細節(如忽略一個Animal作為Cat的細節)。但在引用的場景下,即使我們只引用了一部分細節,所有的細節卻始終被被引用對象所持有着,所以必須保證被引用對象所維護的那些對於本次引用而言“不重要的細節”不會被篡改。具體來說,在evil_feeder中,參數Animal作為Cat的細節被忽略了,該函數同樣也不會關心這個Animal已經被加入了Dog的細節。然而,被引用的對象mr_snuggles始終相信自己所持有的是Cat,於是錯誤發生了。
這也正是Rust將%mut T對T的型變定義為invariance的原因,對待一個可以被修改的的對象的時候,我們總是需要十分小心,因為&mut Sub所關注的僅僅是一部分的細節,但它卻擁有着篡改所有來自&mut Super的細節的權限。
在此基礎上,Rust將&T定義為covariance的理由也變得顯而易見:&T沒有修改細節的權限,自然也不會導致被引用者持有的細節遭到破壞。而UnsafeCell一類的內部可變數據類型也因為類似的原因被定義為invariance
搞清楚如上的概念以后,我們就可以把所有的注意力放在lifetime上,即Rust中真正實現了subtyping機制的對象上。
Subtyping對於lifetime的優化主要體現在如下兩個方面:
首先,基於lifetime的subtyping是Rust中唯一實現的subtyping,這一種subtyping使得Rust程序員可以將存活期長的引用傳遞給一個存活期較短的引用,從而優化了代碼的復用性。
第二,lifetimes屬性只屬於某一個特定的引用,對於同一數據的不同引用可以有不同的lifetime。而被引用對象所持有的是一些被這些引用所共享的信息,這也導致了對於某一引用的修改會帶來一系列的問題。但如果subtyping機制傳遞了了某一次引用到另一個不同lifetime的引用中是不會對傳遞前的引用產生任何影響的。這將會是兩次互相獨立的引用。
但凡事都有例外,Meowing Dog問題的存在使得傳入前的引用的lifetime被縮短了,所以接下來就讓我們討論lifetime下的Meowing Dog問題是如何產生的。
在貓和狗的例子中,我們接受了一個subtype(Cat)作為參數,並把它轉換為了一個supertype(Animal),隨后使用一個同為Animal的Subtype的Dog覆蓋了原先的Cat,而這一切行為在我們最初所討論的規則下都是合法的。
於是在lifetime的例子中,我們將會接受一個存活期較長的argument,並把它轉化為一個存活期較短的parameter傳入函數中,隨后傳給這個新的parameter一個沒有原先的argument那么長存活期的引用,代碼實現如下所示:
fn evil_feeder<T>(input: &mut T, val: T) {
*input = val;
}
fn main() {
let mut mr_snuggles: &'static str = "meow! :3"; // mr. snuggles forever!!
{
let spike = String::from("bark! >:V");
let spike_str: &str = &spike; // Only lives for the block
evil_feeder(&mut mr_snuggles, spike_str); // EVIL!
}
println!("{}", mr_snuggles); // Use after free?
}
如上的代碼顯然是無法通過編譯檢查的,錯誤信息如下所示:
error[E0597]: `spike` does not live long enough
--> src/main.rs:9:32
|
9 | let spike_str: &str = &spike;
| ^^^^^ borrowed value does not live long enough
10 | evil_feeder(&mut mr_snuggles, spike_str);
11 | }
| - borrowed value only lives until here
|
= note: borrowed value must be valid for the static lifetime...
而我們討論的重點在於編譯器是如何做出編譯不通過的判斷的。
首先讓我們考慮evil_feeder函數的簽名:
fn evil_feeder<T>(input: &mut T, val: T) {
*input = val;
}
函數的參數列表告訴我們,它只接受兩個相同類型的數據作為參數,但在main函數中,我們傳入了&mut &'static str和&'spike_str str作為argument,需要注意的是,在這里&'static str'是作為一個完整的類型來看待的,所以它適用於&mut T中對T的型變規則,並且,此時該泛型函數的T已經被monomorphize為evil_feeder<&'static str>(關於monomorphization的內容請參考https://doc.rust-lang.org/stable/book/ch10-01-syntax.html#performance-of-code-using-generics)。所以此時val的類型也應該為‘static str,而根據subtyping和variance機制,此時val只能接受&'static str或其subtype作為參數。於是我們找到了錯誤的來源!
正如我們先前提到的那樣,&'static str是所有&str類型的subtype,所以除非我們傳入的第二個argument同樣具有'static的lifetime,否則就違背了&'a T的協變機制,因此編譯失敗。
另外,對於Box、Hashmap、Vec等類型來說,設計為協變並不會造成嚴重的錯誤,因為每當這些類型被放在一個可能帶來危險的上下文中時,他們會天生地“繼承”invariance,如進行一次&mut Vec<T>時。
最后,對於函數指針類型fn(T) -> U,情況變得有一些不一樣:
首先,對於函數的返回值U來說,滿足協變條件是很容易理解的,如果我們希望一個函數返回一個動物,那么我們並不在意它返回的是貓還是狗,但對於函數的參數來說,原文中使用了如下的類比來進行說明:
If we need a function that can handle Cats, a function that can handle any Animal will surely work fine. Or to relate it back to real Rust: if we need a function that can handle anything that lives for at least
'long, it's perfectly fine for it to be able to handle anything that lives for at least'short.
