Rust 的切片

爲什麼要有切片

除了引用,Rust 還有另外一種不持有所有權的數據類型:切片(slice),切片允許我們引用集合中某一段連續的元素序列,而不是整個集合。

考慮這樣一個小問題:編寫一個搜索函數,它接收字符串作爲參數,並將字符串中的首個單詞作爲結果返回。如果字符串中不存在空格,那麼就意味着整個字符串是一個單詞,直接返回整個字符串作爲結果即可。

讓我們來看一下這個函數的簽名應該如何設計:

fn first_word(s: &String) -> ?

由於我們不需要獲得傳入值的所有權,所以這個函數採用了 &String 作爲參數。但它應該返回些什麼呢?我們還沒有介紹獲取部分字符串的方法,但是可以曲線救國,將首個單詞結尾處的索引返回給調用者。

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();
    for (index, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            // 這裏要使用 return index; 不能只寫 index
            // 因爲表達式作爲返回值要出現在函數的最後面
            return index
        }
    }
    s.len()
}

fn main() {
    println!(
        "{}", 
        first_word(&String::from("hello world"))
    ); // 5
}

這段代碼首先使用 as_bytes 方法將 String 轉換爲字節數組(u8),因爲我們的算法需要依次檢查 String 中的字節是否爲空格。

接着通過 iter 方法創建了一個可以遍歷字節數組的迭代器,我們會在後續詳細討論迭代器,目前只需要知道 iter 方法會依次返回集合中的每一個元素即可。

而隨後的 enumerate 則將 iter 的每個輸出逐一封裝在元組中返回,元組的第一個元素是索引,第二個元素是指向集合中字節的引用(&u8),使用 enumerate 可以較爲方便地獲得迭代索引。

既然 enumerate 方法返回的是一個元組,那麼我們就可以使用模式匹配來解構它,就像 Rust 中其它使用元組的地方一樣。在 for 循環的遍歷語句中,我們指定了一個解構模式,其中 i 是元組中的索引部分,而 &item 則稍微有點難理解。

首先迭代出的元組裏面的第二個元素是 &u8,如果我們使用 item 遍歷,那麼得到的 item 就是 &u8,在比較的時候還需要解引用,即 *item == b' '。而使用 &item 遍歷,那麼 &item 得到的也是 &u8,顯然 item 就是 u8,我們就不需要解引用了。

在 for 循環的代碼塊中,使用了字面量語法來搜索數組中代表着空格的字節,這段代碼會在搜索到空格時返回當前的位置索引,並在搜索失敗時返回傳入字符串的長度 s.len()。

現在我們初步實現了期望的功能,它能夠成功地搜索並返回字符串中第一個單詞結尾處的位置索引。但這裏依然存在一個設計上的缺陷,我們將一個 usize 值作爲索引獨立地返回給調用者,而這個值在脫離了傳入的 &String 的上下文之後便毫無意義。

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();
    for (index, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return index;
        }
    }
    s.len()
}

fn main() {
    let mut s = String::from("hello world");
    let index = first_word(&s);
    println!("{}", index); // 5
    // s.clear() 之後會清空字符串,將 s 變成 ""
    s.clear();
    println!("s = {}", s);  // s =
    // s 被清空了,index 還是 5,但顯然此時 index 已經沒有意義了
}

上面的程序在編譯器看來沒有任何問題,即便我們在調用 s.clear() 之後使用 index 變量也是沒有問題的。同時由於 index 變量本身與 s 沒有任何關聯,所以 index 的值始終都是 5。但當我們再次使用 5 去從變量 s 中提取單詞時,一個 bug 就出現了:此時 s 中的內容在我們將 5 存入 index 之後發生了改變。

這種 API 的設計方式使我們需要隨時關注 word 的有效性,確保它與 s 中的數據是一致的,類似的工作往往相當煩瑣且易於出錯。這種情況對於另一個函數 second_word 而言更加明顯,這個函數被設計來搜索字符串中的第二個單詞,它的簽名也許會被設計爲下面這樣:

fn second_word(s: &String) -> (usize, usize)

現在我們需要同時維護起始和結束兩個位置的索引,這兩個值基於數據的某個特定狀態計算而來,但卻沒有跟數據產生任何程度上的聯繫。於是我們有了 3 個彼此不相關的變量需要被同步,這可不妙。但幸運的是,Rust 爲這個問題提供瞭解決方案:字符串切片。

字符串切片

fn main() {
    let s = String::from("hello world");
    let s1 = &s[0..5];
    let s2 = &s[6..11];
    println!("s1 = {}, s2 = {}", s1, s2);  
    // s1 = hello, s2 = world
}

切片數據結構在內部存儲了指向起始位置的引用和一個描述切片長度的字段,所以在上面的示例中,s2 是一個指向變量 s 第 7 個字節並且長度爲 5 的切片。

Rust 的範圍語法 .. 有一個小小的語法糖:當你希望範圍從第一個元素(也就是索引值爲 0 的元素)開始時,則可以省略兩個點號之前的值;同樣地,假如你的切片想要包含 String 中的最後一個字節,你也可以省略雙點號之後的值;你甚至可以同時省略首尾的兩個值,來創建一個指向整個字符串所有字節的切片。

字符串切片的邊界必須位於有效的 UTF-8 字符邊界內,嘗試從一個多字節字符的中間位置創建字符串切片會導致運行時錯誤。但爲了將問題簡化,我們這裏只使用 ASCII 字符集,至於 Unicode 後續討論。

基於所學到的這些知識,讓我們開始重構 first_word 函數吧!該函數可以返回一個切片作爲結果。另外,字符串切片的類型寫作 &str。

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();
    for (index, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[.. index];
        }
    }
    &s[..]
}

fn main() {
    let s = String::from("hello world");
    println!("{}", first_word(&s));  // hello
}

調用新的 first_word 函數會返回一個與底層數據緊密聯繫的切片作爲結果,它由指向起始位置的引用和描述元素長度的字段組成。當然,我們也可以用同樣的方式重構 second_word 函數。

由於編譯器會確保指向 String 的引用持續有效,所以我們新設計的接口變得更加健壯且直觀了。還記得之前故意構造出的錯誤嗎?那段代碼在搜索完成並保存索引後清空了字符串的內容,這使得我們存儲的索引不再有效。因此它在邏輯上明顯是有問題的,卻不會觸發任何編譯錯誤,這個問題只會在使用第一個單詞的索引去讀取空字符串時暴露出來,而切片的引入使我們可以在開發早期快速地發現此類錯誤。

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();
    for (index, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[.. index];
        }
    }
    &s[..]
}

fn main() {
    let mut s = String::from("hello world");
    let word = first_word(&s);
    s.clear();
    println!("{}", word); 
}

上述代碼執行會報錯:

錯誤很明顯,s 已經作爲不可變引用被借用了,因此不能再作爲可變引用被借用。

那麼問題來了,s 作爲不可變引用借給誰了呢?顯然是 word,因爲它是字符串切片,是指向字符串的不可變引用;然後又是誰想要借 s 的可變引用呢?顯然是 s.clear(),由於 clear 需要截斷當前的 String 實例,所以調用 clear 需要傳入一個可變引用。

因此最終編譯失敗,所以 Rust 不僅使我們的 API 更加易用,它還在編譯過程中幫助我們避免了此類錯誤。

字符串字面量就是切片

let s = "hello world";

在這裏,變量 s 的類型其實就是 &str:它是一個指向二進制程序特定位置的切片。正是由於 & str 是一個不可變的引用,所以字符串字面量是不可變的。

字符串切片作爲參數

既然我們可以分別創建字符串字面量和 String 的切片,那麼就能夠進一步優化 first_word 函數的接口,下面是它目前的簽名:

fn first_word(s: &String) -> &str

比較有經驗的 Rust 開發者往往會採用下面的寫法,這種改進後的簽名使得函數可以同時處理 &String 與 &str:

fn first_word(s: &str) -> &str

總結:當函數參數類型爲 &String,那麼只能傳 String 的引用,不可以傳切片;如果參數類型爲 &str,那麼既可以傳 String 的引用,也可以傳切片。說白了,在 String 類型的值前面加上一個 & 就表示 String 的引用(&String),而在引用的基礎之上,在後面再加上 [..],那麼就表示字符串切片(&str)。

let s1 = String::from("hello world");
// 合法,&str 支持字符串引用
let s2: &str = &s1;  
// 合法,&str 支持字符串切片,因爲本身就是字符串切片類型
let s2: &str = &s1[..];  
// 合法,字符串字面量本身就是一個不可變的字符串切片
let s2: &str = "hello world";  
// 以上三者等價,因爲 &str 既可以接收 &String,也可以接收 &str

let s3: &String = &s1;  // 合法
let s3: &String = &s1[..];  // 不合法
let s3: &String = "hello world";  // 不合法
// 因爲 &String 只能接收 &String,不能接收 &str

// 最後,字符串切片雖然能接收 String 的引用,但 String 是無法接收的
// 不合法,&str 只能接收 &str、&String,無法接收 String
let s2: &str = s1;

因此我們在設計函數時,使用字符串切片來代替字符串引用會使我們的 API 更加通用,且不會損失任何功能。

其它類型的切片

從名字上就可以看出來,字符串切片是專門用於字符串的。但實際上,Rust 還有其他更加通用的切片類型,以下面的數組爲例:

let a = [1, 2, 3, 4, 5];

就像我們想要引用字符串的某個部分一樣,你也可能會希望引用數組的某個部分。這時,我們可以這樣做:

let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];

這裏的切片類型是 &[i32],它在內部存儲了一個指向起始元素的引用及長度,這與字符串切片的工作機制完全一樣。並且我們將在各種各樣的集合中接觸到此類切片,而在後續討論動態數組時再來介紹那些常用的集合。

最後很多人對引用和切片還是有點雲裏霧裏的,我們後面會繼續重點介紹,現在就先留一個伏筆。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AoYKF1SJy1H7E6Zqd2adEg