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同時也就失效了, 不會產生二次釋放的問題, 效率大大增加,
上圖中就是第二中淺拷貝的情況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聖經