在處理資料時,我們經常只需要使用部分的資料。例如,當我們需要顯示句子的前幾個字作為預覽時,如何在保留原始資料所有權的同時,高效地處理部分資料呢?
我們先看一個簡單的例子:
fn get_preview(s: String) -> String {
s[0..4].to_string()
}
fn main() {
let sentence = String::from("Rust programming is fun");
let preview = get_preview(sentence);
println!("Preview: {}", preview);
}
這種寫法會重新產生一個新的字串,原始字串sentence
被移動進入get_preview
,因此無法再在main
中使用sentence
。
這不是我們期望的行為,可以使用引用來解決這個問題:
fn get_preview(s: &String) -> String {
s[0..4].to_string()
}
fn main() {
let sentence = String::from("Rust programming is fun");
let preview = get_preview(&sentence);
println!("Preview: {}", preview);
println!("Sentence: {}", sentence);
}
這段程式碼可以正常編譯和執行,解決了我們無法再使用原始字串的問題。然而,這種做法會使得preview
和原字串失去聯繫,preview
是一個獨立的拷貝,它佔了另外一塊記憶體,原本字串的改動都不會影響到它。
我們把原本的程式碼再加上一點操作,把原本句子的 Rust 改成 Go 來觀察:
fn main() {
let mut sentence = String::from("Rust programming is fun");
let preview = get_preview(&sentence);
println!("Preview: {}", preview); // Preview: Rust
println!("Sentence: {}", sentence); // Sentence: Rust programming is fun
let first_word_end = sentence.find(' ').unwrap_or(sentence.len());
sentence.replace_range(0..first_word_end, "Go");
println!("Preview after clear: {}", preview); // Preview after updated: Rust
println!("Sentence after clear: {}", sentence); // Sentence after updated: Go programming is fun
}
可以看到 preview 還是原本的 Rust,但 sentence 已經變成 Go 了,在這個情境下,預覽已經失效了。
如果我們要取得真正意義上的預覽,如果沒有其他機制,要怎麼確保兩者資料是同步的就是個大問題了,更可能造成後續使用誤判,如果把preview
當成目前sentence
的預覽就搞錯了。
說到底,上述的情況是因為兩者已經失去關聯性,如果要保留兩者關聯性的話,Rust 提供的解決方案是切片(slice)。
可以把切片視做簡易版的引用,和引用的特性大致相同,像是作用域、能不能重複借用等,不過切片是用來指向部分的資料,資料結構是儲存起始位置和長度,而一般引用還會紀錄目前對應記憶體的容量(capacity)。
所以我們把回傳的型別從String
改為&str
:
fn get_preview(s: &String) -> &str {
&s[0..4] // 部分資料的參考
}
fn main() {
let mut sentence = String::from("Rust programming is fun");
let preview = get_preview(&sentence);
println!("Preview: {}", preview);
println!("Sentence: {}", sentence);
let first_word_end = sentence.find(' ').unwrap_or(sentence.len());
sentence.replace_range(0..first_word_end, "Go");
println!("Preview after updated: {}", preview);
println!("Sentence after updated: {}", sentence);
}
透過回傳參考型別,外部拿到一個和原本資料有關聯的數值,這個數值是由切片指定範圍組成的原始資料的部分資料。
然後因為關聯了,這段程式碼是會編譯失敗的。
$ cargo run
error[E0502]: cannot borrow `sentence` as mutable because it is also borrowed as immutable
--> src/main.rs:12:5
|
7 | let preview = get_preview(&sentence);
| --------- immutable borrow occurs here
...
12 | sentence.replace_range(0..first_word_end, "Go");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
13 |
14 | println!("Preview after updated: {}", preview);
| ------- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
如同前面提到的,切片也可以視為一種引用,preview
已經是不可變的引用了,從它宣告到最後一次使用(Preview after updated)之前不允許去更新數值。
當切片和原始資料有聯繫時,Rust 會防止不安全的操作,因此編譯報錯提醒我們。這時,我們可以在修改 sentence 後重新生成 preview:
fn main() {
let mut sentence = String::from("Rust programming is fun");
let mut preview = get_preview(&sentence);
println!("Preview: {}", preview);
println!("Sentence: {}", sentence);
let first_word_end = sentence.find(' ').unwrap_or(sentence.len());
sentence.replace_range(0..first_word_end, "Go");
preview = get_preview(&sentence);
println!("Preview after updated: {}", preview); // Preview after updated: Go p
println!("Sentence after updated: {}", sentence); // Sentence after updated: Go programming is fun
}
當然,除了上述做法,我們也可以重新審視中間的改動是否是必要的,或是我們可以把 preview 的使用往前移到改動之前,重點是,Rust 提醒了我們這是一個需要處理的情況,而不是無視兩者的關聯性。
這邊我們再次回想當初介紹字元型別提到的字串型別。
let s = "test";
我們現在知道它的型別&str
就是一種切片,像 "test"
這樣在程式碼中直接寫出的固定字串被稱為字串字面值(String Literal),這些字串在編譯時會直接嵌入到程式的可執行文件中,因為這些值已經定義下來,在程式執行的期間都存在且不可變,s
實際上是對字串 "test"
在內存中位置的一個引用,而不是一個擁有該字串數據的變數。
也因此,我們不需要也不應該把這類資料當成是 String
型別處理,因為 String
是存在 Heap 拿來用在會變動的字串類型,字串字面值是不可變的、固定的內容,直接用切片的效率顯然更好,並且避免不必要的內存分配和拷貝。
最後最後,再做一個小調整來應對可能會遇到的另外一個情況:
我們已經知道切片的作用,那也有可能拿到字串的切片之後再去處理,例如以下我們用切片截取部分的句子再呼叫 get_preview
,問題是這樣編譯會因為參數的型別不符合就報錯了。
fn main() {
let sentence = String::from("Rust programming is fun");
let part_of_sentence = &sentence[5..];
let preview = get_preview(part_of_sentence);
println!("Preview: {}", preview);
}
$ cargo run
error[E0308]: mismatched types
--> src/main.rs:4:31
|
4 | let preview = get_preview(part_of_sentence);
| ----------- ^^^^^^^^^^^^^^^^ expected `&String`, found `&str`
| |
| arguments to this function are incorrect
|
= note: expected reference `&String`
found reference `&str`
我們回頭看 get_preview
函數簽名,我們定義傳進去的型別要是 &String
,而兩者的差別在於 &String
是對 String
整體的不可變引用。它保留了對 String
變數的引用,可以在需要的地方使用完整的 String
功能,而 &str
是對字串數據的一個切片,可以來自 String
、字串字面量或其他字串類型。它只包含對字串的一部分的不可變引用。
換句話說,&String
支援&str
所有功能,但反過來不是。
通過 &String
訪問字串時,實際上是通過這個引用來訪問 String
的字串數據,通常用在需要保留 String
的所有屬性和行為的情況下。
但大部分的情況 &str
是一個更通用的引用類型,大多數字串操作函數都支援,而且更靈活且開銷更小,因此比較常用。
以我們的情況把get_preview
的參數型別換成 &str
完全不會有問題,反而變得更泛用了:
fn get_preview(s: &str) -> &str {
&s[0..4]
}
fn main() {
let sentence = String::from("Rust programming is fun");
let preview = get_preview(&sentence); // 支援 String 的引用
println!("Preview of whole: {}", preview); // Preview of whole: Rust
let part_of_sentence = &sentence[5..];
let part_preview = get_preview(part_of_sentence); // 支援字串切片
println!("Preview of part: {}", part_preview); // Preview of part: prog
let anoter_sentence = "another sentence";
let another_preview = get_preview(anoter_sentence); // 也支援字串字面值
println!("Preview of another: {}", another_preview); // Preview of another anot
}
到這邊我們對於 Rust 所有權機制的設計有了更深入的理解,開始感受到對 Rust 來說為什麼型別還有資料儲存位置是重要的。
我們同時看到了 Rust 如何通過切片來高效地處理部分資料,同時保持資料的安全性和一致性。這種設計體現了 Rust 的核心理念:在提供靈活性的同時,確保程式的安全性和效能。
Rust 讓我想到之前看的一個動畫叫做慎重勇者,不管要做什麼都小心到很難搞,有時候甚至謹慎到你覺得滿有創意的,緊要關頭卻又很可靠😆
下一篇我們要繼續探討所有權機制的另外一個重要概念:生命週期。