本篇文章包含的內容
- 1 重新從堆和棧開始考慮
- 2 所有權規則
- 3 變量和數據(值)的交互方式
- 3.1 移動 Move
- 3.2 克隆 Clone
- 3.3 復制 Copy
- 4 函數與所有權
- 4.1 參數傳遞時的所有權轉移
- 4.2 函數返回時的所有權轉移
- 5 引用和借用
- 6 切片
前面兩篇僅僅介紹了一些Rust的語法以及一些程序書寫特點。如果是其他語言,其實已經可以說完成了六成以上的學習,可以開始著手項目,以實踐驅動學習了。但所有權和生命周期才是Rust的魅力所在,真正的難點現在才剛剛開始(噔噔咚)。
1 重新從堆和棧開始考慮
所有權是Rust最獨特的特性之一,使得它與Java、C#等語言相比不需要GC(Garbage Collector,垃圾收集器)就可以保證內存安全,同時也不需要像C/C++一樣手動釋放內存。為了理解所有權,我們必須了解Rust的內存分配機制,這是在之前學習的語言中基本不會注意的點。
無論哪種語言編寫的程序,都必須考慮他們運行時對計算機內存的操作方式。Rust并不相信程序員,但是也摒棄了GC算法這種低效的方式,取而代之的是引入所有權的概念,使程序中的內存操作錯誤在編譯時就基本解決,并且這種做法不會造成任何的運行時開銷。
在程序運行時,堆(Heap)和棧(Stack)都是程序可用的內存,它們的本質區別是內存組織的方式不同。棧內存先入后出,永遠有一個指針指向棧頂,內存的存儲是連續的,所有存儲在棧中的數據必須有已知的或者固定的大小;而堆內存相對比較混亂,程序使用的內存是碎片化的,一般在運行時申請的動態內存都屬于堆內存,操作系統在申請Heap時,需要申請一個足夠大的空間,并返回一個額外的指針變量記錄變量的存儲位置(并且需要做好記錄和管理方便下次分配),這導致程序運行時的指針可能存在大范圍的跳轉。總之,棧內存效率更高,堆內存以犧牲效率為代價換取了更多的靈活性。
所有權解決了以下問題:
- 跟蹤代碼的哪些部分正在使用Heap的哪些數據;
- 最小化Heap上的重復數據量;
- 及時清理Heap上未使用的數據以避免空間不足。
2 所有權規則
Rust中所有權有以下三條規則(它很重要,先記下來再慢慢理解):
- 每個值都有一個變量,這個變量就是這個值的所有者;
- 每個值同時只能有一個所有者;
- 當所有者超出作用域(Scope)時,該值將被刪除。
下面是一個關于作用域(Scope)的簡單例子。作用域的概念在其他編程語言中也有,這里需要理解的是,s
是變量,“hello”
就是這個變量的值(一個字符串字面值)。
// s 無效
fn main() {// s 無效let s = "hello"; // s 可用// s 繼續有效
} // s 的作用域從這里結束
通過第一部分的解釋,這里就比較好理解變量s
的存儲方式了。它的值在編譯時就已經全部確定,并且不會隨之變化(如果需要變化則需要引入String類型),所以這個變量和它的值在編譯時就會被全部寫入可執行文件中。
與之相比,String
類型在堆上分配,這使得它可以存儲在編譯時未知數量的文本。下面的例子中,s
超出作用域時會自動調用一個特殊的名為drop
的函數來釋放內存。所以String類型是一個實現了Drop trait(trait,接口)的類型。
fn main() {let mut s = String::from("Hello");s.push_str(", world!");println!("{}", s);
} // s 會自動調用一個drop函數
看到這里你可能依然一頭霧水(這家伙在說什么呢.jpg),這些概念和C/C++以及其他語言難道做不到嗎?超出作用域釋放內存難道不是理所當然的嗎?既然如此我還為什么要學Rust?Rust究竟好在哪?所謂的內存安全就這?
?
別急,這個Drop方法看似人畜無害,但是它會導致一個非常嚴重的bug。
3 變量和數據(值)的交互方式
3.1 移動 Move
首先看下面這個例子,創建了兩個簡單的整數變量,由于它們的大小是確定的,所以兩個變量都將被壓入棧中,值發生了復制。像整數這樣完全存放在棧上的數據實現了Copy trait。
let x = 5;
let y = x; // value copied here
但是下面這個例子不同,s1
在內存中的索引信息存儲在棧中,s1
所對應的內容需要被存放在堆中(出于值的長度可變的需要)。棧中包含一個指向字符串存儲位置的指針,一個字符串實際長度,一個從操作系統中獲得的內存的總字節數。
let s1 = String::from("hello");
如果接下來接著執行這一語句,那么棧中s1的信息會被復制一份,但是堆中字符串的值不會復制(有點像淺拷貝),s1
的所有權將會直接被遞交給s2
,同時s1
會直接失效,這時我們說值的所有權發生了移動(Move)。這樣做的目的是避免兩個字符串離開作用域時調用兩次drop函數,從而導致嚴重的Double Free錯誤。
let s2 = s1; // value moved here
println!("{}", s1); // 編譯直接報錯
3.2 克隆 Clone
對于上面的s1
和s2
的例子,如果想同時拷貝棧和堆中的信息,可以使用clone()
方法。這樣的操作明顯是比較浪費資源的。
fn main() {let s1 = String::from("hello");let s2 = s1.clone();println!("{} {}", s1, s2);
}
3.3 復制 Copy
總之,如果一個變量存在Copy trait,那么舊變量在“移動”后依然可用;如果一個類型或者該類型的一部分實現了Drop triait(例如定義的元組的一部分是String的情況),那么Rust就不允許它再實現Copy trait了,編譯時就會進行檢查,在移動后舊變量就不再可用,除非使用了clone()
方法。
4 函數與所有權
Rust中的變量總是遵循下面的規則:
- 把一個變量賦值給其他變量就會發生移動(除非變量存在Copy trait);
- 當變量超出其作用域后,存儲在Heap上的數據就會被銷毀(Drop trait),除非它的所有權已經被轉移。
4.1 參數傳遞時的所有權轉移
在Rust中,如果函數參數的類型是一個實現了Drop trait的類型(例如String類型),把值傳遞給函數中往往伴隨著所有權的轉移,也就是說舊變量對值的所有權會發生丟失,這里發生的事情和把變量賦值給另一個變量是類似的。看下面這個例子:
fn main() {let s1 = String::from("hello");take_ownership(s1);// println!("{}", s1); // 編譯報錯let x = 1;makes_copy(x);println!("the x is {}", x);
}fn take_ownership(some_string: String) {println!("{}", some_string);
}fn makes_copy(some_integer: i32) {println!("{}", some_integer);
}
對于String這種類型的變量,直接將其作為函數參數時,傳入參數時hello
String的所有權會從s1
轉換到函數內部的some_string
,程序運行到take_ownership
函數之外時會自動調用Drop trait,字符串的值的內存會被釋放。但是對于實現了Copy trait的類型,例如i32
,參數傳遞時會發生copy,而不是move,這樣在函數調用后x
變量依然是可用的。
4.2 函數返回時的所有權轉移
這個比較好理解,看下面一個例子:
fn main() {let s1 = gives_ownership();let s2 = String::from("hello");let s3 = takes_and_gives_back(s2);
}fn gives_ownership() -> String {let s = String::from("hello");s
}fn takes_and_gives_back(a_string: String) -> String {a_string
}
對于gives_ownership
函數,在函數內部創建了一個新的String,函數返回時不會將其銷毀,而是把它的所有權交給主函數的s1
;而takes_and_gives_back
函數獲取到s2
到的所有權,s2
之后會失效,返回時將String的所有權交還給主函數的s3
。
5 引用和借用
但有些時候,我們只想獲得變量的值,而不想它的所有權發生轉移(甚至丟失),這時候就可以使用引用(Reference)。
fn main() {let s1 = String::from("hello");let lenth = calculate_length(&s1);println!("The length of '{}' is {}.", s1, lenth);
}fn calculate_length(s: &String) -> usize {s.len()
}
在上面的例子中,calculate_length
函數使用了String的引用作為參數,函數計算返回字符串長度后s1
仍然是可用的。引用相當于一個指針,它可以獲取到變量對應的值,但是不擁有它,所以當其離開作用域時也無法銷毀它。像這樣,把引用作為函數參數這個行為稱為借用(Borrow)。
在Rust中,引用和變量類似,也分為可變的引用和不可變的引用,創建的引用默認同樣是不可變的。下面是一個使用可變引用的例子。
fn main() {let mut s1 = String::from("hello");let lenth = calculate_length(&mut s1);println!("The length of '{}' is {}.", s1, lenth);
}fn calculate_length(s: &mut String) -> usize {s.push_str(", world!");s.len()
}
需要注意引用的特殊限制:在特定的作用域內,一個變量只能同時擁有一個可變的引用;并且不能同時存在可變的引用和不可變的引用。一個變量可以擁有多個不可變的引用。Rust從編譯層面解決了數據競爭的問題。
let mut s = String::from("hello");let s1 = &mut s;
let s2 = &mut s; // 非法
let mut s = String::from("hello");
{let s1 = &mut s;
}
let s2 = &mut s; // 合法
這樣的做法還帶來了另一個好處,即永遠不會存在“懸空引用”(Dangling Reference,一個引用或者指針指向一塊內存,但是這一塊內存可能已經被釋放或者被其他人使用了)或者“野指針”。
總之,引用一定滿足下面的規則:
- 引用一定有效;
- 引用一定滿足下列條件之一,不可能同時滿足:
- 存在一個可變引用;
- 存在任意數量的不可變引用。
6 切片
切片(Slice)是指一段數據的引用。這里的一段數據可以是String類型,也可以是數組。字符串切片的寫法如下所示,類型名在程序中是&str
。
let s = String::from("hello world");let hello = &s[0..5]; // 左閉右開,此時相當于 &s[..5]
let world = &s[6..11] // 此時相當于 &s[6..]let whole = &s[..] // 整個字符串的切片
需要注意,字符串切片的索引必須發生在有效的UTF-8字符邊界內(就是不能把字符切“壞”了),否則程序就會報錯退出。
為什么要使用切片?看下面這個例子:獲取字符串中的各個單詞,如果字符串中沒有空格,則返回整個字符串。
fn main() {let s = String::from("hello");let word_index = first_word(&s);println!("{}", word_index);
}fn first_word(s: &String) -> usize {let bytes = s.as_bytes(); // 將String轉換為字符數組for (i, &item) in bytes.iter().enumerate() {if item == b' ' {return i;}}s.len()
}
上面這個程序雖然能完成一部分功能(獲取第一個空格的位置),但是這個程序存在一個重要的結構性缺陷:變量word_index
和Strings
之間沒有任何聯系,即使s
被釋放,或者被修改,word_index
也無法感知。
使用字符串切片重寫上面的例子:
fn main() {let s = String::from("hello world");let word = first_word(&s); // 把s作為不可變的引用發生借用,之后s都不可變// s.clear(); // s不可變println!("{}", word);
}fn first_word(s: &String) -> &str {let bytes = s.as_bytes(); // 將String轉換為字符數組for (i, &item) in bytes.iter().enumerate() {if item == b' ' {return &s[..i];}}&s[..]
}
字符串子面值也是切片。利用這一特點,我們可以將函數的參數類型改為字符串切片&str
,使得函數可以直接接收字符串子面值作為參數,這樣函數就可以同時接收String和字符串切片兩種類型的變量作為參數了。
fn main() {let word = first_word("hello world"); println!("{}", word);
}fn first_word(s: &str) -> &str {let bytes = s.as_bytes(); // 將String轉換為字符數組for (i, &item) in bytes.iter().enumerate() {if item == b' ' {return &s[..i];}}&s[..]
}
其他數組類型也存在切片,例如使用下面的方法創建一個i32
類型的切片,程序中用&[i32]
表示該類型。
let a: [i32; 5] = [1, 2, 3, 4, 5];
let slice = &a[1..3]; // slice類型是&[i32]
??原創筆記,碼字不易,歡迎點贊,收藏~ 如有謬誤敬請在評論區不吝告知,感激不盡!博主將持續更新有關嵌入式開發、FPGA方面的學習筆記。