Aeria Leeve Says

懂動詞就懂 Rust

抱歉,我還是初學者,如果概念寫錯了,麻煩請直接指正,不要讓我誤導其他人,謝謝。另見文章結尾有 changelog。

以下正文開始。

今天忽然 (疑似?) 真正了解 Rust ownership system,以下心得分享文件全部看完的初學者。

首先,ownership 是為了解決 free memory 造成的問題 (free 在這裡是動詞 release 的意思),例如忘記 free(memory leak) 或 free 多次 (double free error) 或在同一段時間有讀有寫或寫入多次 (critical section 裡發生的 data race, 一種 race condition),感謝羅偉航補充,Rust 只能解決 data race,無法解決 data race 外的 race condition,詳見:https://www.facebook.com/groups/rust.tw/posts/5098448953523209/?comment_id=5098815700153201&__cft__[0]=AZWcqqN2MHhcItcRG0majKAA_shyqJ-EKuGyec2VVVZchv3Z3ik9xeVokCGRi10lSOUbUYo86WT6Ktf_zI6sILkWQxHiiPlyC97G7af1z7voSYv--tQyzoqdTg_D_co4ye0ZWIW2RYVQqdrdGpUzNW_l9lo3H_CUZwFVoCZi5HW6HetcomkS-IRWCEdTKE_8e7A&__tn__=R]-R

什麼情況下會有 free memory 的問題呢?只要有用到 heap memory 就會有 free memory 的問題,像是可變長度的東西,例如 String 或 Vector。

什麼時候會用到 heap memory 呢?只要 compile time 無法知道 size 就需要用到 heap memory,例如 Figure 15-1 提到的 Cons List。https://doc.rust-lang.org/book/ch15-01-box.html...

什麼時候不會用到 heap memory 呢?只要 compile time 可以知道 size 就不需要用到 heap memory,這時候用的是 stack memory,例如 u32,列表詳見 https://doc.rust-lang.org/.../ch04-01-what-is-ownership...

stack memory 會有 free memory 的問題嗎?不會,Rust 會負責清理。

小結:目前我們知道東西要嘛就存在 stack memory 要嘛就存在 heap memory

那要怎麼解決 free memory 的問題呢?那就是 ownership,不管東西放 stack memory 或 heap memory,都有 owner。隨著 owner 離開 scope,owner 持有的資料也會跟著被 free 掉,可以確保沒有 memory leak。

Rust 怎麼知道隨著 owner 離開 scope,要清掉那些資料?這就要靠實作在 Drop trait 的 drop method 定義了,所以清變數的時候,會 call drop。

重點來了,當 ownership system 碰上 assignment 的時候就會產生 copy 或 move。要注意的是,只要有實作 Drop 就無法實作 Copy,compile 不會過。

什麼時候是 copy 呢?有實作 Copy trait 的都是 copy,例如:

let src = 1;
let dest = src; // copy src to dest

什麼時候是 move 呢?只要沒有實作 Copy 就是 move,例如:

let src = String::from("src");
let dest = src; // move src into dest

那麼資料放在 stack memory 或 heap memory 跟 move 或 copy 有什麼關係呢?答案是,只要可以實作 Copy 就幾乎一定是放在 stack memory,因為 Rust 會檢查 struct 或 enum 裡面的資料是否全部有實作 Copy,例如以下第一段程式碼成功實作了 Copy,第二段程式碼則因為 s 屬於 String,而 String 未實作 Copy,導致整個 S 無法實作 Copy,而 Rust 內建實作 Copy 的都是一些 compile time 可知道大小的資料,前面說幾乎,是因為這部分我還沒調查清楚。

#[derive(Debug)]
struct S {}
impl Copy for S {}
impl Clone for S {
    fn clone(&self) -> S {
        S {}
    }
}
let s = S {};
let t = s;
println!("{:#?} {:#?}", s, t);
#[derive(Debug)]
struct S {
    s: String,
}
impl Copy for S {}
impl Clone for S {
    fn clone(&self) -> S {
        S {}
    }
}
let s = S {
    s: String::from("s"),
};
let t = s;
println!("{:#?} {:#?}", s, t);

那麼資料放在 stack memory 或 heap memory 跟是否實作 Drop strait 有什麼關係呢?答案是只要實作 Drop trait 就幾乎是放在 heap memory,這裡說幾乎,是因為大部分 Rust 內建實作 Drop trait 的 struct/enum 都是是放在 heap memory 上面,另外一點是,資料放在 heap 上幾乎就等同於一定要實作 Drop trait,否則會 memory leak 的問題,例如 Box,但是我們卻可以有一個可以實作 Copy trait 的資料,但我們不實作 Copy trait 而實作 Drop trait,例如:

#[derive(Debug)]
struct S {
    s: String,
}
impl Drop for S {
    fn drop(&mut self) {}
}
let s = S {
    s: String::from("s"),
};
let t = s;
println!("{:#?} {:#?}", s, t);

這裡補充一點,我們在 Rust 裡面常常幫 struct 或 enum 實作 new 於 Drop trait,其實這個古老的 pattern 叫做 RAII。

copy 是 shallow copy 還是 deep copy?copy 就是 copy,在這裡沒有分 shallow copy 還是 deep copy,但在這裡是完整的 copy,因為 stack memory 上的東西都是 compile time 就已經知道大小的東西,例如 u32,就算完整 copy 也就 4 bytes 大。

move 是 shallow copy 還是 deep copy?答案是 shallow copy,以 string 來說,只要 copy stack 上的 string metadata 就夠了,這裡有個重點 move 後 owner 也會跟著改變,也就是說舊的變數就不能用了,以確保同時間只有一個 owner,如此可以確保只會 free 一次。詳見:https://doc.rust-lang.org/.../ch04-01-what-is-ownership...

怎麼 deep copy 呢?答案是用 .clone(),例如:

let src = String::from("a");
let dest = src.clone(); // move deep-copied src into dest

放 stack memory 的東西可以 clone 嗎?可以,不過就如同前面寫的,寫不寫 .clone() 是一樣,例如:

let src = 1;
let dest = src.clone();

放 stack memory 上的東西可以 move 不要 clone 嗎?可以,詳見前文。

要怎麼把 compile time 可知道大小的資料放到 heap memory 裡面?塞進去 smart pointer 裡面就會放到 heap memory 裡面,例如 Box, Rc 或 RefCell 都可以:

let src = Box::new(1);
let dest = src;

東西 move 之後換 owner 了,舊變數就不能用了怎麼辦?也不是不行,再 move 回去就好了,例如:

let src = Box::new(1);
let dest = src;
let src = dest;

這樣 move 來 move 去很麻煩,可以簡單一點嗎?有,那就是用 borrow,之前提到 owner 是為了解決 free memory 的問題,那麼只要有一種寫法可以告訴 Rust 不要 free 就不需要 move 了,那就是 borrow。

怎麼 borrow?borrow 有好多種做法,最簡單的就是用 reference 更進階的就是用 RefCell,例如:

let src = Box::new(1);
let dest = &src;

不管是在 stack memory 還是在 heap memory,都可以 reference,不過這裡要注意喔,reference 本身是一個 pointer,所以一個改了,另一個也會跟著改,例如:

let mut src = 1;
{
    let dest = &mut src;
    *dest += 1;
}
println!("{}", src);

let mut src = String::from("src");
{
    let dest = &mut src;
    dest.push_str("dest");
}
println!("{}", src);

這裡要特別在 scope 裡面 borrow src 是因為 critical section 裡面不能多次寫入,也就是 mut 跟 &mut,雖然我們沒有這樣做,但是 Rust compiler 沒辦法判斷我們沒有寫,只能靠有幾個 mut pointer 來判斷。感謝羅偉航補充,這裏有一個叫 NLL ()Non-Lexical Lifetime) 的 RFC 在改善 Rust 在這方面的寫法,詳見:https://www.facebook.com/groups/rust.tw/posts/5098448953523209/?comment_id=5098815700153201&__cft__[0]=AZWcqqN2MHhcItcRG0majKAA_shyqJ-EKuGyec2VVVZchv3Z3ik9xeVokCGRi10lSOUbUYo86WT6Ktf_zI6sILkWQxHiiPlyC97G7af1z7voSYv--tQyzoqdTg_D_co4ye0ZWIW2RYVQqdrdGpUzNW_l9lo3H_CUZwFVoCZi5HW6HetcomkS-IRWCEdTKE_8e7A&__tn__=R]-R

Rust 真的同時只能有一個 owner 嗎?再強調一次,ownership system 是為了解決 free memory memory 的問題,所以理論上如果 Rust 自己能解決 free memory 的問題,就可以有好多個 owner,實際上也可以用 Rc (reference counted) 解決 double free 的問題,例如:

use std::rc::Rc;
let src = Rc::new(Box::new(1));
let dest = Rc::clone(&src);
println!("{} {}", src, dest);

有這麼好用的 Rc,為什麼還要限制單一 owner?因為 Rc 只保證解決 double free 的問題,不保證解決 memory leak,例如 circular reference,而這類 Rc 造成的 memory leak 就只能靠人工察覺了,詳見:https://doc.rust-lang.org/.../ch15-06-reference-cycles.html

發現有發現 circular reference 該怎麼辦?這時候請仔細思考,reference 兩者之間的關係是不是 ownership,ownership 的意義在於,隨著 scope 結束,清除記憶體,如果不是 ownership 自然就不需要增加 reference count,在這裡指的是 strong count,因為 circular reference 的成因在於,隨著 scope 結束,要清除記憶體時發現 strong count 大於 0,於是無法清除。如果我們發現這兩者並不互為 owner,就可以讓其中一種關係不要增加 strong count,在 Rust 裡面這種關係叫 weak reference,增加的是 weak count,只要用 .downgrade() 而不是用 .clone 就會增加 weak count 而不是 strong count,因為 weak reference 指向的東西不保證存在,所以在取得資料的時候 (.upgrade) 要記得處理的是 Option> 喔。詳見:https://doc.rust-lang.org/.../ch15-06-reference-cycles...

Rc 包起來的資料不給改怎麼辦?像這樣的程式碼,Rust 是不給改的:

use std::rc::Rc;
let src = Rc::new(String::from("src"));
let dest = Rc::clone(&src);
dest.push_str("dest");

這時候只要再包一層 RefCell 就可以了,例如

use std::cell::RefCell;
use std::rc::Rc;
let src = Rc::new(RefCell::new(String::from("src")));
let dest = Rc::clone(&src);
dest.borrow_mut().push_str("dest");

只要是想改不能改的地方就可以用到 RefCell,RefCell 這麼好用,為什麼不用 RefCell 就好了?不過這裡要注意,RefCell 之所以給改,是因為 RefCell 利用 unsafe 把 compile time 的 borrow check 延後到到 runtime,所以本來 compile time 會抓到的 bug 都會延遲到 runtime 發生,而且因為 runtime 多做 borrow check,所以會影響 runtime 的效能。

接下來特別提一下 RefCell 搭配 deference operator,在 Rust 中 lvalue 並沒有一定要是 identifier,這第一次看到的時候覺得滿怪的,例如:

fn main() {
    use std::cell::RefCell;
    let src = RefCell::new(1);
    *src.borrow_mut() += 1;
    println!("{:#?}", src);
}

在結束之前離題討論一下 stack memory 於 heap memory,東西放在 stack memory 會比放在 heap memory 還要來得快,那麼 Rust 能不能像 C 語言一樣在 stack 生一大塊記憶體 (int a[32];) 出來呢?答案就讓大家自己去找,因為我不太確定我 Google 的答案是否正確。

感謝 Kenneth Lo 補充,heap memory 是慢在 allocate memory 跟 free memory,詳見:https://www.facebook.com/groups/rust.tw/posts/5098448953523209/?comment_id=5098813733486731&__cft__[0]=AZWcqqN2MHhcItcRG0majKAA_shyqJ-EKuGyec2VVVZchv3Z3ik9xeVokCGRi10lSOUbUYo86WT6Ktf_zI6sILkWQxHiiPlyC97G7af1z7voSYv--tQyzoqdTg_D_co4ye0ZWIW2RYVQqdrdGpUzNW_l9lo3H_CUZwFVoCZi5HW6HetcomkS-IRWCEdTKE_8e7A&__tn__=R]-R 與 https://doc.rust-lang.org/.../ch04-01-what-is-ownership.html

再離題一下,存取鄰近的記憶體跟隨即存取記憶體速度大約差了十倍,Sequential Memory R/W (64 bytes) 跟 Random Memory R/W (64 bytes) 的資料詳見:https://github.com/sirupsen/napkin-math#numbers

最後一個離題是,究竟 str (string slice) 是存在 stack memory 還是 heap memory,答案也留給大家去尋找。

今天介紹了 Rust 最重要的幾個動詞,copy、move、borrow、clone、drop,掌握之後,對瞭解 Rust 會很有幫助。

給初學者的建議是,看到 assignment 時,不念 assign,念 copy 或 move 或 borrow 可以練習判斷究竟是否影響到 ownership,對掌握觀念很有幫助。


ChangeLog 2021-01-29