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