切片型別
切片(slice) 讓你可以參考一串集合中的元素序列,而並非參考整個集合。切片也算是某種類型的參考,所以它沒有所有權。
以下是個小小的程式問題:寫一支函式接收一串用空格分開單字的字串,並回傳第一個找到的單字,如果函式沒有在字串找到空格的話,就代表整個字串就是一個單字,所以就回傳整個字串。
我們先來想看看不使用切片的話,以下函式的簽名會長怎樣。這有助於我們理解切片想解決什麼問題:
fn first_word(s: &String) -> ?
此函式 first_word
有一個參數 &String
。我們不需要取得所有權,所以這是合理的。但我們該回傳啥呢?我們目前還沒有方法能夠描述一個字串的其中一部分。不過我們可以回傳單字的最後一個索引,也就是和空格作比較。讓我們像範例 4-7 這樣試試看。
檔案名稱:src/main.rs
fn first_word(s: &String) -> usize { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return i; } } s.len() } fn main() {}
因為我們需要遍歷 String
的每個元素並檢查該值是否為空格,我們要用 as_bytes
方法將 String
轉換成一個位元組陣列。
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
接下來我們使用 iter
方法對位元組陣列建立一個疊代器(iterator):
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
我們會在第十三章討論疊代器的細節。現在我們只需要知道 iter
是個能夠回傳集合中每個元素的方法,然後 enumerate
會將 iter
的結果包裝起來回傳成元組(tuple)。enumerate
回傳的元組中的第一個元素是索引,第二個才是元素的參考。這樣比我們自己計算索引還來的方便。
既然 enumerate
回傳的是元組,我們可以用模式配對來解構元組。我們會在第六章進一步解釋模式配對。所以在 for
迴圈中,我們指定了一個模式讓 i
取得索引然後 &item
取得元組中的位元組。因為我們從用 .iter().enumerate()
取得參考的,所以在模式中我們用的是 &
來獲取。
在 for
迴圈裡面我們使用字串字面值的語法搜尋位元組是不是空格。如果我們找到空格的話,我們就回傳該位置。不然我們就用 s.len()
回傳整個字串的長度。
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
我們現在有了一個能夠找到字串第一個單字結尾索引的辦法,但還有一個問題。我們回傳的是一個獨立的 usize
,它套用在 &String
身上才有意義。換句話說,因為它是個與 String
沒有直接關係的數值,我們無法保證它在未來還是有效的。參考一下使用了範例 4-7 中函式 first_word
的範例 4-8:
檔案名稱:src/main.rs
fn first_word(s: &String) -> usize { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return i; } } s.len() } fn main() { let mut s = String::from("hello world"); let word = first_word(&s); // word 取得數值 5 s.clear(); // 這會清空 String,這就等於 "" // word 仍然是數值 5 ,但是我們已經沒有相等意義的字串了 // 擁有 5 的變數 word 現在完全沒意義! }
此程式可以成功編譯沒有任何錯誤,而且我們在呼叫 s.clear()
後仍然能使用 word
。因為 word
和 s
並沒有直接的關係,word
在之後仍能繼續保留 5
。我們可以用 s
取得 5
並嘗試取得第一個單字。但這樣就會是程式錯誤了,因為 s
的內容自從我們賦值 5
給 word
之後的內容已經被改變了。
要隨時留意 word
會不會與 s
的資料脫鉤是很煩瑣的且容易出錯!要是我們又寫了個函式 second_word
,管理這些索引會變得非常難以管控!我們會不得不將函式簽名改成這樣:
fn second_word(s: &String) -> (usize, usize) {
現在我們得同時紀錄起始與結束的索引,而且我們還產生了更多與原本數值沒辦法直接相關的計算結果。我們現在有三個非直接相關的變數需要保持同步。
幸運的是 Rust 為此提供了一個解決辦法:字串切片(String slice)。
字串切片
字串切片是 String
其中一部分的參考,它長得像這樣:
fn main() { let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11]; }
與其參考整個 String
,hello
只參考了一部分的String
,透過 [0..5]
來指示。我們可以像這樣 [起始索引..結束索引]
用中括號加上一個範圍來建立切片。起始索引
是切片的第一個位置,而 結束索引
在索引結尾之後的位置(所以不包含此值)。在內部的切片資料結構會儲存起始位置,以及 結束索引
與 起始索引
相減後的長度。所以用 let world = &s[6..11];
作為例子的話, world
就會是個切片,包含一個指標指向索引爲 6 的位元組 s
和一個長度數值 5
。
圖示 4-6 就是此例的示意圖。
要是你想用 Rust 指定範圍的語法 ..
從索引 0 開始的話,你可以省略兩個句點之前的值。換句話說,以下兩個是相等的:
#![allow(unused)] fn main() { let s = String::from("hello"); let slice = &s[0..2]; let slice = &s[..2]; }
同樣地,如果你的切片包含 String
的最後一個位元組的話,你同樣能省略最後一個數值。這代表以下都是相等的:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[3..len]; let slice = &s[3..]; }
如果你要獲取整個字串的切片,你甚至能省略兩者的數值,以下都是相等的:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[0..len]; let slice = &s[..]; }
注意:字串切片的索引範圍必須是有效的 UTF-8 字元界限。如果你嘗試從一個多位元組字元(multibyte character)中產生字串切片,你的程式就會回傳錯誤。為了方便介紹字串切片,本章只使用了 ASCII 字元而已。 我們會在第八章的「使用 String 儲存 UTF-8 編碼的文字」做更詳盡的討論。
有了這些資訊,讓我們用切片來重寫 first_word
吧。對於「字串切片」的回傳型別我們會寫 &str
:
檔案名稱:src/main.rs
fn first_word(s: &String) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..] } fn main() {}
我們如同範例 4-7 一樣用判斷第一個空格取得了單字結尾的索引。當我們找到第一個空格,我們用字串的初始索引與當前空格的索引作為初始與結束索引來回傳字串切片。
現在當我們呼叫 first_word
,我們就會取得一個與原本資料有直接相關的數值。此數值是由切片的起始位置即切片中的元素個數組成。
這樣函式 second_word
一樣也可以回傳切片:
fn second_word(s: &String) -> &str {
我們現在有個不可能出錯且更直觀的 API,因為編譯器會確保 String
的參考會是有效的。還記得我們在範例 4-8 的錯誤嗎?就是那個當我們取得單字結尾索引,但字串卻已清空變成無效的錯誤。那段程式碼邏輯是錯誤的,卻不會馬上顯示錯誤。要是我們持續嘗試用該索引存取空字串的話,問題才會浮現。切片可以讓這樣的程式錯誤無所遁形,並及早讓我們知道我們程式碼有問題。使用切片版本 first_word
的程式碼的話就會出現編譯期錯誤:
檔案名稱:src/main.rs
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // 錯誤!
println!("第一個單字為:{}", word);
}
以下是錯誤訊息:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // 錯誤!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("第一個單字為:{}", word);
| ---- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` due to previous error
回憶一下借用規則,要是我們有不可變參考的話,我們就不能取得可變參考。因為 clear
會縮減 String
,它必須是可變參考。在呼叫 clear
之後的 println!
用到了 word
的參考,所以不可變參考在該處仍必須保持有效。Rust 不允許同時存在 clear
的可變參考與 word
的不可變參考,所以編譯會失敗。Rust 不僅讓我們的 API 更容易使用,還想辦法讓所有錯誤在編譯期就消除!
字串字面值作為切片
回想一下我們講說字串字面值是怎麼存在執行檔的。現在既然我們已經知道切片,我們就能知道更清楚理解字串字面值:
#![allow(unused)] fn main() { let s = "Hello, world!"; }
此處 s
的型別是 &str
:它是指向執行檔某部份的切片。這也是為何字串字面值是不可變的,&str
是個不可變參考。
字串切片作為參數
知道你可以取得字面值的切片與 String
數值後,我們可以再改善一次 first_word
。也就是它的簽名表現:
fn first_word(s: &String) -> &str {
較富有經驗的 Rustacean 會用範例 4-9 的方式編寫函式簽名,因為這讓該函式可以同時接受 &String
和 &str
的數值。
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let my_string = String::from("hello world");
// first_word 適用於 `String` 的切片,無論是部分或整體
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// first_word 也適用於 `String` 的參考,這等同於對整個 `String` 切片的操作。
let word = first_word(&my_string);
let my_string_literal = "hello world";
// first_word 適用於字串字面值,無論是部分或整體
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// 因為字串字面值本來就是切片
// 沒有切片語法也是可行的!
let word = first_word(my_string_literal);
}
如果我們有字串切片的話,我們可以直接傳遞。如果我們有 String
的話,我們可以傳遞此 String
的切片或參考。這樣的彈性用到了強制解參考(deref coercion),這個功能我們會在第十五章的「函式與方法的隱式強制解參考」段落做介紹。
定義函式的參數為字串切片而非 String
可以讓我們的 API 更通用且不會失去任何功能:
檔案名稱:src/main.rs
fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..] } fn main() { let my_string = String::from("hello world"); // first_word 適用於 `String` 的切片,無論是部分或整體 let word = first_word(&my_string[0..6]); let word = first_word(&my_string[..]); // first_word 也適用於 `String` 的參考,這等同於對整個 `String` 切片的操作。 let word = first_word(&my_string); let my_string_literal = "hello world"; // first_word 適用於字串字面值,無論是部分或整體 let word = first_word(&my_string_literal[0..6]); let word = first_word(&my_string_literal[..]); // 因為字串字面值本來就是切片 // 沒有切片語法也是可行的! let word = first_word(my_string_literal); }
其他切片
字串切片如你所想的一樣是特別針對字串的。但是我們還有更通用的切片型別。請考慮以下陣列:
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; }
就像我們參考一部分的字串一樣,我們可以這樣參考一部分的陣列:
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; let slice = &a[1..3]; assert_eq!(slice, &[2, 3]); }
此切片的型別為 &[i32]
,它和字串運作的方式一樣,儲存了切片的第一個元素以及總長度。你以後會對其他集合也使用這樣的切片。我們會在第八章討論這些集合的更多細節。
總結
所有權、借用與切片的概念讓 Rust 可以在編譯時期就確保記憶體安全。Rust 程式語言讓你和其他程式語言一樣控制你的記憶體使用方式,但是會在擁有者離開作用域時自動清除擁有的資料,讓你不必再編寫或除錯額外的程式碼。
所有權影響了 Rust 很多其它部分執行的方式,所以我們在書中之後討論這些概念。讓我們繼續到第五章,看看如何用 struct
將資料組合在一起。