Rust 使用字符串儲(chǔ)存 UTF-8 編碼的文本

2023-03-22 15:10 更新
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 值。

使用 push_str 和 push 附加字符串

可以通過(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”。

使用 + 運(yùn)算符或 format! 宏拼接字符串

通常你會(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ǔ)存字符串的。

內(nèi)部表現(xiàn)

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ā)生。

字節(jié)、標(biāo)量值和字形簇!天吶!

這引起了關(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)確定有多少有效的字符。

字符串 slice

索引字符串通常是一個(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。

字符串并不簡(jiǎn)單

總而言之,字符串還是很復(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!


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)