能分清何謂可變、何謂不可變之後,要想理解 Rust 最常見(jiàn)的兩種字串型態(tài)就不那麼困難了。
在一般用例中,Rust 的字串分成兩種型態(tài):String 和 &str。
String 比較接近我們近代程式語(yǔ)言常見(jiàn)的字串,是一個(gè)經(jīng)過(guò)高度封裝的完整結(jié)構(gòu),可以動(dòng)態(tài)增減、拼接字串的內(nèi)容:
let mut my_string = String::from("Hello!"); // 如果嫌麻煩可以用 "Hello!".to_string() my_string += " My name is Johnny."; // 等價(jià)於 my_string.push_str(" My name is Johnny."); my_string += " Yoroshikuuuuuuuu-"; println!("{my_string}"); my_string = my_string.replace("Johnny", "Pig Knuckle"); println!("{my_string}"); |
你可以從上面的範(fàn)例注意到,my_string 在初始化的時(shí)候使用的是 String::from("Hello!") 而非 "Hello!",這兩個(gè)東西是不一樣的──前者是完整的 String,而後者正是剛才提及的 &str。當(dāng)我們直接使用雙引號(hào) "Hello!" 來(lái)表示字串時(shí),它就是指向一筆靜態(tài)字串資料的參考(因此是 &str),而 String::from 這個(gè)函數(shù)則負(fù)責(zé)把 &str 指向的資料複製出來(lái),包裝成一份完整的 String 字串。
由於字串拼接實(shí)際上做的是擴(kuò)充原有的 String 所佔(zhàn)用的記憶體大小,然後把新增的字串資料填進(jìn)去,因此 Rust 內(nèi)建的字串拼接是設(shè)計(jì)成 String + &str 的形式,而不是 String + String。如果想要拼接兩個(gè)既有的 String,我們就需要用 .as_str() 方法取得它的參考 &str:
let mut my_string = String::from("Hello!"); let new_string_to_add = String::from(" Konnichiwa!"); my_string.push_str(new_string_to_add.as_str()); println!("{my_string}"); |
除此之外,String 切片的參考、&str 切片的參考也都能傳入接受 &str 的函數(shù):
fn main() { let my_string = String::from("Hello!"); print_str(&my_string[1..=4]); let my_static_str = "Hello!"; print_str(&my_static_str[1..=4]); } fn print_str(content: &str) { println!("{content}"); } |
也就是說(shuō),本質(zhì)上 &str 就只是指向一個(gè)資料地址、長(zhǎng)度,且只能讀不能寫(xiě);String 則是自己擁有一份完整的資料,可以讀也可以寫(xiě),如此而已。
只要曉得如何在 String 和 &str 之間轉(zhuǎn)換,基本上就有能力處理大部分的情況。
然而,若想真正瞭解兩者的性質(zhì)和差異、精準(zhǔn)掌握什麼時(shí)候該用 String、什麼時(shí)候該用 &str,就得涉及記憶體管理的範(fàn)疇。
首先,程式儲(chǔ)存資料的位置可以粗分為 stack、heap、ROM 三種區(qū)域:
- stack:存取速度快但空間小,通常我們?cè)诔淌窖e直接宣告的變數(shù)都會(huì)被放在 stack 上面
- heap:存取速度慢一些但能用的空間大,通常在遇到大小不確定或是較大的資料時(shí)會(huì)存到 heap 上面,比如可變長(zhǎng)度的陣列(Vec)
- ROM:read-only memory,用來(lái)存放編譯階段早就已經(jīng)確定並寫(xiě)死在程式裡的靜態(tài)資料
String 實(shí)際上就是一個(gè)裝滿 u8 元素的 Vec。
我們現(xiàn)在試著宣告一個(gè) String 字串:
let my_string = String::from("Hello, world!"); |
當(dāng)我們宣告一個(gè) String 的時(shí)候,String 底下的 Vec 會(huì)包含三樣?xùn)|西:
- ptr(pointer):實(shí)際資料內(nèi)容的記憶體位址
- len(length):有效的資料長(zhǎng)度
- cap(capacity):實(shí)際被分配的記憶體大小
這三樣?xùn)|西都會(huì)被存放在高效的 stack,並且在產(chǎn)生 String 物件時(shí),系統(tǒng)會(huì)在 heap 上分配一個(gè)長(zhǎng)度為 13 的記憶體,把字串的實(shí)際資料放進(jìn)去:
這裡的 cap 意義在於,它能夠讓程式的底層機(jī)制知道這份資料佔(zhàn)用空間的合法範(fàn)圍。假如我們打算修改資料使之變長(zhǎng)(例如我們拼接一段字串上去,讓它的長(zhǎng)度從 13 變成 20),那麼系統(tǒng)就需要分配長(zhǎng)度至少為 20 的空間給它用,這時(shí)候 len 就會(huì)變成 20,而 cap 也會(huì)變成至少 20。因?yàn)榉峙溆洃涹w空間需要一定的運(yùn)作開(kāi)銷(xiāo),有些情況下系統(tǒng)可能會(huì)刻意預(yù)先分配一些額外的記憶體空間(例如只需要 20,但系統(tǒng)分配 32),避免下次被寫(xiě)入的時(shí)候又要大費(fèi)周章分配記憶體空間,這種時(shí)候可能就會(huì)有 len 為 20、cap 為 32 的情況。
反之,如果我們把字串的資料抹除,覆蓋新的資料上去,但是新的資料較短:
let mut my_string = String::from("Hello, world!"); my_string.replace_range(.., "Hi"); println!("{my_string}"); // -> Hi |
這時(shí)候系統(tǒng)就不會(huì)重新分配記憶體,而是把 len 的值改為 2:
如此一來(lái),系統(tǒng)就會(huì)知道這份資料只有前兩格是有意義的,讀取時(shí)只需要拿到前兩格的資料就可以停下來(lái)了。
若我們想宣告一個(gè)可能會(huì)被頻繁修改的字串,而且我們確定範(fàn)圍都在一定值以內(nèi),就可以使用 with_capacity 方法:
let mut my_string = String::with_capacity(1024); |
在一些用程式讀寫(xiě)檔案的例子當(dāng)中,時(shí)常會(huì)專(zhuān)門(mén)宣告一個(gè)固定長(zhǎng)度的 buffer,把讀到的資料分批寫(xiě)入 buffer 以後再取出,正是因?yàn)檫@樣可以避免一直重新分配記憶體而拖累速度。
接下來(lái)?yè)Q個(gè)情境吧。當(dāng)我們把一個(gè)字串寫(xiě)死在程式裡:
let my_static_str = "Hello, world!"; |
字串的實(shí)際資料會(huì)被放在程式的 ROM 區(qū)域:
若我們先產(chǎn)生一個(gè) String,再獲得它的參考:
let my_string = String::from("Hello, world!"); let my_str = my_string.as_ref(); |
這時(shí)候 my_str 就是一個(gè)指向 heap 上的數(shù)據(jù)的變數(shù)了:
&str 參考的對(duì)象可以是一個(gè) String,也可以是一個(gè)被寫(xiě)死在 ROM 上面的字串。
以上就是 String 和 &str 的差別。
其實(shí)上面例子中 my_static_str 的型態(tài),我們可以說(shuō)它是一個(gè)「&'static str」,加上了 'static 標(biāo)籤表示這個(gè)字串是從程式執(zhí)行以來(lái)就一直存在,直到程式關(guān)閉:
let my_static_str: &'static str = "Hello, world!"; |
不過(guò)這個(gè)標(biāo)籤從 2017 年的 Rust 1.17 版本開(kāi)始就不是必要的東西了,因?yàn)榫退隳闶÷粤?'static 標(biāo)籤,編譯器也可以自動(dòng)推斷出這個(gè)字串是 'static 的,畢竟它本來(lái)就是個(gè)寫(xiě)死的字串,而不是從其他地方讀取而來(lái)。假如你從某些文獻(xiàn)看到 &'static str 這種形式也不用感到驚慌,只要知道它是寫(xiě)在 ROM 上面的字串就行,本質(zhì)上還是個(gè) &str。
? 為什麼字串要設(shè)計(jì)得這麼囉嗦?全都封裝成 String 不好嗎?
在許多程式語(yǔ)言對(duì)字串的實(shí)作當(dāng)中,字串底層的資料多半是不可變的。表面上這些字串看起來(lái)都可以自由增減、取代,但實(shí)際上只要字串被修改了,系統(tǒng)就會(huì)在背後重新分配記憶體、重建一個(gè)新的字串去取代掉原來(lái)的位置,這並不是真正意義上的「字串修改」,它只是語(yǔ)言本身對(duì)於這個(gè)過(guò)程高度抽象化的結(jié)果。
若把全部的字串都封裝得讓開(kāi)發(fā)者難以微調(diào),這可能就不是 Rust 想要的方向。作為一個(gè)系統(tǒng)級(jí)的語(yǔ)言,Rust 必須提供選項(xiàng)讓使用者有辦法親自掌握這些細(xì)微的差異,既能選擇使用抽象的高級(jí)功能,也能按照需求使用開(kāi)銷(xiāo)較小的做法,也就有了 String 和 &str 的分別。
除此之外,也有一個(gè)理由是基於「所有權(quán)」的設(shè)計(jì)。對(duì)於 String 變數(shù)和 &str 變數(shù),兩者相差最大的地方就是「一個(gè)是真正被擁有的資料,一個(gè)是從其他地方借來(lái)的」,它們的有效範(fàn)圍和權(quán)限在實(shí)務(wù)上有很大的不同。因?yàn)槠L(zhǎng),打算之後再把所有權(quán)單獨(dú)拿出來(lái)說(shuō)說(shuō),畢竟 Rust 也正是透過(guò)嚴(yán)格的所有權(quán)系統(tǒng)來(lái)阻止?jié)撛诘陌踩珕?wèn)題,這不是兩三句就能夠說(shuō)得完的。
我沒(méi)提到的是,其實(shí)真要細(xì)分起來(lái)的話,Rust 的字串至少還有十種:可以存放非 UTF-8 內(nèi)容的、可以共享所有權(quán)的、可以跨執(zhí)行緒讀取的、固定長(zhǎng)度的、被寫(xiě)入時(shí)才複製的、作業(yè)系統(tǒng)專(zhuān)用的、檔案路徑專(zhuān)用的……其中有很多都要具備更多關(guān)於 Rust 的背景知識(shí)才能理解,在這之前還是先聚焦在這些背景知識(shí)吧。
HackMD 好讀版:https://hackmd.io/@upk1997/rust-strings