rust學習五、認識所有權

正在战斗中發表於2024-11-01

在<<The rust programming language>>的中譯版<<rust權威指南>>中,作者用了30頁的篇幅來闡述這個問題。

如作者所言,所有權是學習rust語言的基礎,不掌握這個,無需繼續往下,所以,這是初學rust就必須會的。

正是所有權概念和相關工具的引入,Rust才能夠在沒有垃圾回收機制的前提下保障記憶體安全。

一、變數的儲存方式和賦值方式

要進入rust所有權範圍討論問題,那麼必須先理解RUST的變數的儲存方式賦值方式

rust出於各種目的,規定變數可以存放在棧和堆上:

  1. 棧-存放哪些編譯時期就知道大小的。通常儲存那些簡單的資料型別,例如整數、浮點、布林、字元、成員型別都是整數、浮點、布林、字元之一的元組
    • 注意這是一個FILO(先進後出,或者是後進先出)型別的,好似堆碟子,反而最上面的最先用。
  2. 堆-存放那些編譯時期無法知道其大小的。例如String(字串)

棧變數轉賦

對於棧變數而言,把a賦值給b,僅僅是一種在棧中複製資料的過程-快速且成本小。

let a=10;
let b=a;

上面這個程式碼中,就是把10複製一份給b。

堆變數轉賦

但是堆不同,這個就是所有權的問題的來源,見後文。

堆變數和java的類變數是相識的儲存方式,都是用一個地址指向實際的儲存區域,如下圖(來自書本):

所有權問題就是堆變數問題。

二、所有權規則是如何產生的?所有權規則是什麼?

2.1、規則怎麼來?

要理解所有權,就需要從rust的設計目的談起,否則難於理解有些概念。

按照rust官方的說法,rust要保證記憶體安全(一定會釋放掉),同時還需要保證高效(不能用java 那樣的gc管理器),同時還不能太繁瑣(像C++那樣手動釋放)

所以,他們想到了一個主意:一個變數應該用完就自動釋放(通常是立刻釋放)

透過設這種設定,那麼就解決了一些問題:

  • 不會記憶體洩漏
  • 不需要藉助額外的垃圾收集器,增大了用於系統程式設計的可行性

但這會引發一個問題,怎麼知道需要被移除的變數沒人用了?

像java那樣使用引用計數器之類的?但是現在不搞那一套,就需要定個規矩:一個變數一定要有所有者;而且任意時刻只能有一個所有者

和前面提到的結合起來,就是rust著名的所有權規則:

  1. 一個值(不是變數)一定要有所有者
  2. 而且任意時刻,一個值只能有一個所有者
  3. 所有者離開作用域後,持有的值會被立刻釋放

因為任意時候一個值只有一個所有者,所以一旦所有者離開返回,就可以簡單“高效”地執行釋放操作,不用擔心還被誰用了。

規則就是為了達到這個目的。

這個規則有一個瑕疵:按照rust官方的說法,有個drop程式會立刻做這個事情,那麼這某種程度上會降低效能。

但是現代高階語言都沒有解決這個問題,哪怕C++之類的,所以這個就不算是一個問題了。大家都裝糊塗吧,也許某天cpu和作業系統變化了之後,可以解決這個問題。

所以,這應該是一個當前情況下,權衡後的可接受方案。

2.2、規則怎麼得到保證?

規則定了,那麼如何保證這些規則可以得到遵守?

1.明確作用域的

如果是有明確作用域的,例如{}或者函式,那麼很好理解:

fn test(){
  let s:String=String::from("種瓜得豆");
  println!("{}",s);
}

絕大部分語言都是這麼規定,所以能夠立刻理解!離開函式或者明確的作用域,就應該要釋放掉。

2.在一個作用域之內

參考書中的經典例子:

let s1=String::from("錦繡中華");
let s2=s1;
println!("{}",s1); 

通不過編譯!

let s1=String::from("錦繡中華");
	   |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
	12 |     let s2=s1;
	   |            -- value moved here
	13 |     println!("{}",s1);
	   |                   ^^ value borrowed here after move

第一次接觸這個,我有一點震驚,是因為兩個沒有想到:

1.怎麼會不行?

2.編譯器就能夠解決這個問題,而不是在執行時發生。編譯器夠厲害的。

現在再回過頭看下這個編譯錯誤提示,提示大意是這樣的:

由於s1是String型別,所以發生的移動情況。String並沒有實現Copy trait(型別特性)(意思就是不能透過複製完成值的轉賦)

當指定s2=s1的時候,s1的值已經轉給了s2(s1已經沒有值了)

當再次使用s1的時候,這就是一種典型的錯誤行為:值被移動後企圖借用

透過這個提示我們可以看到:在一個作用域之內,如果一個變數轉賦給另外一個變數的時候,會發生一件事情值會從一個變數轉到另外一個變數上

為什麼要這麼做? 因為只有這樣才能保證前面提到的第二個原則:任意時刻只能有一個變數擁有某個值

回頭再看編譯提示,我們還知道一點:類似s1這樣的變數只所以會發生這個值轉移情況,是因為s1是String,預設沒有實現Copy型別特性。

反過來說,如果String實現了Copy型別特性,那麼就不會發生這種編譯錯誤。

2.3、規則帶來的其它問題

雖然三個規則保證了目的,但是會帶來不少問題(麻煩),比較典型的問題就是:

一個變數的值被移走後,那麼再次使用原來的變數就變得麻煩了。 因為需要再次移動值。如果這樣寫程式碼,太冗餘囉嗦,誰也受不了!

以下是一個奇怪的例子。

fn main(){
    let mut  address:String=String::from("福建福州");
    println!("{}",address);
    print_str(address);
    //再用address,那麼久會報錯,如果要不報錯,則必須
    //插入諸如 address=xxx之類語句,把值移回來,或者重新賦值
    println!("新地址:{}",address) ;  //報錯 
}

fn  print_str(s:String){
    println!("{}",s);
}

在上例中,如果為了避免println!("新地址:{}",address)報錯,必須需要在這句話之前做一些事情。這樣無疑太麻煩了。

如果按照上面這種方法,那麼rust就沒有存在的意義,因為一門語言不但要考慮效能、安全等,也需要考慮工程效果:不能讓工程師煩

為了解決這個問題,rust給出了三個解決方案

  1. 不可變引用-透過某種方式標記這是一種借用,值所有權沒有轉移。借用完成後會自動歸還。當前借用不能修改值
  2. 可變引用 -透過某種方式標記這是一種借用,值所有權沒有轉移,借用完成後會自動歸還。這個期間,其它變數不能同時用這個值。但是當前借用能修改這個值
  3. 切片引用-只借用了部分(也可以是全部),可以細分為不可變切片可變切片

透過這3個方案,編譯器已經幫工程師默默地完成了借用和自動歸還的操作,工程師不需要再操心了。

借用是如何實現的,借用原書的2幅圖

圖_引用

圖_切片引用

原書還列出兩個引用原則:

  1. 在任何一段給定的時間內,你要麼只能擁有一個可變引用,要麼只能擁有任意數量的不可變引用                   
  2. 引用總是有效的

後文演示這幾個例子

2.3.1不可變引用

fn main() {
    let name = String::from("岳飛");
    //引用,被引用多少次都無所謂
    print_me(&name);
    print_me(&name);
    let n1 = &name;
    let n2 = &name;
    println!("n1={},n2={}", n1, n2);
}

書寫格式: &s

name可以同時被不可變借用多次。

這意味著,併發情況下可以共用一個值。

2.3.2可變引用

書寫格式: &mut s

fn mut_borrow() {
    let mut s = String::from("飛翔");
    let s1 = &mut s;
    println!("{}", s1);
    s1.push_str("在太空!");
    println!("{}", s1);
    let s2 = &mut s;
    println!("{}", s2);
    //println!("{}",s1); //在已經借給s2的情況下,再使用s1會報錯 -- first borrow later used here
    let s3 = &mut s;
    s3.push_str("星光燦爛");
    println!("{}", s3);

    let s4 = &mut s;
    let s5 = &mut s;
    let s6 = &s;
}

由於只能同時有一個可變引用,所以併發是一個問題。

2.3.3切片引用

書寫格式:

&s[..] -- 等同於s
&s[n..m] -- 取從n到m-1的部分,其中n>=0
&s[..n] -- 取0到n-1的部分
&s[n..] -- 取從n及其之後的所有

特別地,字串切片型別寫成 &str。

同時定義多個不可變切片,並可同時用

fn show_string_slice(){
    let name=String::from("ABCDEF GHIJKLMN");
    let s1=&name[0..4];
    let s2=&name[5..10];
    println!("s1={},s2={}",s1,s2);
}

其它切片演示:

可變的陣列切片

fn test_mut_slice(){
    let mut numbers = [1, 2, 3, 4, 5];  
    let slice: &mut [i32] = &mut numbers[0..1]; // 獲取整個陣列的可變切片  
    slice[0] = 10; // 修改切片中的第一個元素,這也會修改原始陣列  
    println!("{:?}", numbers); // 輸出: [10, 2, 3, 4, 5]
}

三、小節

  1. rust的值的所有權規則是一個非常獨特的內容,其它語言暫時沒有這樣的情況
  2. rust的所有權有一點難於理解,但務必理解,因為這是繼續的基礎,此關不通,不要考慮後面的學習內容
  3. rust的所有權,可以算是一種相對高效的主動垃圾回收機制,是一種可以接受的妥協
  4. rust提供了多種引用方式,使得同時共享一個值變得可能,例如引用,切片引用
  5. rust的編譯器默默地幹了很多的事情

相關文章