ETH官方钱包

前往
大廳
主題 達(dá)人專欄

Rust 的兩種字串:String 和 &str

解凍豬腳 | 2024-07-26 19:00:04 | 巴幣 112 | 人氣 481

 
本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版。



? String 與 &str

能分清何謂可變、何謂不可變之後,要想理解 Rust 最常見的兩種字串型態(tài)就不那麼困難了。

在一般用例中,Rust 的字串分成兩種型態(tài):String 和 &str。

String 比較接近我們近代程式語言常見的字串,是一個經(jīng)過高度封裝的完整結(jié)構(gòu),可以動態(tài)增減、拼接字串的內(nèi)容:
let mut my_string = String::from("Hello!"); // 如果嫌麻煩可以用 "Hello!".to_string()
my_string += " My name is Johnny."; // 等價於 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 在初始化的時候使用的是 String::from("Hello!") 而非 "Hello!",這兩個東西是不一樣的──前者是完整的 String,而後者正是剛才提及的 &str。當(dāng)我們直接使用雙引號 "Hello!" 來表示字串時,它就是指向一筆靜態(tài)字串資料的參考(因此是 &str),而 String::from 這個函數(shù)則負(fù)責(zé)把 &str 指向的資料複製出來,包裝成一份完整的 String 字串。

由於字串拼接實際上做的是擴充原有的 String 所佔用的記憶體大小,然後把新增的字串資料填進(jìn)去,因此 Rust 內(nèi)建的字串拼接是設(shè)計成 String + &str 的形式,而不是 String + String。如果想要拼接兩個既有的 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}");
}

也就是說,本質(zhì)上 &str 就只是指向一個資料地址、長度,且只能讀不能寫;String 則是自己擁有一份完整的資料,可以讀也可以寫,如此而已。

只要曉得如何在 String 和 &str 之間轉(zhuǎn)換,基本上就有能力處理大部分的情況。



? 字串背後的記憶體行為

然而,若想真正瞭解兩者的性質(zhì)和差異、精準(zhǔn)掌握什麼時候該用 String、什麼時候該用 &str,就得涉及記憶體管理的範(fàn)疇。

首先,程式儲存資料的位置可以粗分為 stack、heap、ROM 三種區(qū)域:
  • stack:存取速度快但空間小,通常我們在程式裡直接宣告的變數(shù)都會被放在 stack 上面
  • heap:存取速度慢一些但能用的空間大,通常在遇到大小不確定或是較大的資料時會存到 heap 上面,比如可變長度的陣列(Vec)
  • ROM:read-only memory,用來存放編譯階段早就已經(jīng)確定並寫死在程式裡的靜態(tài)資料

String 實際上就是一個裝滿 u8 元素的 Vec。

我們現(xiàn)在試著宣告一個 String 字串:
let my_string = String::from("Hello, world!");

當(dāng)我們宣告一個 String 的時候,String 底下的 Vec 會包含三樣?xùn)|西:
  • ptr(pointer):實際資料內(nèi)容的記憶體位址
  • len(length):有效的資料長度
  • cap(capacity):實際被分配的記憶體大小

這三樣?xùn)|西都會被存放在高效的 stack,並且在產(chǎn)生 String 物件時,系統(tǒng)會在 heap 上分配一個長度為 13 的記憶體,把字串的實際資料放進(jìn)去:


這裡的 cap 意義在於,它能夠讓程式的底層機制知道這份資料佔用空間的合法範(fàn)圍。假如我們打算修改資料使之變長(例如我們拼接一段字串上去,讓它的長度從 13 變成 20),那麼系統(tǒng)就需要分配長度至少為 20 的空間給它用,這時候 len 就會變成 20,而 cap 也會變成至少 20。因為分配記憶體空間需要一定的運作開銷,有些情況下系統(tǒng)可能會刻意預(yù)先分配一些額外的記憶體空間(例如只需要 20,但系統(tǒng)分配 32),避免下次被寫入的時候又要大費周章分配記憶體空間,這種時候可能就會有 len 為 20、cap 為 32 的情況。

反之,如果我們把字串的資料抹除,覆蓋新的資料上去,但是新的資料較短:
let mut my_string = String::from("Hello, world!");
my_string.replace_range(.., "Hi");
println!("{my_string}"); // -> Hi

這時候系統(tǒng)就不會重新分配記憶體,而是把 len 的值改為 2:


如此一來,系統(tǒng)就會知道這份資料只有前兩格是有意義的,讀取時只需要拿到前兩格的資料就可以停下來了。

若我們想宣告一個可能會被頻繁修改的字串,而且我們確定範(fàn)圍都在一定值以內(nèi),就可以使用 with_capacity 方法:
let mut my_string = String::with_capacity(1024);

在一些用程式讀寫檔案的例子當(dāng)中,時常會專門宣告一個固定長度的 buffer,把讀到的資料分批寫入 buffer 以後再取出,正是因為這樣可以避免一直重新分配記憶體而拖累速度。

接下來換個情境吧。當(dāng)我們把一個字串寫死在程式裡:
let my_static_str = "Hello, world!";

字串的實際資料會被放在程式的 ROM 區(qū)域:


若我們先產(chǎn)生一個 String,再獲得它的參考:
let my_string = String::from("Hello, world!");
let my_str = my_string.as_ref();

這時候 my_str 就是一個指向 heap 上的數(shù)據(jù)的變數(shù)了:


&str 參考的對象可以是一個 String,也可以是一個被寫死在 ROM 上面的字串。

以上就是 String 和 &str 的差別。

其實上面例子中 my_static_str 的型態(tài),我們可以說它是一個「&'static str」,加上了 'static 標(biāo)籤表示這個字串是從程式執(zhí)行以來就一直存在,直到程式關(guān)閉:
let my_static_str: &'static str = "Hello, world!";

不過這個標(biāo)籤從 2017 年的 Rust 1.17 版本開始就不是必要的東西了,因為就算你省略了 'static 標(biāo)籤,編譯器也可以自動推斷出這個字串是 'static 的,畢竟它本來就是個寫死的字串,而不是從其他地方讀取而來。假如你從某些文獻(xiàn)看到 &'static str 這種形式也不用感到驚慌,只要知道它是寫在 ROM 上面的字串就行,本質(zhì)上還是個 &str。



? 為什麼字串要設(shè)計得這麼囉嗦?全都封裝成 String 不好嗎?

在許多程式語言對字串的實作當(dāng)中,字串底層的資料多半是不可變的。表面上這些字串看起來都可以自由增減、取代,但實際上只要字串被修改了,系統(tǒng)就會在背後重新分配記憶體、重建一個新的字串去取代掉原來的位置,這並不是真正意義上的「字串修改」,它只是語言本身對於這個過程高度抽象化的結(jié)果。

若把全部的字串都封裝得讓開發(fā)者難以微調(diào),這可能就不是 Rust 想要的方向。作為一個系統(tǒng)級的語言,Rust 必須提供選項讓使用者有辦法親自掌握這些細(xì)微的差異,既能選擇使用抽象的高級功能,也能按照需求使用開銷較小的做法,也就有了 String 和 &str 的分別。

除此之外,也有一個理由是基於「所有權(quán)」的設(shè)計。對於 String 變數(shù)和 &str 變數(shù),兩者相差最大的地方就是「一個是真正被擁有的資料,一個是從其他地方借來的」,它們的有效範(fàn)圍和權(quán)限在實務(wù)上有很大的不同。因為篇幅太長,打算之後再把所有權(quán)單獨拿出來說說,畢竟 Rust 也正是透過嚴(yán)格的所有權(quán)系統(tǒng)來阻止?jié)撛诘陌踩珕栴},這不是兩三句就能夠說得完的。

我沒提到的是,其實真要細(xì)分起來的話,Rust 的字串至少還有十種:可以存放非 UTF-8 內(nèi)容的、可以共享所有權(quán)的、可以跨執(zhí)行緒讀取的、固定長度的、被寫入時才複製的、作業(yè)系統(tǒng)專用的、檔案路徑專用的……其中有很多都要具備更多關(guān)於 Rust 的背景知識才能理解,在這之前還是先聚焦在這些背景知識吧。



HackMD 好讀版:https://hackmd.io/@upk1997/rust-strings

縮圖素材原作者:Karen Rustad T?lva(CC0 1.0)
送禮物贊助創(chuàng)作者 !
0
留言

創(chuàng)作回應(yīng)

露米諾斯 Lumynous
這個差別在 C 和 C++ 中也能看到,char const *str = "Hello"; 的 str 指向的字串位於 ROM,如果嘗試修改會是未定義行為,與 &str 相應(yīng),都只是沒有所有權(quán)的引用
2024-07-26 21:49:34
追蹤 創(chuàng)作集

作者相關(guān)創(chuàng)作

相關(guān)創(chuàng)作

更多創(chuàng)作