Rust 語言入門教程 字符串

字符串是新晉 Rustacean 們通常會被困住的領域,這是由於三方面理由的結合:Rust 傾向於確保暴露出可能的錯誤,字符串是比很多程序員所想象的要更爲複雜的數據結構,以及 UTF-8。所有這些要素結合起來對於來自其他語言背景的程序員就可能顯得很困難了。

在集合章節中討論字符串的原因是,字符串就是作爲字節的集合外加一些方法實現的,當這些字節被解釋爲文本時,這些方法提供了實用的功能。在這一部分,我們會講到 String 中那些任何集合類型都有的操作,比如創建、更新和讀取。也會討論 String 與其他集合不一樣的地方,例如索引String 是很複雜的,由於人和計算機理解 String 數據方式的不同。

什麼是字符串?

在開始深入這些方面之前,我們需要討論一下術語 字符串 的具體意義。Rust 的核心語言中只有一種字符串類型:str,字符串 slice,它通常以被借用的形式出現,&str。第四章講到了 字符串 slice:它們是一些儲存在別處的 UTF-8 編碼字符串數據的引用。比如字符串字面值被儲存在程序的二進制輸出中,字符串 slice 也是如此。

稱作 String 的類型是由標準庫提供的,而沒有寫進核心語言部分,它是可增長的、可變的、有所有權的、UTF-8 編碼的字符串類型。當 Rustacean 們談到 Rust 的 “字符串” 時,它們通常指的是 String 和字符串 slice &str 類型,而不僅僅是其中之一。雖然本部分內容大多是關於 String 的,不過這兩個類型在 Rust 標準庫中都被廣泛使用,String 和字符串 slice 都是 UTF-8 編碼的。

Rust 標準庫中還包含一系列其他字符串類型,比如 OsStringOsStrCString 和 CStr。相關庫 crate 甚至會提供更多儲存字符串數據的選擇。看到這些由 String 或是 Str 結尾的名字了嗎?這對應着它們提供的所有權和可借用的字符串變體,就像是你之前看到的 String 和 str。舉例而言,這些字符串類型能夠以不同的編碼,或者內存表現形式上以不同的形式,來存儲文本內容。本章將不會討論其他這些字符串類型,更多有關如何使用它們以及各自適合的場景,請參見其 API 文檔。

新建字符串

很多 Vec 可用的操作在 String 中同樣可用,從以 new 函數創建字符串開始,如示例 8-11 所示。

let mut s = String::new();

示例 8-11:新建一個空的 String

這新建了一個叫做 s 的空的字符串,接着我們可以向其中裝載數據。通常字符串會有初始數據,因爲我們希望一開始就有這個字符串。爲此,可以使用 to_string 方法,它能用於任何實現了 Display trait 的類型,字符串字面值也實現了它。示例 8-12 展示了兩個例子。

let data = "initial contents";

let s = data.to_string();

// 該方法也可直接用於字符串字面值:
let s = "initial contents".to_string();

示例 8-12:使用 to_string 方法從字符串字面值創建 String

這些代碼會創建包含 initial contents 的字符串。

也可以使用 String::from 函數來從字符串字面值創建 String。示例 8-13 中的代碼代碼等同於使用 to_string

let s = String::from("initial contents");

示例 8-13:使用 String::from 函數從字符串字面值創建 String

因爲字符串應用廣泛,這裏有很多不同的用於字符串的通用 API 可供選擇。其中一些可能看起來多餘,不過都有其用武之地!在這個例子中,String::from 和 .to_string 最終做了完全相同的工作,所以如何選擇就是風格問題了。

記住字符串是 UTF-8 編碼的,所以可以包含任何可以正確編碼的數據,如示例 8-14 所示。

let hello = String::from("السلام عليكم");
let hello = String::from("Dobrý 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 的大小可以增加,其內容也可以改變,就像可以放入更多數據來改變 Vec 的內容一樣。另外,可以方便的使用 + 運算符或 format! 宏來拼接 String 值。

使用 push_str 和 push 附加字符串

可以通過 push_str 方法來附加字符串 slice,從而使 String 變長,如示例 8-15 所示。

let mut s = String::from("foo");
s.push_str("bar");

示例 8-15:使用 push_str 方法向 String 附加字符串 slice

執行這兩行代碼之後,s 將會包含 foobarpush_str 方法採用字符串 slice,因爲我們並不需要獲取參數的所有權。例如,示例 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 方法被定義爲獲取一個單獨的字符作爲參數,並附加到 String 中。示例 8-17 展示了使用 push 方法將字母 l 加入 String 的代碼。

let mut s = String::from("lo");
s.push('l');

示例 8-17:使用 push 將一個字符加入 String 值中

執行這些代碼之後,s 將會包含 “lol”。

使用 + 運算符或 format! 宏拼接字符串

通常你會希望將兩個已知的字符串合併在一起。一種辦法是像這樣使用 + 運算符,如示例 8-18 所示。

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意 s1 被移動了,不能繼續使用

示例 8-18:使用 + 運算符將兩個 String 值合併到一個新的 String 值中

執行完這些代碼之後,字符串 s3 將會包含 Hello, world!s1 在相加後不再有效的原因,和使用 s2 的引用的原因,與使用 + 運算符時調用的函數簽名有關。+ 運算符使用了 add 函數,這個函數簽名看起來像這樣:

fn add(self, s: &str) -> String {

這並不是標準庫中實際的簽名;標準庫中的 add 使用泛型定義。這裏我們看到的 add 的簽名使用具體類型代替了泛型,這也正是當使用 String 值調用這個方法會發生的。第十章會討論泛型。這個簽名提供了理解 + 運算那微妙部分的線索。

首先,s2 使用了 &,意味着我們使用第二個字符串的 引用 與第一個字符串相加。這是因爲 add 函數的 s 參數:只能將 &str 和 String 相加,不能將兩個 String 值相加。不過等一下 —— 正如 add 的第二個參數所指定的,&s2 的類型是 &String 而不是 &str。那麼爲什麼示例 8-18 還能編譯呢?

之所以能夠在 add 調用中使用 &s2 是因爲 &String 可以被 強轉(coerced)成 &str。當add函數被調用時,Rust 使用了一個被稱爲 解引用強制多態(deref coercion)的技術,你可以將其理解爲它把 &s2 變成了 &s2[..]。第十五章會更深入的討論解引用強制多態。因爲 add 沒有獲取參數的所有權,所以 s2 在這個操作後仍然是有效的 String

其次,可以發現簽名中 add 獲取了 self 的所有權,因爲 self 沒有 使用 &。這意味着示例 8-18 中的 s1 的所有權將被移動到 add 調用中,之後就不再有效。所以雖然 let s3 = s1 + &s2; 看起來就像它會複製兩個字符串並創建一個新的字符串,而實際上這個語句會獲取 s1 的所有權,附加上從 s2 中拷貝的內容,並返回結果的所有權。換句話說,它看起來好像生成了很多拷貝,不過實際上並沒有:這個實現比拷貝要更高效。

如果想要級聯多個字符串,+ 的行爲就顯得笨重了:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;

這時 s 的內容會是 “tic-tac-toe”。在有這麼多 + 和 " 字符的情況下,很難理解具體發生了什麼。對於更爲複雜的字符串鏈接,可以使用 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。這個版本就好理解的多,並且不會獲取任何參數的所有權。

索引字符串

在很多語言中,通過索引來引用字符串中的單獨字符是有效且常見的操作。然而在 Rust 中,如果你嘗試使用索引語法訪問 String 的一部分,會出現一個錯誤。考慮一下如示例 8-19 中所示的無效代碼。

let s1 = String::from("hello");
let h = s1[0];

示例 8-19:嘗試對字符串使用索引語法

這段代碼會導致如下錯誤:

error[E0277]: the trait bound `std::string::String: std::ops::Index<{integer}>` is not satisfied
 -->
  |
3 |     let h = s1[0];
  |             ^^^^^ the type `std::string::String` cannot be indexed by `{integer}`
  |
  = help: the trait `std::ops::Index<{integer}>` is not implemented for `std::string::String`

錯誤和提示說明了全部問題:Rust 的字符串不支持索引。那麼接下來的問題是,爲什麼不支持呢?爲了回答這個問題,我們必須先聊一聊 Rust 是如何在內存中儲存字符串的。

內部表現

String 是一個 Vec<u8> 的封裝。讓我們看看示例 8-14 中一些正確編碼的字符串的例子。首先是這一個:

let len = String::from("Hola").len();

在這裏,len 的值是 4 ,這意味着儲存字符串 “Hola” 的 Vec 的長度是四個字節:這裏每一個字母的 UTF-8 編碼都佔用一個字節。那下面這個例子又如何呢?(注意這個字符串中的首字母是西裏爾字母的 Ze 而不是阿拉伯數字 3 。)

let len = String::from("Здравствуйте").len();

當問及這個字符是多長的時候有人可能會說是 12。然而,Rust 的回答是 24。這是使用 UTF-8 編碼 “Здравствуйте” 所需要的字節數,這是因爲每個 Unicode 標量值需要兩個字節存儲。因此一個字符串字節值的索引並不總是對應一個有效的 Unicode 標量值。作爲演示,考慮如下無效的 Rust 代碼:

let hello = "Здравствуйте";
let answer = &hello[0];

answer 的值應該是什麼呢?它應該是第一個字符 З 嗎?當使用 UTF-8 編碼時,З 的第一個字節 208,第二個是 151,所以 answer 實際上應該是 208,不過 208 自身並不是一個有效的字母。返回 208 可不是一個請求字符串第一個字母的人所希望看到的,不過它是 Rust 在字節索引 0 位置所能提供的唯一數據。用戶通常不會想要一個字節值被返回,即便這個字符串只有拉丁字母:即便 &"hello"[0] 是返回字節值的有效代碼,它也應當返回 104 而不是 h。爲了避免返回意外的值並造成不能立刻發現的 bug,Rust 根本不會編譯這些代碼,並在開發過程中及早杜絕了誤會的發生。

字節、標量值和字形簇!天吶!

這引起了關於 UTF-8 的另外一個問題:從 Rust 的角度來講,事實上有三種相關方式可以理解字符串:字節、標量值和字形簇(最接近人們眼中 字母 的概念)。

比如這個用梵文書寫的印度語單詞 “नमस्ते”,最終它儲存在 vector 中的 u8 值看起來像這樣:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]

這裏有 18 個字節,也就是計算機最終會儲存的數據。如果從 Unicode 標量值的角度理解它們,也就像 Rust 的 char 類型那樣,這些字節看起來像這樣:

['न', 'म', 'स', '्', 'त', 'े']

這裏有六個 char,不過第四個和第六個都不是字母,它們是發音符號本身並沒有任何意義。最後,如果以字形簇的角度理解,就會得到人們所說的構成這個單詞的四個字母:

["न", "म", "स्", "ते"]

Rust 提供了多種不同的方式來解釋計算機儲存的原始字符串數據,這樣程序就可以選擇它需要的表現方式,而無所謂是何種人類語言。

最後一個 Rust 不允許使用索引獲取 String 字符的原因是,索引操作預期總是需要常數時間 (O(1))。但是對於 String 不可能保證這樣的性能,因爲 Rust 必須從開頭到索引位置遍歷來確定有多少有效的字符。

字符串 slice

索引字符串通常是一個壞點子,因爲字符串索引應該返回的類型是不明確的:字節值、字符、字形簇或者字符串 slice。因此,如果你真的希望使用索引創建字符串 slice 時,Rust 會要求你更明確一些。爲了更明確索引並表明你需要一個字符串 slice,相比使用 [] 和單個值的索引,可以使用 [] 和一個 range 來創建含特定字節的字符串 slice:

let hello = "Здравствуйте";

let s = &hello[0..4];

這裏,s 會是一個 &str,它包含字符串的頭四個字節。早些時候,我們提到了這些字母都是兩個字節長的,所以這意味着 s 將會是 “Зд”。

如果獲取 &hello[0..1] 會發生什麼呢?答案是:Rust 在運行時會 panic,就跟訪問 vector 中的無效索引時一樣:

thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/libcore/str/mod.rs:2188:4

你應該小心謹慎的使用這個操作,因爲這麼做可能會使你的程序崩潰。

遍歷字符串的方法

幸運的是,這裏還有其他獲取字符串元素的方式。

如果你需要操作單獨的 Unicode 標量值,最好的選擇是使用 chars 方法。對 “नमस्ते” 調用 chars 方法會將其分開並返回六個 char 類型的值,接着就可以遍歷其結果來訪問每一個元素了:

for c in "नमस्ते".chars() {
    println!("{}", c);
}

這些代碼會打印出如下內容:

न
म
स
्
त
े

bytes 方法返回每一個原始字節,這可能會適合你的使用場景:

for b in "नमस्ते".bytes() {
    println!("{}", b);
}

這些代碼會打印出組成 String 的 18 個字節:

224
164
// --snip--
165
135

不過請記住有效的 Unicode 標量值可能會由不止一個字節組成。

從字符串中獲取字形簇是很複雜的,所以標準庫並沒有提供這個功能。crates.io 上有些提供這樣功能的 crate。

字符串並不簡單

總而言之,字符串還是很複雜的。不同的語言選擇了不同的向程序員展示其複雜性的方式。Rust 選擇了以準確的方式處理 String 數據作爲所有 Rust 程序的默認行爲,這意味着程序員們必須更多的思考如何預先處理 UTF-8 數據。這種權衡取捨相比其他語言更多的暴露出了字符串的複雜性,不過也使你在開發生命週期後期免於處理涉及非 ASCII 字符的錯誤。

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