使用字符串存儲 UTF-8 編碼文本
我們在第4章討論過字符串,但現在將更深入地探討它們。新手 Rustacean 常常因為三個原因而卡在字符串上:Rust 傾向于暴露可能的錯誤、字符串比許多程序員想象的要復雜得多,以及 UTF-8。這些因素結合起來,對于來自其他編程語言的人來說,可能顯得很難理解。
我們在集合的上下文中討論字符串,因為字符串是作為字節集合實現的,并附帶一些方法,當這些字節被解釋為文本時提供有用功能。在本節中,我們將談論 String 上每個集合類型都有的操作,如創建、更新和讀取。我們還會討論 String 與其他集合不同之處,即索引一個 String 時,由于人類和計算機對 String 數據解釋方式不同,這一過程變得復雜。
什么是字符串?
首先定義“字符串”這個術語。Rust 核心語言只有一種字符串類型,即通常以借用形式 &str 出現的字符串切片 str。在第4章,我們講過字符串切片,它們是對存儲在別處某些 UTF-8 編碼數據的引用。例如,字符串字面量存儲在程序二進制文件中,因此它們就是字符串切片。
String 類型由 Rust 標準庫提供,而非內置核心語言,是一種可增長、可變、有所有權且采用 UTF-8 編碼的字符串類型。當 Rustacean 提到 Rust 中“strings”時,他們可能指的是 String 或者 字符串切片 &str 兩種類型中的任意一種,而不僅僅是一種。雖然本節主要講解 String,但這兩種類型都廣泛用于標準庫,而且都是 UTF-8 編碼。
創建新的 String
許多 Vec 可用操作同樣適用于 String,因為實際上,String 是圍繞字節向量封裝的一層包裝器,并增加了一些額外保證、限制和能力。例如,用來創建實例的新函數 new,在 Vec 和 String 中工作方式相同,如清單 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
這段代碼生成包含初始內容的 string。
我們也可以使用函數 String::from
從字符字面量創建一個 String
。如清單 8-13,其代碼等價于使用 to_string
的版本:
let s = String::from("initial contents");
清單 8-13:利用 String::from
函數從字符字面量構造 String
由于 strings 用途廣泛,可以通過很多通用 API 操作它們,給開發者提供大量選擇。有些看似冗余,但各有其用途!這里,String::from
和 to_string
功能相同,你選哪個取決于風格與可讀性。
記住,strings 是 UTF-8 編碼,所以你可以包含任何正確編碼的數據,如下例(見清單 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 ("Здравствуйте") ;lethello=Str ing :: from ("Hola") ;
清單 8-14 : 將不同語言問候語存入 strings
以上均為有效的 string
值。
更新一個 string
像 Vec 一樣,一個 string 可以增長并改變其內容,只要你往里面推送更多數據。此外,還可以方便地用 + 運算符或 format! 宏連接多個 string 值。
通過 push_str 和 push 向 string 添加內容
我們可以調用 push_str 方法追加一個 string 切片,從而擴展已有 string,如下(見清單8-15):
let mut s = String::from("foo");s.push_str("bar");
清 單8-15 : 使用push_str方法把string slice添加到string后
執行完上述兩行后,s就成了foobar 。push_str 接受參數為string slice ,因為不一定需要取得參數所有權。例如,下面代碼(見 清 單8-16)希望追加完之后還能繼續訪問s2 :
let mut s1 = String::from("foo");let s2 = "bar";s1.push_str(s2);println!("s2 is {s2}");
清 單8-16 : 在追加后仍然能訪問原來的slice
如果push_str取得了s2'所有權,那么最后一行打印
s2’值就無法成功。但實際運行結果符合預期!
push 方法接受的是 char 類型參數,將該字符添加至末尾。如以下例子(見 清 單8-17 )把’l’加到了 ‘lo’:
let mut s = String::from("lo");s.push('l');
清 單8-17 : 用push給string添加1個char
結果s == lol
.
+運算符或format!宏進行拼接
經常需要合并兩個已存在 strings,一種做法是 + 運算符,例如(見 清 單8-18 ):
let s1 = String::from("Hello, ");let s2 = String::from("world!");let s3 = s1 + &s2; // 注意:此處移動了's1',不能再用了。
清 單8-18 : 利用+運算符合并兩個Strings得到新值
變量s3== Hello, world!
. 為什么`s1’失效?為什么傳遞的是&s2?這是因為 + 調用了 add 函數,其簽名大致如下:
fn add(self, s: &str) -> String {
標準庫里add定義較復雜,這里簡化說明。當調用add時,第2個參數必須是&str,不支持兩個完整 Strings 相加;但&s2 實際上是 &St ring ,為何編譯沒錯呢?
答案是在調用 add 時發生了解引用強制轉換(deref coercion),即把 &S tring 轉換成對應范圍內(&[…]) 的&st r . 我們將在第15章詳細介紹deref coercion 。此外,由于是引用傳參,沒有轉移所有權,所以$s2依舊有效。而 self 參數沒有 &, 表明擁有self所有權,也就是說 $S1 被移動進add 調用了。因此表達式看似復制兩次其實只移動一次,更高效無冗余拷貝.
當需拼接多個 strings 時,+ 會讓表達式變得難懂,例如:
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}");
同樣賦值$tic-tac-toe. format! 類似println!, 不過不是輸出屏幕,而返回含格式化內容的新 String 。且內部采用引用,不轉移參數所有權,使代碼更易讀、更安全.
字符串索引
在許多其他編程語言中,通過索引訪問字符串中的單個字符是一種有效且常見的操作。然而,如果你嘗試在 Rust 中使用索引語法訪問 String 的部分內容,會得到一個錯誤。請看清單 8-19 中的無效代碼。
let s1 = String::from("hi");
let h = s1[0];
清單 8-19:嘗試對 String 使用索引語法
這段代碼會產生如下錯誤:
$ cargo runCompiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`--> src/main.rs:3:16|
3 | let h = s1[0];| ^ string indices are ranges of `usize`|= note: you can use `.chars().nth()` or `.bytes().nth()`for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>= help: the trait `SliceIndex<str>` is not implemented for `{integer}`but trait `SliceIndex<[_]>` is implemented for `usize`= help: for that trait implementation, expected `[_]`, found `str`= note: required for `String` to implement `Index<{integer}>`For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
錯誤和提示說明了問題所在:Rust 字符串不支持索引。但為什么呢?要回答這個問題,我們需要討論 Rust 如何在內存中存儲字符串。
內部表示
String 是 Vec<u8>
的封裝。讓我們看看之前 UTF-8 編碼正確的示例字符串(清單 8-14)中的一些例子。首先是:
let hello = String::from("Hola");
此時,len 為4,意味著存儲字符串 “Hola” 的向量長度為4字節。每個字母用 UTF-8 編碼時占用一個字節。然而,下面這一行可能會讓你感到驚訝(注意該字符串以大寫西里爾字母 Ze 開頭,而不是數字3):
let hello = String::from("Здравствуйте");
如果被問及這個字符串有多長,你可能會說12。但實際上,Rust 給出的答案是24:這是“Здравствуйте”用 UTF-8 編碼所需的字節數,因為該字符串中每個 Unicode 標量值占用2個字節。因此,對字符串按字節進行索引并不總能對應到有效的 Unicode 標量值。例如,請看以下無效 Rust 示例代碼:
let hello = "Здравствуйте";
let answer = &hello[0];
你已經知道 answer 不會是第一個字符 З。當以 UTF-8 編碼時,З 的第一個字節是208,第二個是151,所以似乎 answer 應該返回208,但208本身不是有效字符。如果用戶請求獲取這個字符串的第一個字符,他們通常不會想要得到208這樣的原始字節;然而,這正是 Rust 在 byte index=0 時擁有的數據。如果&“hi”[0] 是合法代碼且返回的是原始字節,它將返回104而非’h’。
因此,為避免返回意外值并導致難以發現的 bug,Rust 干脆不允許編譯這類代碼,從開發初期就防止誤解發生。
字節、標量值與圖形簇!哎呀!
關于 UTF-8,還有一點需要注意的是,從 Rust 的角度來看,有三種相關方式來觀察字符串:作為字節、標量值以及圖形簇(最接近我們所謂“字符”的概念)。
例如印地語詞 “??????”,它使用天城文書寫,在計算機中被存儲為 u8 向量,如下所示:
[224,164,168,224,164,174,224,164,184,224,165,141,
224,164,164,
224,165 ,135]
共18個字節,這是計算機最終如何保存這些數據。如果把它們視作 Unicode 標量值,也就是 Rust 中 char 類型,這些 bytes 對應于:
[‘?’, ‘?’, ‘?’, ‘?’, ‘?’, ‘?’]
這里有6個 char 值,但第四和第六不是獨立意義上的“字符”:它們是不完整不能獨立存在的變音符號。最后,如果從圖形簇角度看,則相當于人們認知中的四個印地文字母組成詞匯:
[“?”, “?”, “??”, “??”]
Rust 提供不同方法解釋底層原始數據,使程序可以根據需求選擇合適的人類語言處理方式。
另一個原因是不允許通過索引直接獲取某一位置上的字符,是因為期望所有索引操作都能保證常數時間復雜度(O(1))。但對于 String 來說無法保證這一點,因為必須從開頭遍歷直到目標位置才能確定有多少有效字符。
切片 Strings
對 string 索引用途往往不好界定——到底應該返回什么類型?是單一 byte 值、char 字符、圖形簇還是 string slice?如果確實需要基于范圍創建切片,那么 Rust 要求更明確指定范圍,而非簡單數字下標,例如:
let hello ="Здравствуйте";let s =&hello[0..4];
此處 s 是包含前四個 bytes 的 &str 切片。如前所述,每兩個 bytes 表示一個 character,因此s 包含 “Зд”。
若嘗試只截取部分 character 所占 bytes,比如 &hello[0…1] ,則運行時會 panic,就像 vector 越界一樣報錯:
$ cargo run Compiling collections v0.1.0 (file:///projects/collections) Finished dev profile [unoptimized + debuginfo] target(s) in 0.43s Running target/debug/collections thread ‘main’ panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside ‘З’ (bytes 0..2) of ‘Здравствуйте’
note : run with RUST_BACKTRACE=1 environment variable to display a backtrace
因此,在使用范圍創建 string slices 時務必小心,否則程序可能崩潰。
迭代 Strings 方法
處理 strings 最好明確自己想要的是 characters 或者 bytes 。針對單獨 Unicode 標量,可以調用 chars 方法。“Зд”.chars() 會分離出兩個 char 類型元素,可迭代訪問:
for c in "Зд".chars() {println!("{c}");
}
輸出結果為:
З
д
另外也可調用 bytes 返回每個位元組(byte),適用于特定場景:
for b in "Зд".bytes() {println!("{b}");
}
輸出四個位元組(bytes):
208
151
208
180
但請記住,有效 Unicode 標量可能由多個 byte 構成,不可簡單按 byte 操作替代 char 。
由于提取如天城文等復雜腳本中的 grapheme clusters 較困難,此功能未納入標準庫。如需此功能,可查找 crates.io 上相關第三方庫實現。
Strings 并非那么簡單
總結來說,string 很復雜,不同語言對此做出了不同設計權衡。Rust 默認要求正確處理 String 數據,這使得程序員必須提前認真考慮如何處理 UTF-8 數據。這雖然暴露了更多細節,卻避免了后續因非 ASCII 字符帶來的潛在錯誤風險。
好消息是標準庫提供大量基于 String 和 &str 類型的方法幫助正確應對這些復雜情況,比如 contains 用于搜索,以及 replace 用于替換子串等,非常實用,請務必查看官方文檔了解詳情。
接下來,讓我們轉向稍微簡單一點的話題:哈希映射(hash maps)!