Try   HackMD

Crust of Rust : Subtyping and Variance

直播錄影

  • 主機資訊
    ​​​​wilson@wilson-HP-Pavilion-Plus-Laptop-14-eh0xxx ~/CrustOfRust> neofetch --stdout
    ​​​​wilson@wilson-HP-Pavilion-Plus-Laptop-14-eh0xxx 
    ​​​​----------------------------------------------- 
    ​​​​OS: Ubuntu 22.04.3 LTS x86_64 
    ​​​​Host: HP Pavilion Plus Laptop 14-eh0xxx 
    ​​​​Kernel: 6.2.0-37-generic 
    ​​​​Uptime: 22 mins 
    ​​​​Packages: 2367 (dpkg), 11 (snap) 
    ​​​​Shell: bash 5.1.16 
    ​​​​Resolution: 2880x1800 
    ​​​​DE: GNOME 42.9 
    ​​​​WM: Mutter 
    ​​​​WM Theme: Adwaita 
    ​​​​Theme: Yaru-dark [GTK2/3] 
    ​​​​Icons: Yaru [GTK2/3] 
    ​​​​Terminal: gnome-terminal 
    ​​​​CPU: 12th Gen Intel i5-12500H (16) @ 4.500GHz 
    ​​​​GPU: Intel Alder Lake-P 
    ​​​​Memory: 5798MiB / 15695MiB 
    
  • Rust 編譯器版本 :
    ​​​​wilson@wilson-HP-Pavilion-Plus-Laptop-14-eh0xxx ~/CrustOfRust> rustc --version
    ​​​​rustc 1.70.0 (90c541806 2023-05-31) (built from a source tarball)
    

Introduction

0:00:00

In this episode of Crust of Rust, we go over subtyping and variance — a niche part of Rust that most people don't have to think about, but which is deeply ingrained in some of Rust's borrow ergonomics, and occasionally manifests in confusing ways. In particular, we explore how trying to implement the relatively straightforward strtok function from C/C++ in Rust quickly lands us in a place where the function is more or less impossible to call due to variance!

即便你看完了 Subtyping and Variance,可能還是不知道實際的用途。lifetime-variance 的程式碼可以讓你更清楚。

Practical variance in strtok

0:02:30

本次直播會用實際的方法來展示會什麼你需要在意 variance,它出現在您可能編寫的程式碼中的什麼位置?為此,我們要做的是實作一個來自 C++ 和 C 的函式,也就是 strtok 函式。

strtok 函式的使用方式是傳入字串以及 delimiter,它要做的就是將字串回傳給你直到遇到下一個 delimiter,然後它將更改你提供給它的字串以刪除它剛剛回傳的 prefix。因此,你可以將其視為類似於 Rust 標準庫中的 strip prefix,只不過你使用的是 delimiter 而不是字串。

strtok 函式有點像 split 函式,但不同之處在於它不會給你一個迭代器,它會就地改變字串,以便你可以繼續呼叫這個函式。

我們將會以簡單的方式實作 strtok 函式,並且在實作的過程中會發現 variance 會讓 strtok 函式很難使用。我們將會看到如何解決這個問題。

由於該主題 Jon 不常用,本次直播可能必須進行一些除錯並找出幕後實際發生的情況,而且也有可能用字不精確,因為太多術語了,需多加注意。

開始建置 Rust 專案 :

$ cargo new --lib strtok
$ cd vecmac
$ vim src/lib.rs

開始實作 strtok 原型 :

// 1. C++ 函式的 delimiter 是 `const char *`, // 但這裡只是要示範,所以 delimiter 宣告成 char。 // 2. 傳入字串為 mutable 參考。 // 想像你傳入的是字串指標,strtok 將改變指標, // 讓該指標指到字串後面一點的位置。 // 3. 生命週期參數先全部設為 'a pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str { "" }

Q : Is it pronounced "str-talk" or "str-tohk"?
A : 不確定,可以去看 C 標準函式庫的發音導覽。

A simple strtok test

0:07:41

實作簡單的測試 :

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; let hello = strtok(&mut x, ' '); assert_eq!(hello, "hello"); assert_eq!(x, "world"); } }

Implementing strtok

0:09:45

開始實作 strtok 函式 :

pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str { // find 會回傳第一個 delimiter 的位置, // 如果不存在則回傳 None。 if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; prefix } else { let prefix = *s; *s = ""; prefix } }

strtok 函式的邏輯並不複雜,程式可能有錯,等等會測試,但 strtok 函式的內部實作細節對於我們今天要討論的內容來說並不那麼重要。

Why can't we call strtok?

0:13:00

目前程式碼
pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; prefix } else { let prefix = *s; *s = ""; prefix } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; let hello = strtok(&mut x, ' '); assert_eq!(hello, "world"); assert_eq!(x, "world"); } }

測試出現以下錯誤 :

cargo test 
...
error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
  --> src\lib.rs:25:9
   |
23 |         let hello = strtok(&mut x, ' ');
   |                            ------ mutable borrow occurs here
24 |         assert_eq!(hello, "world");
25 |         assert_eq!(x, "world");
   |         ^^^^^^^^^^^^^^^^^^^^^^
   |         |
   |         immutable borrow occurs here
   |         mutable borrow later used here
...

strtok 的函式宣告讓 hello 的生命週期會跟 x 的生命週期一樣長。只要 hello 還活著,x 就持續為 mutably borrowed,又因為 Line 25 的 x 還存在,所以編譯器回報錯誤。

嘗試加個 scope :

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; { let hello = strtok(&mut x, ' '); assert_eq!(hello, "world"); // 離開 scope 後,預期 borrow 將消失, // 不應該再有任何東西保留 x 的 mutably borrow。 } assert_eq!(x, "world"); } }
目前程式碼
pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; prefix } else { let prefix = *s; *s = ""; prefix } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; { let hello = strtok(&mut x, ' '); assert_eq!(hello, "world"); } assert_eq!(x, "world"); } }

:-1: 測試仍然出現 error[E0502]

稍微修改 strtok 函式 :

-pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str +pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'static str +// 使用 static,讓我們暫時不用管生命週期 { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; - prefix + "" } else { let prefix = *s; *s = ""; - prefix + "" } }
目前程式碼
pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'static str { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; "" } else { let prefix = *s; *s = ""; "" } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; { let hello = strtok(&mut x, ' '); assert_eq!(hello, "world"); } assert_eq!(x, "world"); } }

:-1: 測試仍然出現 error[E0502]。可以得知這跟回傳值的生命週期並沒有關係,因為回傳值的生命週期已經宣告成 static 了 :

再更激進一點,連回傳值都不要了 :

-pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'static str +pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; - "" } else { let prefix = *s; *s = ""; - "" } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; { let hello = strtok(&mut x, ' '); - assert_eq!(hello, "world"); } assert_eq!(x, "world"); } }
目前程式碼
pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; } else { let prefix = *s; *s = ""; } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; { let hello = strtok(&mut x, ' '); } assert_eq!(x, "world"); } }

:-1: 測試仍然出現 error[E0502]。明明不應該有其他變數向 x borrow 值了 ! 也許你可能已經猜到答案了,就是 variance

Pretending to be the compiler

0:17:26

再修改一下測試,讓它變更簡易 :

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; // 編譯器推論前 - // strtok 函式推論傳入參數 : &'a mut &'a str // strtok 函式實際傳入參數 : & mut &'static str (x 是 &'static str) // 編譯器開始推論 - // 1. 實際傳入參數的 'static 先將推論傳入參數的 str 的生命週期推論成 'static : // &'a mut &'a str -> &'a mut &'static str // 2. 接著因為推論傳入參數的 str 的生命週期被推論成 'static, // 推論傳入參數的參考的生命週期也被推論成 'static : // &'a mut &'static str -> &'static mut &'static str // 3. 因為推論傳入參數的參考的生命週期被推論成 'static, // 實際傳入參數的參考的生命週期也跟著被推論成 'static // & mut &'static str -> &'static mut &'static str // 編譯器推論後 - // strtok 函式推論傳入參數 : &'static mut &'static str // strtok 函式實際傳入參數 : &'static mut &'static str // &mut x 只有在 x 的生命週期內有效, // x 本來會在離開 it_works 函式時被卸除, // 但現在 x 變成 static 變數,x 要活到程式結束之後才被卸除, // 導致 &mut x 也跟著 x 活到程式結束。 strtok(&mut x, ' '); assert_eq!(x, "world"); } // 但實際上 &mut x 一定會活到程式結束嗎 ? // 答案是不一定。 // 為什麼是不一定呢 ? // 讓我們繼續看下去編譯器還有什麼機制。 }

Shortening lifetimes

0:19:03

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; strtok(&mut x, ' '); // 由於現在的 &mut x 活到程式結束, // 所以 x 接下來不可以 immutablely 使用。 // 然而 Line 11 immutablely 使用 x,最終導致編譯失敗。 assert_eq!(x, "world"); } }

如果註解掉以下,就可以編譯成功了 :

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; strtok(&mut x, ' '); - assert_eq!(x, "world"); } }
目前程式碼
pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; } else { let prefix = *s; *s = ""; } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; strtok(&mut x, ' '); } }

這可能會讓你覺得很奇怪,&mut x 明明就是 static,我們的程式竟然還可以編譯成功。注意到,可以編譯成功並不是因為我們不再使用 x。真正可以編譯的是,雖然 "hello world" 是個 static 字串,但是編譯器假裝它不是 static,並將"hello world" 的生命週期縮短至 'x 的生命週期長度才編譯成功的 :

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; // 推論傳入參數 : &'x mut &'x str // 實際傳入參數 : &'x mut &'x str strtok(&mut x, ' '); } }

具體來說,編譯器知道的是,如果您有 static 參考,那麼你可以使用它來代替任何生命週期較短的參考 :

fn main() { let s = String::new(); let x: &'static str = "hello world"; // y 是字串參考,它沒有 static 的生命週期 let mut y = &*s; // 我們仍然可以將 x 的值指派給 y, // 因為 x 為 static,所以不會造成懸空指標的情形。 // 為什麼可以將值指派給不同生命週期的變數 ? // 因為 variance。 y = x; // 下函式簽章的例子是合法的 : // fn('a T) &'static T // 也是因為 variance,具體來說,這就是所謂 covariance。 }

但如果我們想的話,我們可以讓它在即使沒有 immutablely 使用 x 的情況下也編譯失敗 :

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { + fn check_is_static(_: &'static str) {} + let mut x = "hello world"; + check_is_static(x); strtok(&mut x, ' '); } }
目前程式碼
pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; } else { let prefix = *s; *s = ""; } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { fn check_is_static(_: &'static str) {} let mut x = "hello world"; check_is_static(x); strtok(&mut x, ' '); } } fn main() { let s = String::new(); let x: &'static str = "hello world"; let mut y = &*s; y = x; }

編譯得到預期的錯誤訊息 :

$ cargo test
...
error[E0597]: `x` does not live long enough
  --> src\lib.rs:24:16
   |
22 |         let mut x = "hello world";
   |             ----- binding `x` declared here
23 |         check_is_static(x);
24 |         strtok(&mut x, ' ');
   |         -------^^^^^^------
   |         |      |
   |         |      borrowed value does not live long enough
   |         argument requires that `x` is borrowed for `'static`
25 |     }
   |     - `x` dropped here while still borrowed
...

編譯失敗的原因是因為這次編譯器沒辦法假裝 x 的值的生命週期只有 'x,它的值現在必須也是 static,因為 check_is_static() 函式的傳入值是 static,所以編譯器就將 x 的值的生命週期推論成 static。

接著因為 x 為 static,就又回到剛剛的編譯器推論流程 :

#[cfg(test)] mod tests { use super::*; #[test] fn it_works() { fn check_is_static(_: &'static str) {} let mut x = "hello world"; check_is_static(x); // 編譯器推論前 - // strtok 函式推論傳入參數 : &'a mut &'a str // strtok 函式實際傳入參數 : & mut &'static str (x 是 &'static str) // 編譯器開始推論 - // 1. 實際傳入參數的 'static 先將推論傳入參數的 str 的生命週期推論成 'static : // &'a mut &'a str -> &'a mut &'static str // 2. 接著因為推論傳入參數的 str 的生命週期被推論成 'static, // 推論傳入參數的參考的生命週期也被推論成 'static : // &'a mut &'static str -> &'static mut &'static str // 3. 因為推論傳入參數的參考的生命週期被推論成 'static, // 實際傳入參數的參考的生命週期也跟著被推論成 'static // & mut &'static str -> &'static mut &'static str // 編譯器推論後 - // strtok 函式推論傳入參數 : &'static mut &'static str // strtok 函式實際傳入參數 : &'static mut &'static str // &mut x 只有在 x 的生命週期內有效, // x 本來會在離開 it_works 函式時被卸除, // 但現在 x 變成 static 變數,x 要活到程式結束之後才被卸除, // 導致 &mut x 也跟著 x 活到程式結束。 strtok(&mut x, ' '); } }

等等會回頭討論加 scope 為什麼仍會編譯失敗。

Subtypes

0:25:40

先討論為何 main() 函式可以編譯成功 :

fn main() { let s = String::new(); let x: &'static str = "hello world"; let mut y /* : &'a*/ = &*s; y = x; /* &'a <- &'static */ }

Line 6 是合法的是因為 static 是任何生命週期 'asubtype。Line 5 要注意到,之所以註解掉 &'a 是因為我們不能在這裡為生命週期取名字,因為它並不是泛型生命週期參數,而只是我們想像它的生命週期,並將之與 'static 做比較並說明 subtype 觀念。

subtype 比較非正式卻比較好記的定義是如果 T 至少與 U 一樣有用,則某些類型 T 是某些類型 U 的 subtype :

// T: U // T is at least as uesful as U // // 'static: 'a // 'static is at least as useful as 'a

事實上,你可以將其用於非生命週期的東西。不過目前在 Rust 中,variants 大多只影響生命週期。但你可以想像,在一種具有繼承性的語言中,就有實際的應用例子 :

// class Animal; // class Cat: Animal; (Cat is the subtype of Animal/ inherits from Animal) // Cat: Animal // Cat is at least as useful as Animal

Q : An alternate wording is "T can be used anywhere U can be used"
A : 這並非事實。"T 可以用在任何 U 可以用的地方" 跟 "T 至少與 U 一樣有用" 的定義不完全相同。等到講解 Contravariance 會講它們之間的差異。

Covariance

0:29:12

有三種型別的 variant : covariance, contravariance, inveriance

先介紹covariance (大部分 variant 都是這種) :

fn foo(&'a str) {} // 1. 您可以提供作任何類型作為參數傳遞, // 只要該類型是參數的 subtype 即可。 // 2. 參數型別是 covariant, // 函式本身並不是 covariant。 foo(&'a str) foo(&'static str) // 合法,因為 'static 是 'a 的 subtype。 // 以下這個例子並非 variant,而只是單純的泛型生命週期參數 !!! fn foo<'a>(&'a str) {} foo(&'a str) // 'a 被設為 'a foo(&'static str) // 'a 被設為 'static

Contravariance

0:33:15

接下來講解 contravariance :

fn foo(Fn(&'a str) ->()) // 等價於 let x: Fn(&'a str) -> () foo(fn(&'static str) {}) // 與 fn foo(&'a str) {} 相同語法, // 但現在這個例子應該編譯的過嗎 ?

繼續說明 :

// Case 1 (not ok) fn foo(Fn(&'a str) ->()) { bar("" /* 'a */) // 現在試圖呼叫生命週期若只有 'a 的傳入函式, // 它就無法被編譯,因為我傳入的函數要求類型是 'static。 // 這就是 contravariance。 // 我們傳入的 more useful 生命週期 ('static)。 // 而 foo 內的 bar 提供 less useful 生命週期 ('a)。 // 我們不可以向 foo 提供一個對其參數的要求比我們預期的更嚴格的函式。 // ('a 比 'static 更嚴格,因為 'a 生命週期更有限。) } foo(fn(&'static str) {}) // Case 2 (ok) fn foo(Fn(&'static str) ->()) { bar("hello world" /* 'static */) // 我們傳入的 more useful 生命週期 ('a)。 // 而 foo 內的 bar 提供 less useful 生命週期 ('static)。 // 我們可以向 foo 提供一個對其參數的要求比我們預期的更薄弱的函式。 } foo(fn(&'a str) {})

Covariance vs. Contravariance :

// Covariance : you need to provide more useful argument // 如果我期望得到一個短生命週期的字串, // 而你給我一個長生命週期的字串,我會很高興。 // &'a <- &'static ok // &'static <- &'a not ok // Contravariance : you need to provide less useful argument // 如果我期望使用一個要求短生命週期字串的函式, // 而你給我一個採用長生命週期字串的函式,那就不行了。 // 換句話說,當涉及函式參數時,covariant 要求被翻轉。 // 為什麼要這麼要求 ? // 我推測是因為當離開函式時要釋放掉區域變數。 // &'a <- &'static not ok // &'static <- &'a ok

只有在函式有 contravariant :
image

比較以下哪些是 more useful :

&'static str // more useful &'a str // less useful // 因為任何 'static str 滿足任何 'a str。 // 'static <: 'a ('static is subtype of 'a) // &'static T <: &'a T Fn(&'static str) // less useful Fn(&'a str) // more useful // 因為任何 'a str 可以接收任何生命週期的參數。 // 'static <: 'a ('static is subtype of 'a) // Fn(&'a T) <: Fn(&'static T) // 回顧剛剛的 Case 1 : // fn foo(Fn(&'a str) ->()) // foo(fn(&'static str) {}) // fn(&'static str) is not more useful than Fn(&'a str),so it's not ok. // 回顧剛剛的 Case 2 : // fn foo(Fn(&'static str) ->()) // foo(fn(&'a str) {}) // fn(&'a str) is more useful than Fn(&'static str), so it's ok.

:warning: 這裡 Jon 的對於函式 more useful 定義與前面有衝突,但他想表達的是,若函式要求傳入參數的生命週期為 'a,你不僅可以傳入短生命週期的參數,也可以傳入長生命週期的參數;若函式要求傳入參數的生命週期為 'static,你就只能傳入長生命週期的參數。

Q : the Fn(& 'static str) requires the argument to be "maximally usable" but most things aren't that useful
A : 很正確的評論。

Invariance

0:42:14

為什麼下圖圈起來的 T invariant ?
image

講解 :

fn foo(s: &mut &'a str, x: &'a str) { // 先忽略 &mut 的生命週期 *s = x; } let mut x: &'static str = "hello world"; let z = String::new(); // 假設 &mut 是 `covariant` (實際上是 invariant),就可以這樣呼叫函式。 // 因為我們傳入的 x 是 more useful 的型別, // 我們將生命週期 'static 降級成 'a。 foo(&mut x, &z); // 數字為流程順序 // 1. 函式內兩個參數都是 'a 的生命週期,因此"應該"要可以編譯。 drop(z); // 2. 函式呼叫完之後卸除 z,所以 &z 現在消失了。 // 3. 但這裡 x 的型別仍然是 &'static str, // 而我們讓它在函式內指向 &'a str。 println!("{}", x); // 4. 如果我們現在嘗試印出 x,誰知道會發生什麼? // x 儘管它應該是 &'static str (一開始就宣告 x 是 &'static str 型別), // 但它在操作的過程中指向已刪除的 stack local memory。(*s = x) // 顯然這是不行的,這不應該編譯。 // 不能編譯是因為 &mut 的參數型別是 invariant。 // 如果你有 invariant, // 你必須提供與指定內容完全相同的內容 : // 你不能提供 more useful 的生命週期。 // 你也不能提供 less useful 的生命週期。 // 因為我們剛剛並未提供與要求相同的生命週期物件, // 函式要求 : foo(&mut &'a str , &'a str) // 實際傳入 : foo(&mut &'static str, &'a str) // 所以實際上會編譯失敗 !

基本上,一種非正式的思考方式是,如果我們沒有 &mut 的 invariant,您可以將 &mut 降級為對 less useful 東西的參考,並指派 less useful 的東西給 &mut,但在外部 scope 中,&mut 仍然是 more useful 型別。因此現在你擁有了一個認為自己是 more useful 型別的東西,但實際上它是一個 less useful 型別。這是不可接受的,因為它缺少其型別所指示應該具有的一些屬性。

&'a mut T covariance in 'a

0:50:00

為什麼下圖圈起來的 'a 是 covariant ?
image
講解 :

// &mut 有 static 生命週期的例子 pub fn bar() { let mut y = true; let mut z /* &'y mut bool */ = &mut y; let x = Box::new(true); let x: &'static mut bool = Box::leak(x); // 忽略這行,只是想擺脫編譯器警告。 let _ = z; // 重點是這一行 z = x; // &'y mut bool <- &'static mut bool // 為什麼這行 ok ? // 因為 invariant 性質是在參考所指向的 T 身上, // 但參考的生命週期所擁有的性質則是 covariant。 // 而 x 又是 z 的 subtype ('static is at least as useful as 'a), // 所以這行是合法的。 // 但為什麼參考的生命週期即使不是 invariant 也不會造成問題 ? // 因為 T 有 invaraint 性質的關係, // 你在指派值的過程中不會意外地將 less useful 型別指派給 mutable 參考所指向的 T。 // 基本上,這意味著縮短 mutable borrow 的生命週期是可以的,這在直覺上是有道理的。 // 忽略這行,只是想擺脫編譯器警告。 drop(z); }

:bulb: Box::leak()
一種以安全的方式引入記憶體洩漏的方法。如果你從不卸除 Box,那麼 Box 裡的東西就會永遠存在於 stack 上,因為它永遠不會被卸除。因此,你可以獲得任何生命週期的 mutable 參考,尤其是 static 生命週期。

What went wrong in our strtok test?

0:57:57

回到 strtok 函式,先讓函式回到我們最初實作的版本 :

pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; prefix } else { let prefix = *s; *s = ""; prefix } }

結合剛剛學到的 covariant, invariant 性質來理解為何之前的其中一個測試為什麼編譯失敗 :

#[test] fn it_works() { fn check_is_static(_: &'static str) {} let mut x = "hello world"; check_is_static(x); // 編譯器推論前 - // strtok 函式推論傳入參數 : <'a> &'a mut &'a str // 用 'a 來表示,代表它是泛型。 // strtok 函式實際傳入參數 : &'x mut &'static str // 用 'x 來表示是 x 的 borrow 值。 // 編譯器開始推論 - // 1. 實際傳入參數的 'static 先將推論傳入參數的 str 的生命週期推論成 'static : // &'a mut &'a str -> &'a mut &'static str // 2. 接著因為推論傳入參數的 str 的生命週期被推論成 'static, // 推論傳入參數的參考的生命週期也被推論成 'static : // &'a mut &'static str -> &'static mut &'static str // ★ 3. 我們知道參考的生命週期有 covariant 的性質, // ★ 所以理論上參考的生命週期可以保持是 'x, // ★ 而又因為函式的簽章要求參考的生命週期跟 str 的生命週期一樣, // ★ 所以實際傳入參數的參考的生命週期跟 str 的生命週期都被推論成 'x : // ★ &'x mut &'static str -> &'x mut &'x str // ★ 但這個推論其實破壞了 str 的 invaraint 性質, // ★ 因為推論的過程中編譯器嘗試縮短 str 的生命週期。 // ★ 最終才會造成無法編譯。 // 如果 str 沒有 (實際上有) invaraint 性質,最終編譯器推論會變成 : // strtok 函式推論傳入參數 : <'a> &'a mut &'a str // strtok 函式實際傳入參數 : &'x mut &'x str // 所以 3. 的實際推論是,將實際傳入參數的參考變成 static (再次強調,參考具有 covariant 性質) : // &'x mut &'static str -> &'static mut &'static str // 編譯器推論後 - // strtok 函式推論傳入參數 : &'static mut &'static str // strtok 函式實際傳入參數 : &'static mut &'static str strtok(&mut x, ' '); // 編譯器推論完 &mut x 的生命週期是 'static, // 但實際上 x 是區域變數,所以 x 活的不夠久, // 最終編譯才出現了錯誤 : error[E0597]: `x` does not live long enough }
目前程式碼
pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; prefix } else { let prefix = *s; *s = ""; prefix } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { fn check_is_static(_: &'static str) {} let mut x = "hello world"; check_is_static(x); strtok(&mut x, ' '); } }

Fixing strtok

1:02:24

解決辦法是,多引入一個泛型生命週期參數,讓參考跟 str 有不同的生命週期 :

-pub fn strtok<'a>(s: &'a mut &'a str, delimiter: char) -> &'a str +pub fn strtok<'a, 'b>(s: &'a mut &'b str, delimiter: char) -> &'b str { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; prefix } else { let prefix = *s; *s = ""; prefix } }

多引入一個生命參數允許編譯器在呼叫函式時分別選擇參考與 str 的生命週期,而不是只選擇一個生命週期並同時套用到參考與 str 的生命週期上。這樣編譯器在推論的過程中就不會因為為了遵守 invarint 性質,而做出非我們預期的推論。

目前程式碼
pub fn strtok<'a, 'b>(s: &'a mut &'b str, delimiter: char) -> &'b str { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; prefix } else { let prefix = *s; *s = ""; prefix } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { fn check_is_static(_: &'static str) {} let mut x = "hello world"; check_is_static(x); strtok(&mut x, ' '); } }

順利通過測試 :

$ cargo test
...
running 1 test
test tests::it_works ... ok
...

再來看看最一開始的 case :

#[test] fn it_works() { let mut x = "hello world"; // 編譯器推論前 - // 推論傳入參數 : strtok<'a, 'b>(&'a mut &'b str) -> &'b str // 實際傳入參數 : strtok<'a, 'static>(&'a mut &'b str) -> &'b str // 編譯器開始推論 - // 略 // 編譯器推論後 - // 推論傳入參數 : strtok<'a, 'static>(&'a mut &'b str) -> &'static str // 實際傳入參數 : strtok<'a, 'static>(&'a mut &'b str) -> &'static str // 結論 : 使用到兩個生命週期讓編譯器完全不必使用 subtyping // 新增這行用來說明還是有用到 subtyping。 // 為何這行能夠編譯成功 ? // z 不是會在函式結束時才被卸除嗎 ? // 為什麼後面還可以 mutably borrow x (Line 23),它的值不是應該等 z 被卸除才歸還嗎 ? // 原因是 z 在 Line 23 之後就沒再被使用, // 再加上 mutable 參考是 covarint, // 所以編譯器可以縮短 z borrow 的生命週期, // 最終 z 的 borrow x 停在 Line 24。 let z = &mut x; // &'until-ZZZ mut <- &'x mut // until-ZZZ: borrow of x stops here // 所以這行才可以 borrow x 的值。 let hello = strtok(&mut x, ' '); assert_eq!(hello, "hello"); assert_eq!(x, "world"); }
目前程式碼
pub fn strtok<'a, 'b>(s: &'a mut &'b str, delimiter: char) -> &'b str { if let Some(i) = s.find(delimiter) { let prefix = &s[..i]; let suffix = &s[(i + delimiter.len_utf8())..]; *s = suffix; prefix } else { let prefix = *s; *s = ""; prefix } } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let mut x = "hello world"; let hello = strtok(&mut x, ' '); assert_eq!(hello, "hello"); assert_eq!(x, "world"); } }

順利通過測試 :

$ cargo test
...
running 1 test
test tests::it_works ... ok
...

Why is 'b: 'a not needed?

1:07:34

Q : yeah, why isn't 'b: 'a bound needed?

pub fn strtok<'a, 'b>(s: &'a mut &'b str, delimiter: char) -> &'b str -where - 'b: 'a

A : 因為編譯器總是假設泛型生命週期參數的生命週期長度至少為函式呼叫期間,這就是我們對 mutable 參考所需的全部內容。當在此函式中執行程式碼時,只假設這個 mutable 參考存在於函式的持續時間內,編譯器並不關心之後離開函式 mutable 參考發生了什麼事 (ex. 離開函式立即卸除值),因為離開函式所發生的事並不會使函式本體有任何的不正確。
'b 並不真的依賴於 'a。我們所做的只是讓回傳值與我們傳入的參數具有相同生命週期,這與我們修改的 mutable 參考存在多長時間無關。因此,在這個例子 'b'a 之間不必有關聯。

Shortening &'a mut and NLL

1:09:08

Q : would you be able to modify z after strtok? im guessing not

#[test] fn it_works() { let mut x = "hello world"; let z = &mut x; let hello = strtok(&mut x, ' '); assert_eq!(hello, "hello"); assert_eq!(x, "world"); + *z = "foo"; }

A : 不行,因為這次 z 的生命這次沒辦法縮短導致與 Line 7 的 mutable borrow 重疊了。

:question: 1:09:32
Q : Would your last example be still true for the previous edition? Something seems to be tied to non-linear lifetimes there

#[test] fn it_works() { let mut x = "hello world"; let z = &mut x; // 之前版本的編譯器無法縮短 z 的生命週期 let hello = strtok(&mut x, ' '); assert_eq!(hello, "hello"); assert_eq!(x, "world"); }

A : Jon 認為是對的。在 2015 年版本中,儘管 Rust 的 2015 年版本現在已啟用 NLL,但 Jon 相信在沒有 NLL 之前,這個程式碼將無法編譯,因為它無法知道縮短這個 borrow。

Is 'b: 'a implied for &'a &'b?

1:10:11

Q : after the explanation, thinking it's a general rule that "&'a &'b " must have 'b: 'a, otherwise the reference would be invalid

pub fn strtok<'a, 'b>(s: &'a mut &'b str, delimiter: char) -> &'b str +where + 'b: 'a

A : Jon 認為並不為編譯器會自動加上 'b: 'a 的 bound。
你能擁有一個 T 生命週期長於 mutable 參考生命週期的變數 :

let mut x = "hello world"; // ok

但你不能擁有一個 T 生命週期短於 mutable 參考生命週期的變數 :

// &'a &'b T where 'a is longer than 'b // 你一開始就無法建立這種變數。 // Jon 認為實際上編譯器對此沒問題,因為參考的生命週期是 covariant。 // 因此,編譯器將縮短 'a、'b 的生命週期,以較短者為準。 // Jon 不認為編譯器實際上 implicitly 添加了一個 bound , // 即 'b 必須比 'a 長,或者 'b 必須是 'a 的 subtype。 // Jon 認為這只是如果你試圖建立這種變數, // 你會得到一個編譯器錯誤。

Variance, PhantomData, and drop check

1:12:54

你可能看過以下的結構簽章 :

use std::marker::PhantomData; struct Deserializer<T> { // some fields // t: T, 實際欄位沒這個 T,因為我們還沒 deserialize _t: PhantomData<T>, } struct Deserializer2<T> { // some fields _t: PhantomData<fn() -> T>, } struct Deserializer3<T> { // some fields _t: PhantomData<fn(T)>, }

使用 PhantomData 的情境是你可能有一個對 T 泛型,但不直接擁有 T。如果你用 FFI 做某事,這種情況經常會出現,以 deserialize 為例,您希望一個 deserializer 對將要 deserialize 的型別進行泛型化,但 deserializer 並沒有真正擁有 T,你只是希望 deserializer 知道要產生哪些型別。

編譯器不允許你這樣做 (error[E0392]: parameter T is never used):

struct Deserializer<T> { // some fields // _t: PhantomData<T>, }

所以我們才需要加一個 PhantomData。在 Rust 中,PhantomData 是唯一一種對型別參數進行泛型化但不擁有該型別參數的型別。

剛剛三種結構的使用時機有什麼區別 ?
這三種跟 drop check 機制有關,當一個結構被卸除時,Rust 需要知道 T 是否也會被卸除。一般來說當結構被卸除時,T 也跟著被卸除,舉例來說,如果你卸除了 vector,你也會卸除 T。


接下來的說明不太涉及到 varaint 的具體內容,但會解釋 drop check 是如何跟 varaint 相關的。

先看到以下編譯失敗的例子 :

fn main() { let x = String::new(); let z = vec![&x]; drop(x); // 不 ok,因為還有變數在參考 x 的值。 drop(z); }

如果移除了 Line 6 會發生什麼事 ? 這樣就可以編譯成功,因為 z 在 Line 5 之後就沒有被使用了。那麼編譯器是怎麼判斷這樣是可以的 ?

fn main() { let x = String::new(); let z = vec![&x]; drop(x); - drop(z); }

以下的例子編譯器又怎麼判斷這樣是不可以的 :

struct TouchDrop<T: std::fmt::Debug>(T); impl<T: std::fmt::Debug> Drop for TouchDrop<T> { fn drop(&mut self) { // 印出所有將要卸除的值 println!("{:?}", self.0); // Q : Stupid question probably: what is self.0? // A : TouchDrop 是有一個元素的 tuple 結構。 } } fn main() { let x = String::new(); let z = vec![TouchDrop(&x)]; drop(x); // drop(z) // implicit 卸除 `z` 時會呼叫 TouchDrop::drop(), // TouchDrop::drop() 在 x 已經被卸除的情況下想把 x 印出來 // 最終造成了編譯錯誤。 // 編譯器是怎麼判斷出以上情形是不 ok 的 ? }

用編譯成功的程式碼來解釋編譯器判斷的方法 :

fn main() { let x = String::new(); let z = vec![&x]; // 當我們卸除 vector 時, // 卸除參考並不會在卸除時存取內部型別。 // 編譯器必須以某種方式知道這一點。 // 答案仍然是 drop check 機制。 // // 以下解說 Jon 有點說謊。 // 這裡有一個 unstable nightly feature : // 編譯器需要知道 vector 是否卸除其泛型參數。 // Vector 對內部型別進行泛型化, // 而 vector 只有在它們自己實作 drop 的情況下才會卸除內部型別。 // 編譯器知道這一點的方式是查看泛型參數,並查看該型別是否擁有 T。 drop(x); }

回到剛剛三種結構簽章 :

use std::marker::PhantomData; struct Deserializer<T> { // some fields _t: PhantomData<T>, // PhantomData<T> 告訴編譯器該結構擁有 T, // 所以你可以卸除 T。 } struct Deserializer2<T> { // some fields _t: PhantomData<fn() -> T>, // PhantomData<fn() -> T> 告訴編譯器該結構不擁有 T, // 所以你不可以卸除 T,你只是卸除了一個函式定義。 // 如果你知道你的結構不會刪除 T, // 則你會選擇這種而不是第一種。 // 1:21:03 // 我們希望允許類似 TouchDrop 的東西, // let x = String::new(); // let z = TouchDrop(&x) // let y = Deserializer2::<z>; // 因為我們知道我們不會在內部型別上呼叫 drop。 // 如果有一個 TouchDrop 的 deserializer, // 那麼當 TouchDrop 內部的參考無效時, // 可以安全地卸除 deserializer, // 因為在卸除 deserializer 時, // 不會卸除任何 TouchDrop。 // 為何你會選這種而不選 Deserializer3 ? // 因為 Deserializer2 是 covariant in T, // Deserializer3 則是 contravariant in T。 // Deserializer3 使用起來很惱人,因為它不能夠縮短生命週期, // Deserializer2 的話就可以縮短生命週期。 _t2: PhantomData<*const T>, // 也可以用這種讓欄位是 covariant in T。 } struct Deserializer3<T> { // some fields _t: PhantomData<fn(T)>, }

:question: 1:23:15
Q : Wouldn't the compiler know that the only T inside a Deserializer is a PhantomData, which doesn't actually hold a T?
A : 編譯器可以,但實際上最好假設編譯器會卸除 T,看以下例子 :

use std::marker::PhantomData; struct Deserializer<T> { // some fields c_void: *const (), // 想像這個指標實際上是一個指向在 C 世界中分配的指向 T 的指標。 // 因此,你實際上確實有一個呼叫 c_free(self.c_void) 的 drop。 // 所以當這種型別被卸除時,你實際上會卸除一個 T。 // 所以你會希望 PhantomData<T> 傳達你擁有 T 並且可能會卸除的語義。 // 這就是為什麼 PhantomData<T> 讓編譯器認為該結構擁有 T, // 即使實際上該結構並沒`真正`擁有 T。 _t: PhantomData<T>, } drop() { c_free(self.c_void); }

假設你希望你的結構對於 T 是 invariant。在某些情況下,你可能會有一些 interior mutability,因此你需要 invariant,以防止駭客使用相同的手段來修改 mutable 參考,你可以用以下方法:

  • 法一
    ​​​​use std::marker::PhantomData; ​​​​struct Deserializer4<T> ​​​​{ ​​​​ // some fields ​​​​ // 第四種是 invariant in T。 ​​​​ // 因為它在 T 中必須同時是 covariant 和 contravariant, ​​​​ // 而且沒有任何型別可以同時擁有這兩種性質, ​​​​ // 型別就只有 covariant/ contravariant/ invariant 三種, ​​​​ // 因此編譯器得出結論 Deserializer4 必須是 invariant in T。 ​​​​ _t1: PhantomData<fn(T)>, ​​​​ _t2: PhantomData<fn(T) -> T>, ​​​​}
  • 法二
    ​​​​use std::marker::PhantomData; ​​​​struct DeserializerBad<'a, T> ​​​​{ ​​​​ // some fields ​​​​ _t1: PhantomData<&'a mut T>, ​​​​ // 這種方法是的問題是需要一個生命週期參數, ​​​​ // 所以你必須為你的結構添加一個生命週期, ​​​​ // 即使是 PhantomData 中你也必須加生命週期參數。 ​​​​}
  • 法三
    ​​​​use std::marker::PhantomData; ​​​​struct DeserializerGood<T> ​​​​{ ​​​​ // some fields ​​​​ _t1: PhantomData<*mut T>, ​​​​ // 這種就不需要生命週期參數了。 ​​​​}
  • 法四
    ​​​​use std::marker::PhantomData; ​​​​struct DeserializerGood<T> ​​​​{ ​​​​ // some fields ​​​​ _t1: PhantomData<fn(T) -> T>, ​​​​ // fn(T) 是 contravariant in T, ​​​​ // T 是 covariant in T。 ​​​​ // 最後編譯器推論出是 invaraint in T。 ​​​​}

型別的 varaint 種類都可以在表格中找到 :
image

  • 表格中看到了 UnsafeCell<T> 是 invariant,因為它可以讓你改變 T 的值。如果 T 不是 invariant 將有可能造成懸空指標的情形。
  • fn() -> T*const T 是等價的,但是前者通常更好,因為一旦引入 *const T (raw pointer),有一些事情你無法得到。比如,*const T 是不會自動實作 Send 和 Sync。因此如果你的結構包含一個 PhantomData<*const T>,你的結構將不會是 Send 和 Sync。通常情況下,只要所有成員都是 Send 和 Sync 就會自動實作在你的類型中,但如果是 *const T,它就不會自動為你的型別實作 Send 和 Sync。這可能是人們更喜歡 fn() -> T 的原因。

Reasons for changing variance

1:28:06

:question: 1:28:06
Variance 和 subtyping 是相對罕見需要處理的概念。通常,它在兩個情境中出現。

第一種情境 : 也許你的型別預設情況下成為 invariant,但你知道它是 covaraint,因此你可以透過使其 covariant 來改進型別的 ergonomics,在這種情況下,你必須仔細思考它是否真的是 covaraint。因為如果編譯器自動得出你的型別在 T 上是 invaraint 的結論,它就像是對 T 泛型且編譯器得出它在 T 上是 invaraint 的結論,那麼如果你試圖略為改動一些東西使其成為 covaraint。

如果你願意的話,你必須確保你不會受到這種 invaraint attack 的影響,就是意外地削弱,這可能會很困難 :

// invaraince fn foo(s: &mut &'a str, x: &'a str) { *s = x; } let mut x: &'static str = "hello world"; let z = String::new(); foo(&mut x, &z); drop(z); println!("{}", x);

第二種情境 : 在 unsafe 的程式碼中,你的程式碼的安全性可能依賴於泛型參數的 invaraint。但是編譯器得出你的型別是 covaraint 的結論 :

struct Foo<T> { _t1: *const, // 編譯器得出這種型別在 T 上是 covaraint 的結論。 // 假設你在 `Foo` 內部的某個深層程式碼中以某種原因允許進行 mutate 操作, // (在這種情況下實際上是不可能發生的, // 因為你不被允許透過 `*const` 進行 mutate,但這是另一個討論。) // 你的 unsafe 程式碼最終以某種原因 mutate T, // 那麼現在你必須確保你的型別在 T 上是 invaraint, // 而不是編譯器所假定的 covaraint // 一般來說,這不應該發生, // (補充 : invaraint 只對變異操作很重要) // 你只能透過 `*mut T`、`&mut T` 或 `UnsafeCell<T>` 來 mutate T _t2: &mut T, _t3: *mut T, }

一般來說,這不應該發生,你只能透過以下三種欄位的型別來 mutate T :

struct Foo<T> { _t1: *mut T, _t2: &mut T, _t2: UnsafeCell<T>, // 以上三種皆為 invaraint。 // // 你必須非常努力才能獲得實際上是安全的 (即非 UB), // 但同時編譯器得到是 covariant 的結論是錯誤的。 }

:pencil: invaraint 只對 mutate 操作很重要

:::

for{'a} and variance

1:30:47

:question: 1:30:47
Q : do for<'a> changes any variance or it doesn't have any relationship?

fn foo<T>(_: T) where for<'a> &'a T: std::fnt::Debug, { }

A : 這是一種特殊的語法,如果你想表達對於任何生命週期,都應該滿足這個 bound。Jon 認為你只能在 bound 中使用它,這對varaince 沒有影響。這只是在說 &'a T 必須對於任何生命周期 'a 都是成立的。因此,這裡沒有涉及 invariant 或 subtyping。

Mutating through *const T

1:31:51

:question: 1:31:51
Q : you can mutate through *const T if you cast it without UB
A : 分析兩種轉換型別路徑 :

struct Foo<T> { // 不 ok,是 UB // 一般來說,*cons T 來自於 immutable 參考。 // immutable 透過轉換最後變成 mutable 的行為是 UB。 // 因為你不被允許修改共享參考,最後卻透過轉換而破壞規則。 &T -> *const T -> *mut T -> &mut T // ok,不是 UB &mut T -> *mut T -> *const T -> *mut T -> &mut T // | | // covariant,因為你儲存 T // invaraint,因為你存取 T }

NonNull{T}

1:33:29

:question: 1:33:29
Q : Types such as Vec<T> or Box<T> generally contain a *const T even though you can mutate through it, specifically because they should be covariant
A : Jon 認為 Vec<T>Box<T> 包含 unique T,而不是 *const T。並且 unique 包含 NonNull<T>
NonNull : *mut T but non-zero and covariant. If your type cannot safely be covariant, you must ensure it contains some additional field to provide invariance. Often this field will be a PhantomData type like PhantomData<Cell<T>> or PhantomData<&'a mut T>.

NonNull 的建構式簽章如下 :

pub fn new(ptr: *mut T) -> Option<NonNull<T>> // NonNull 建構式避免你走以下路徑產生 UB : // T -> *const T -> NonNull<T> -> *mut T -> &mut T // 你只能走以下的轉換路徑 : // &mut T -> *mut T -> *const T -> -> NonNull<T> -> *mut T -> &mut T

How we got here

1:35:26

// ok,最需要掌握每個生命週期參數的方法。 pub fn strtok<'a, 'b>(s: &'a mut &'b str, delimiter: char) -> &'b str // ok,但 Jon 不喜歡 implicit。 pub fn strtok<'s>(s: &mut &'s str, delimiter: char) -> &'s str // 編譯器會自動為每個參考產生一個獨特的生命週期 // 等價於 pub fn strtok<'s, 'b>(s: &'b mut &'s str, delimiter: char) -> &'s str // ok,Jon 最喜歡這種 explicit, // 編譯器幫忙推論參考的生命週期。 pub fn strtok<'s>(s: &'_ mut &'s str, delimiter: char) -> &'s str

:bulb: 建議常常開啟 warning

#![warn(rust_2018_idoms)]

待整理

  1. 1:09:32
  2. 1:21:03
  3. Reasons for changing variance 整個小節
  4. 1:30:47
  5. 1:31:51
  6. 1:33:29