iT邦幫忙

2024 iThome 鐵人賽

DAY 14
0
Software Development

螃蟹幼幼班:Rust 入門指南系列 第 14

Day14 - 所有權(三):切片

  • 分享至 

  • xImage
  •  

引言:部分資料的處理

在處理資料時,我們經常只需要使用部分的資料。例如,當我們需要顯示句子的前幾個字作為預覽時,如何在保留原始資料所有權的同時,高效地處理部分資料呢?

保留原始變數所有權

我們先看一個簡單的例子:

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

https://ithelp.ithome.com.tw/upload/images/20241020/20168952WjuiICWw4m.png

這不是我們期望的行為,可以使用引用來解決這個問題:

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的預覽就搞錯了。

https://ithelp.ithome.com.tw/upload/images/20241020/20168952q1ztawFLRY.png

切片:不複製並保留關聯性

說到底,上述的情況是因為兩者已經失去關聯性,如果要保留兩者關聯性的話,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);
}

透過回傳參考型別,外部拿到一個和原本資料有關聯的數值,這個數值是由切片指定範圍組成的原始資料的部分資料。

https://ithelp.ithome.com.tw/upload/images/20241020/20168952Q5nrOjpQDS.png

然後因為關聯了,這段程式碼是會編譯失敗的。

$ 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 提醒了我們這是一個需要處理的情況,而不是無視兩者的關聯性。

&String 與 &str:何時使用?

這邊我們再次回想當初介紹字元型別提到的字串型別。

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 讓我想到之前看的一個動畫叫做慎重勇者,不管要做什麼都小心到很難搞,有時候甚至謹慎到你覺得滿有創意的,緊要關頭卻又很可靠😆
下一篇我們要繼續探討所有權機制的另外一個重要概念:生命週期。


上一篇
Day13 - 所有權(二):借用
下一篇
Day15 - 生命週期
系列文
螃蟹幼幼班:Rust 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言