Rust所有權及引用

聽風走了八千里發表於2022-02-25

Rust 所有權和借用

Rust之所以可以成為萬眾矚目的語言, 就是因為其記憶體安全性. 在以往記憶體安全幾乎全都是通過GC的方式實現, 但是GC會引來效能、CPU以及Stop The World等問題, 在需要高效能的場景是不可以接受的,因此Rust使用一種與眾不同的方式 解決記憶體安全問題: 所有權機制

Rust所有權

所有程式都必須和計算機的記憶體打交道, 如何從RAM中申請空間存放程式執行所需要的資料, 在不需要是回收記憶體空間, 成為了關鍵, 在計算機程式語言不斷進化的過程中出現了三種解決方案:

  • 垃圾回收機制(GC) , 程式執行時RunTime 通過三色標記 引用計數 分代回收等演算法 回收空閒記憶體 : Go Python Java

  • 手動管理記憶體的分配和釋放, 編寫通過函式呼叫的方式申請釋放記憶體 : C malloc() free(), C++ new() delete()

  • 通過所有權機制管理記憶體, 在程式編譯期間 確定記憶體申請 釋放的時間, 將相關的資料硬編碼到二進位制程式中, 在程式執行期間不會有任何效能上的損耗

一段記憶體不安全的程式碼

int* foo() {
    int a;          // 變數a的作用域開始
    a = 100;
    char *c = "xyz";   // 變數c的作用域開始
    return &a;
}                   // 變數a和c的作用域結束

​ 這段C程式碼是可以順利編譯通過的 foo函式返回一個int指標型別, 但是變數a和c是foo函式內的區域性變數, 我們都知道 函式和函式內的區域性變數 都是儲存在棧當中的, 當foo函式執行完成後 區域性變數a,c及函式foo 在棧內申請的記憶體 就已經被回收了, 此時返回變數a的指標, 從而形成了懸空指標 (懸垂指標, 野指標) 因為a申請的記憶體資料在foo函式結束是已經被回收, 此時返回a的指標 指向的記憶體地址已經被回收或者被其他程式使用, 如果這塊地址再次被其他程式申請到並放入資料, 那就跟我們程式預期的效果產生差異,容易導致程式崩潰.

例如: a程式中a的資料是100 , 回收後被其他程式申請存入資料為 "malloc"。

​ 我們再來看一下變數c, 變數c的問題在於記憶體的浪費, 也是對棧的空間的浪費, c變數申請的記憶體在他宣告完成後沒有任何操作, 但是他回收的時間需要在foo函式結束是才進行回收 產生了資源的浪費

​ 記憶體安全的問題一直都是令開發者頭疼的問題, 所以如何保證記憶體安全成為我們對技術深度評判標準之一, Rust的所有權機制將解決大部分記憶體安全問題, 想要保證記憶體安全我們就需要對 堆 棧有足夠的認知

堆 和 棧

堆和棧是程式語言最核心的兩個資料結構, 在許多程式語言我們不需要深入瞭解, 因為GC會偷偷的無感知的幫我們進行記憶體的回收, 這也意味著效能的瓶頸, 但是對於Rust這種系統程式語言, 資料值位於棧 或 堆 上是很重要的, 因為他大大的影響程式執行時的效能

堆疊實際上都是我們RAM

棧 是按照順序且連續儲存值 並以相反的資料取值, 先進後出, 儲存資料為進棧 , 取出資料為出棧。 棧中的資料值所申請的記憶體大小必須是已知的固定的記憶體空間, 如果資料值大小是未知的, 那麼取出資料時, 你無法取出你想要的資料。

棧 通常儲存的資料是 程式語言的內建的基本型別的資料 i32 i64 f32 f64 &str bool 、 函式、 函式內的區域性變數 、堆指標地址、元祖

ulimit -s 用於檢視作業系統的棧空間 間接的說明棧空間是有限的 如果申請棧記憶體空間超出棧 就會發生棧溢位 程式崩潰、Go記憶體逃逸分析 等場景

每一個程式執行時作業系統都會為其分配棧的記憶體空間 1-8M , 通常情況下不會出現棧溢位 如果出現死迴圈、深遞迴的時候就極有可能出現程式崩潰。

對比著棧來理解堆 更容易理解一些

棧是由cpu暫存器來訪問控制回收, 堆是由開發者來控制堆記憶體的回收

棧中儲存的資料值都是已知大小的資料, 堆內可以儲存未知大小的動態資料 相對靈活 .

棧申請的記憶體用完立即釋放, 堆記憶體需要根據生命週期和GC演算法釋放記憶體

棧是連續的記憶體空間, 堆是不連續的 很有可能會產生記憶體碎片 無法回收造成浪費

棧的空間是有限的, 堆的空間可以認為是無限的

棧為什麼會比堆快

1.cpu快取記憶體會快取棧內的資料 不會快取堆內的資料 跟他們的儲存規則有關

2.棧是直接定址 申請只存只需要移動一個指標即可, 堆是間接定址的 首先要去棧內取得變數的堆指標, 才可以獲取資料。

3.棧是由cpu的暫存器直接訪問控制的

4.棧在程式開始執行就已經開闢好了記憶體空間, 而堆需要在程式執行時 執行到對應到指定位置才開闢記憶體空間

5.入棧比堆分配記憶體快, 因為入棧作業系統無需分配新的記憶體空間,只需將新資料放入棧頂

所有權原則

在理解堆疊的前提下, 更有利理解Rust的所有權

1.Rust中的每一個值 有且只有一個所有者(變數)

let s = String::from("teststr")  // 變數s就是字串teststr的所有者

2.當所有者(變數)離開作用域範圍時,這個值將被丟棄(free) 也就是釋放記憶體空間

fn test() {
  let s = String::from("teststr")  // s為test函式中的區域性變數
} // 函式執行完成  變數s 離開作用域 字串teststr的記憶體將被釋放 生命週期結束

簡單介紹String型別

上邊提到了String::from 方法 , 建立變數的型別是String

let s = String::from("teststr")  // 變數s就是字串teststr的所有者

還有一種宣告字串的例子 這種宣告的字串型別是 字串字面值 a 是被硬編碼到程式的型別是&str 他不可修改

let a = "test"

所有權背後的資料互動

下面看這樣一段程式碼

let x = 5;  // x 變數就是 整數5的所有者
let y = x;  // 拷貝 x 賦值給 y  最終x和y都等於5  且都可以呼叫 因為上述操作都是在棧中運作的 整數型別是rust的基本型別 基本型別賦值呼叫都會自動拷貝 不會在堆中進行分配使用  也不會引發所有權機制

// 可能有好奇寶寶 會想 這種棧中的的copy賦值 是不是太慢了些, 但是實際上在rust的基本型別足夠簡單 ,拷貝會非常快, 只需要賦值一個i32,4位元組的記憶體即可

隨即看這樣一段程式碼:


let s1 = String::from("hello");
let s2 = s1;

println!("{}{}", s1, s2)
// 跟上邊的整型拷貝很像吧 但是 String型別 並不是rust的基本型別  所以他是存放在堆上的 不會自動拷貝 此時列印s1,s2就會觸發rust的所有權機制

// 我們可以先看一下上邊這段程式碼具體發生了什麼
//String型別是一個複雜的型別, 他的堆指標、字串長度、字串容量共同存放在棧中, 真實資料存放在堆中,下面我們分析 let s2 = s1 可能出現的兩種情況
	1.拷貝棧上String堆指標 容量 長度 和儲存在堆上的位元組陣列, 這就是深拷貝了
	2.只拷貝String的堆指標 容量 長度 8+8+8位元組 理解為淺拷貝, 但是這樣就跟Rust所有者機制產生了衝突  因為我們的資料的所有者有且只能有一個, 如果按照這種淺拷貝的情況 那麼這個資料就出現了兩個所有者, 那麼當s1和s2離開作用域的時候都會釋放同一塊記憶體, 也稱為二次釋放, 導致記憶體汙染 違背了Rust的所有權機制, 那麼Rust是如何處理這種問題呢? 解決方法: 
	當s1將值賦值給s2的時候, Rust認為s1不再有效, 因此也無需在s1離開作用域後drop釋放s1的內容, s1的資料的所有權已經轉移給了s2, s1同時也就失效了, 不會產生二次釋放的問題, 效率大大增加,

image

上圖中就是第二中淺拷貝的情況rust解決的方案, s1賦值給s2後 s1自動失效, s2接管這塊記憶體地址

深拷貝

Rust永遠不會自動建立資料的"深拷貝", 因此, 任何的自動複製都不是深拷貝. 淺拷貝被認為執行時效能影響較小

let s1 = String::from("hehahi");
let s2 = s1.clone();  // 深拷貝
println!("{}{}", s1, s2)

此段程式碼編譯執行暢通無阻, 因為s2 完成的clone了s1 包括棧內的堆指標 容量 長度 堆內的資料, 但是如果頻繁使用clone深拷貝 將會帶來效能上的降低。

函式引數傳遞及返回 所有權的轉移

在變數作為引數傳遞給函式是, 同樣會發生移動或者複製, 所有權就會對應的產生變化


fn main() {
    let s = String::from("hello");  // s 進入作用域

    takes_ownership(s);             // s 的值移動到函式裡 ...
                                    // ... 所以到這裡不再有效

    let x = 5;                      // x 進入作用域

    makes_copy(x);                  // x 應該移動函式裡,
                                    // 但 i32 是 Copy 的,所以在後面可繼續使用 x

} // 這裡, x 先移出了作用域,然後是 s。但因為 s 的值已被移走,
  // 所以不會有特殊操作

fn takes_ownership(some_string: String) { // some_string 進入作用域
    println!("{}", some_string);
} // 這裡,some_string 移出作用域並呼叫 `drop` 方法。佔用的記憶體被釋放

fn makes_copy(some_integer: i32) { // some_integer 進入作用域
    println!("{}", some_integer);
} // 這裡,some_integer 移出作用域。不會有特殊操作

我們如果嘗試在takes_ownership(s); 語句執行之後 列印s值 就會產生報錯 因為s作為引數傳遞給takes_ownership函式 String型別 不是基本型別 不會自動拷貝, 所以String的所有權轉移到函式內, 又轉移給了println巨集當中 但函式執行完成, String開闢的這塊記憶體已經被釋放了 所以在函式之後列印s 就會報錯 ,但是如果makes_copy(x) 函式之後執行列印x 就不會報錯的, 因為i32型別是基本型別, 儲存在棧內會進行自動拷貝, 不會觸發所有權機制 , 但如果不是儲存在棧的資料 就需要將資料返回出來, 這樣資料傳來傳去 很是麻煩, Rust就幫我們解決了這個問題 引入了借用機制。

借用

在Rust中借用 在變數前加& 就變成了借用 不會產生所有權的轉移, 在其他語言我們稱這樣的變數是引用, 但是Rust直譯器中明確表明 就稱其為借用, Rust通過借用Borrow概念達成減少所有權傳遞程式複雜的目的: **獲取變數的引用, 稱之為借用 **, 可以很好的理解, 我們上學忘記帶鉛筆, 可以跟朋友同學去借, 但是在使用完成後, 要物歸原主.這裡排除老賴等極端情況...

引用與解引用

常規的引用是一個指標型別, 指向了物件儲存的記憶體地址。 在下面我們建立一個x i32值的引用 y, 然後使用解引用得到記憶體中真實的資料

let x: i32 = 5;
let y = &x

assert_eq!(5, x)
assert_eq!(5, *y) // y 是 5這個i32型別的資料記憶體地址  *y就是反引用得到的就是記憶體中的真實的資料5

當然這個時候 x 和 y也都可以正常列印出來因為引用不會涉及到所有權轉移的問題 x 的不會出現失效的情況

不可變引用


fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1); // 將s1的引用傳遞給函式

    println!("The length of '{}' is {}.", s1, len);
}

// 函式接受 String的引用 返回一個 usize型別  usize就是無符號的根據作業系統位數生成的整數型別 例如我們作業系統是64位 那就是u64 
fn calculate_length(s: &String) -> usize { 
    s.len()
}// 因為傳入的是引用型別  所以函式執行完成後不會釋放drop掉s 什麼也不會發生, 通過下面看一下型別引用的整體結構

s            s1         

ptr    ->    ptr     ->   0  h
             len          1  e 
             cap          2  l
       										3  l
													4  o

上述場景我們函式傳參的簡易性有了, 我們不覺的想到如果想修改 資料的值可以嗎, 接下來我們看下面的程式碼:


fn main() {
    let s1 = String::from("hello");

    calculate_length(&s1); // 將s1的引用傳遞給函式

}
 
fn calculate_length(s: &String) { 
    s.push_str(" world!"); // 再此處修改資料
}

push_str處就會報錯。因為在rust中定義的引用 都是不可以更改原來的資料的 就好像我們去圖書館借書 看可以 但是如果在毀壞書籍 亂塗亂畫是不被允許的, 那如何我想畫就畫呢? Rust 也幫我們解決了, 那就是定義引用的時候宣告他是一個可變引用

可變引用

fn main() {
    let mut s1 = String::from("hello"); // 宣告s1為可變引數

    calculate_length(&mut s1); // 將s1的引用傳遞給函式

}
 
fn calculate_length(s: &mut String) {  // 宣告傳遞的引數必須是一個可變的String型別引數
    s.push_str(" world!"); // 再此處修改資料
}

這段程式碼就可以完美的執行了

但是可變引用必須遵從Rust的一個原則:可變引用同時只能存在一個, 也就是在同一個作用域中, 一個資料只能有一個可變的引用, 同時不可變可以擁有多個

也就是說 一本書我借給多個人 , 你們一堆人可以一起看, 其中只能有一個人可以對這本書 修改 , 這樣的好處就是 Rust在編譯時就避免了資料的競爭, 下面這段程式碼就出現了多引用:

fn main() {
  let mut s = String::from("hello");

	let r1 = &mut s;
	let r2 = &mut s;

	println!("{}, {}", r1, r2);
} 
// 這段程式碼就會報錯  因為宣告瞭兩個可變引用 且他們在同一個作用域main函式中,第一個可變引用r1宣告週期必須持續到print完成後 在r1的宣告週期內又嘗試建立了一個可變引用r2 引起了資料的競爭 




fn main() {
  let mut s = String::from("hello");

	let r1 = &s;
  println!("{}", r1); 
	let r2 = &mut s;   // 如果想要 一段程式碼中同時引用可變引用和不可變引用  他們的生命週期必須沒有交集
	println!("{},", r2);  
} 

// 可變引用和不可變引用在新版本的編譯器中是可以同時存在的, 1.31之前不可以
// 對於這種編譯器的優化Rust專門去了一個名字NLL - Non-Lexical Lifetimes(NLL),, 就是專門找出某一個引用在作用域 } 結束之前就不在被使用的引用的位置


懸垂引用 (出現懸空指標、 也可稱迷途指標 、 野指標)

懸空指標 就是 指標指向實際的資料, 但是這個值在使用之前之前就已經被釋放掉了, 但是 指標 也就是引用存在, 釋放掉的記憶體可能不存在任何值, 或者被其他程式變新使用了, 造成了資料汙染 , 而Rust編譯器可以永遠保證 引用不懸垂。

發生懸垂的場景:

fn main() {
    let mut testStr = String::from("testing"); 
    let result = overhang(testStr); // 將String資料傳給overhang函式 此時String的所有權轉移到overhang函式當中
    println!("{}",result); // 懸空指標產生了因為引用真正資料已經被釋放了 找不到原本你的資料了
}

fn overhang(mut s: String) -> &String {  // 
    s.push_str("123");  // 修改String
    &s  // 返回String 的引用
} // 在此處 s 離開當前作用域 s 被drop掉 記憶體釋放 , 返回&s 危險

error : error[E0106]: missing lifetime specifier

這裡出現了關於生命週期的概念: 程式中每一個變數都有對應的作用域, 當超出作用域之後變數就會被自動銷燬 一句話說就是一個變數在建立 到 被釋放的過程, 稱之為生命週期.

不過即使不瞭解生命週期僅僅瞭解引用 就可以理解懸垂指標。

解決上述程式碼的方法:將String返回 而不是&String

fn overhang(mut s: String) -> &String {  // 
    s.push_str("123");  // 修改String
    s  // 返回String 的引用
} 

這樣就沒有任何問題了

本文部分參照: Rust聖經

相關文章