ch08-02-strings.md
commit db403a8bdfe5223d952737f54b0d9651b3e6ae1d
第四章已經(jīng)講過一些字符串的內容,不過現(xiàn)在讓我們更深入地了解它。字符串是新晉 Rustacean 們通常會被困住的領域,這是由于三方面理由的結合:Rust 傾向于確保暴露出可能的錯誤,字符串是比很多程序員所想象的要更為復雜的數(shù)據(jù)結構,以及 UTF-8。所有這些要素結合起來對于來自其他語言背景的程序員就可能顯得很困難了。
在集合章節(jié)中討論字符串的原因是,字符串就是作為字節(jié)的集合外加一些方法實現(xiàn)的,當這些字節(jié)被解釋為文本時,這些方法提供了實用的功能。在這一部分,我們會講到 String
中那些任何集合類型都有的操作,比如創(chuàng)建、更新和讀取。也會討論 String
與其他集合不一樣的地方,例如索引 String
是很復雜的,由于人和計算機理解 String
數(shù)據(jù)方式的不同。
在開始深入這些方面之前,我們需要討論一下術語 字符串 的具體意義。Rust 的核心語言中只有一種字符串類型:字符串slice str
,它通常以被借用的形式出現(xiàn),&str
。第四章講到了 字符串 slices:它們是一些對儲存在別處的 UTF-8 編碼字符串數(shù)據(jù)的引用。舉例來說,由于字符串字面值被儲存在程序的二進制輸出中,因此字符串字面值也是字符串slices。
稱作 String
的類型是由標準庫提供的,而沒有寫進核心語言部分,它是可增長的、可變的、有所有權的、UTF-8 編碼的字符串類型。當 Rustacean 們談到 Rust 的 “字符串”時,它們通常指的是 String
或字符串slice &str
類型,而不特指其中某一個。雖然本部分內容大多是關于 String
的,不過這兩個類型在 Rust 標準庫中都被廣泛使用,String
和字符串 slices 都是 UTF-8 編碼的。
很多 Vec
可用的操作在 String
中同樣可用,從以 new
函數(shù)創(chuàng)建字符串開始,如示例 8-11 所示。
let mut s = String::new();
示例 8-11:新建一個空的 String
這新建了一個叫做 s
的空的字符串,接著我們可以向其中裝載數(shù)據(jù)。通常字符串會有初始數(shù)據(jù),因為我們希望一開始就有這個字符串。為此,可以使用 to_string
方法,它能用于任何實現(xiàn)了 Display
trait 的類型,字符串字面值也實現(xiàn)了它。示例 8-12 展示了兩個例子。
let data = "initial contents";
let s = data.to_string();
// 該方法也可直接用于字符串字面值:
let s = "initial contents".to_string();
示例 8-12:使用 to_string
方法從字符串字面值創(chuàng)建 String
這些代碼會創(chuàng)建包含 initial contents
的字符串。
也可以使用 String::from
函數(shù)來從字符串字面值創(chuàng)建 String
。示例 8-13 中的代碼等同于使用 to_string
。
let s = String::from("initial contents");
示例 8-13:使用 String::from
函數(shù)從字符串字面值創(chuàng)建 String
因為字符串應用廣泛,這里有很多不同的用于字符串的通用 API 可供選擇。其中一些可能看起來多余,不過都有其用武之地!在這個例子中,String::from
和 .to_string
最終做了完全相同的工作,所以如何選擇就是代碼風格與可讀性的問題了。
記住字符串是 UTF-8 編碼的,所以可以包含任何可以正確編碼的數(shù)據(jù),如示例 8-14 所示。
let hello = String::from("?????? ?????");
let hello = String::from("Dobry den");
let hello = String::from("Hello");
let hello = String::from("???????");
let hello = String::from("??????");
let hello = String::from("こんにちは");
let hello = String::from("?????");
let hello = String::from("你好");
let hello = String::from("Olá");
let hello = String::from("Здравствуйте");
let hello = String::from("Hola");
示例 8-14:在字符串中儲存不同語言的問候語
所有這些都是有效的 String
值。
String
的大小可以增加,其內容也可以改變,就像可以放入更多數(shù)據(jù)來改變 Vec
的內容一樣。另外,可以方便的使用 +
運算符或 format!
宏來拼接 String
值。
可以通過 push_str
方法來附加字符串 slice,從而使 String
變長,如示例 8-15 所示。
let mut s = String::from("foo");
s.push_str("bar");
示例 8-15:使用 push_str
方法向 String
附加字符串 slice
執(zhí)行這兩行代碼之后,s
將會包含 foobar
。push_str
方法采用字符串 slice,因為我們并不需要獲取參數(shù)的所有權。例如,示例 8-16 中我們希望在將 s2
的內容附加到 s1
之后還能使用它。
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {}", s2);
示例 8-16:將字符串 slice 的內容附加到 String
后使用它
如果 push_str
方法獲取了 s2
的所有權,就不能在最后一行打印出其值了。好在代碼如我們期望那樣工作!
push
方法被定義為獲取一個單獨的字符作為參數(shù),并附加到 String
中。示例 8-17 展示了使用 push
方法將字母 "l" 加入 String
的代碼。
let mut s = String::from("lo");
s.push('l');
示例 8-17:使用 push
將一個字符加入 String
值中
執(zhí)行這些代碼之后,s
將會包含 “l(fā)ol”。
通常你會希望將兩個已知的字符串合并在一起。一種辦法是像這樣使用 +
運算符,如示例 8-18 所示。
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意 s1 被移動了,不能繼續(xù)使用
示例 8-18:使用 +
運算符將兩個 String
值合并到一個新的 String
值中
執(zhí)行完這些代碼之后,字符串 s3
將會包含 Hello, world!
。s1
在相加后不再有效的原因,和使用 s2
的引用的原因,與使用 +
運算符時調用的函數(shù)簽名有關。+
運算符使用了 add
函數(shù),這個函數(shù)簽名看起來像這樣:
fn add(self, s: &str) -> String {
這并不是標準庫中實際的簽名;標準庫中的 add
使用泛型定義。這里我們看到的 add
的簽名使用具體類型代替了泛型,這也正是當使用 String
值調用這個方法會發(fā)生的。第十章會討論泛型。這個簽名提供了理解 +
運算那微妙部分的線索。
首先,s2
使用了 &
,意味著我們使用第二個字符串的 引用 與第一個字符串相加。這是因為 add
函數(shù)的 s
參數(shù):只能將 &str
和 String
相加,不能將兩個 String
值相加。不過等一下 —— 正如 add
的第二個參數(shù)所指定的,&s2
的類型是 &String
而不是 &str
。那么為什么示例 8-18 還能編譯呢?
之所以能夠在 add
調用中使用 &s2
是因為 &String
可以被 強轉(coerced)成 &str
。當add
函數(shù)被調用時,Rust 使用了一個被稱為 Deref 強制轉換(deref coercion)的技術,你可以將其理解為它把 &s2
變成了 &s2[..]
。第十五章會更深入的討論 Deref 強制轉換。因為 add
沒有獲取參數(shù)的所有權,所以 s2
在這個操作后仍然是有效的 String
。
其次,可以發(fā)現(xiàn)簽名中 add
獲取了 self
的所有權,因為 self
沒有 使用 &
。這意味著示例 8-18 中的 s1
的所有權將被移動到 add
調用中,之后就不再有效。所以雖然 let s3 = s1 + &s2;
看起來就像它會復制兩個字符串并創(chuàng)建一個新的字符串,而實際上這個語句會獲取 s1
的所有權,附加上從 s2
中拷貝的內容,并返回結果的所有權。換句話說,它看起來好像生成了很多拷貝,不過實際上并沒有:這個實現(xiàn)比拷貝要更高效。
如果想要級聯(lián)多個字符串,+
的行為就顯得笨重了:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
這時 s
的內容會是 “tic-tac-toe”。在有這么多 +
和 "
字符的情況下,很難理解具體發(fā)生了什么。對于更為復雜的字符串鏈接,可以使用 format!
宏:
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2, s3);
這些代碼也會將 s
設置為 “tic-tac-toe”。format!
與 println!
的工作原理相同,不過不同于將輸出打印到屏幕上,它返回一個帶有結果內容的 String
。這個版本就好理解的多,宏 format!
生成的代碼使用引用所以不會獲取任何參數(shù)的所有權。
在很多語言中,通過索引來引用字符串中的單獨字符是有效且常見的操作。然而在 Rust 中,如果你嘗試使用索引語法訪問 String
的一部分,會出現(xiàn)一個錯誤。考慮一下如示例 8-19 中所示的無效代碼。
let s1 = String::from("hello");
let h = s1[0];
示例 8-19:嘗試對字符串使用索引語法
這段代碼會導致如下錯誤:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
--> src/main.rs:3:13
|
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error
錯誤和提示說明了全部問題:Rust 的字符串不支持索引。那么接下來的問題是,為什么不支持呢?為了回答這個問題,我們必須先聊一聊 Rust 是如何在內存中儲存字符串的。
String
是一個 Vec<u8>
的封裝。讓我們看看示例 8-14 中一些正確編碼的字符串的例子。首先是這一個:
let hello = String::from("Hola");
在這里,len
的值是 4 ,這意味著儲存字符串 “Hola” 的 Vec
的長度是四個字節(jié):這里每一個字母的 UTF-8 編碼都占用一個字節(jié)。那下面這個例子又如何呢?(注意這個字符串中的首字母是西里爾字母的 Ze 而不是阿拉伯數(shù)字 3 。)
let hello = String::from("Здравствуйте");
當問及這個字符是多長的時候有人可能會說是 12。然而,Rust 的回答是 24。這是使用 UTF-8 編碼 “Здравствуйте” 所需要的字節(jié)數(shù),這是因為每個 Unicode 標量值需要兩個字節(jié)存儲。因此一個字符串字節(jié)值的索引并不總是對應一個有效的 Unicode 標量值。作為演示,考慮如下無效的 Rust 代碼:
let hello = "Здравствуйте";
let answer = &hello[0];
我們已經(jīng)知道 answer
不是第一個字符 З
。當使用 UTF-8 編碼時,З
的第一個字節(jié) 208
,第二個是 151
,所以 answer
實際上應該是 208
,不過 208
自身并不是一個有效的字母。返回 208
可不是一個請求字符串第一個字母的人所希望看到的,不過它是 Rust 在字節(jié)索引 0 位置所能提供的唯一數(shù)據(jù)。用戶通常不會想要一個字節(jié)值被返回,即便這個字符串只有拉丁字母: 即便 &"hello"[0]
是返回字節(jié)值的有效代碼,它也應當返回 104
而不是 h
。
為了避免返回意外的值并造成不能立刻發(fā)現(xiàn)的 bug,Rust 根本不會編譯這些代碼,并在開發(fā)過程中及早杜絕了誤會的發(fā)生。
這引起了關于 UTF-8 的另外一個問題:從 Rust 的角度來講,事實上有三種相關方式可以理解字符串:字節(jié)、標量值和字形簇(最接近人們眼中 字母 的概念)。
比如這個用梵文書寫的印度語單詞 “??????”,最終它儲存在 vector 中的 u8
值看起來像這樣:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
這里有 18 個字節(jié),也就是計算機最終會儲存的數(shù)據(jù)。如果從 Unicode 標量值的角度理解它們,也就像 Rust 的 char
類型那樣,這些字節(jié)看起來像這樣:
['?', '?', '?', '?', '?', '?']
這里有六個 char
,不過第四個和第六個都不是字母,它們是發(fā)音符號本身并沒有任何意義。最后,如果以字形簇的角度理解,就會得到人們所說的構成這個單詞的四個字母:
["?", "?", "??", "??"]
Rust 提供了多種不同的方式來解釋計算機儲存的原始字符串數(shù)據(jù),這樣程序就可以選擇它需要的表現(xiàn)方式,而無所謂是何種人類語言。
最后一個 Rust 不允許使用索引獲取 String
字符的原因是,索引操作預期總是需要常數(shù)時間 (O(1))。但是對于 String
不可能保證這樣的性能,因為 Rust 必須從開頭到索引位置遍歷來確定有多少有效的字符。
索引字符串通常是一個壞點子,因為字符串索引應該返回的類型是不明確的:字節(jié)值、字符、字形簇或者字符串 slice。因此,如果你真的希望使用索引創(chuàng)建字符串 slice 時,Rust 會要求你更明確一些。為了更明確索引并表明你需要一個字符串 slice,相比使用 []
和單個值的索引,可以使用 []
和一個 range 來創(chuàng)建含特定字節(jié)的字符串 slice:
let hello = "Здравствуйте";
let s = &hello[0..4];
這里,s
會是一個 &str
,它包含字符串的頭四個字節(jié)。早些時候,我們提到了這些字母都是兩個字節(jié)長的,所以這意味著 s
將會是 “Зд”。
如果獲取 &hello[0..1]
會發(fā)生什么呢?答案是:Rust 在運行時會 panic,就跟訪問 vector 中的無效索引時一樣:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
你應該小心謹慎的使用這個操作,因為這么做可能會使你的程序崩潰。
操作字符串每一部分的最好的方法是明確表示需要字符還是字節(jié)。對于單獨的 Unicode 標量值使用 chars
方法。對 “??????” 調用 chars
方法會將其分開并返回六個 char
類型的值,接著就可以遍歷其結果來訪問每一個元素了:
for c in "??????".chars() {
println!("{}", c);
}
這些代碼會打印出如下內容:
?
?
?
?
?
?
另外 bytes
方法返回每一個原始字節(jié),這可能會適合你的使用場景:
for b in "??????".bytes() {
println!("{}", b);
}
這些代碼會打印出組成 String
的 18 個字節(jié):
224
164
// --snip--
165
135
不過請記住有效的 Unicode 標量值可能會由不止一個字節(jié)組成。
從字符串中獲取字形簇是很復雜的,所以標準庫并沒有提供這個功能。crates.io 上有些提供這樣功能的 crate。
總而言之,字符串還是很復雜的。不同的語言選擇了不同的向程序員展示其復雜性的方式。Rust 選擇了以準確的方式處理 String
數(shù)據(jù)作為所有 Rust 程序的默認行為,這意味著程序員們必須更多的思考如何預先處理 UTF-8 數(shù)據(jù)。這種權衡取舍相比其他語言更多的暴露出了字符串的復雜性,不過也使你在開發(fā)生命周期后期免于處理涉及非 ASCII 字符的錯誤。
現(xiàn)在讓我們轉向一些不太復雜的集合:哈希 map!
更多建議: