能分清何謂可變、何謂不可變之後,要想理解 Rust 最常見的兩種字串型態就不那麼困難了。
在一般用例中,Rust 的字串分成兩種型態:String
和 &str
。
String
比較接近我們近代程式語言常見的字串,是一個經過高度封裝的完整結構,可以動態增減、拼接字串的內容:
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}");
你可以從上面的範例注意到,my_string
在初始化的時候使用的是 String::from("Hello!")
而非 "Hello!"
,這兩個東西是不一樣的──前者是完整的 String
,而後者正是剛才提及的 &str
。當我們直接使用雙引號 "Hello!"
來表示字串時,它就是指向一筆靜態字串資料的參考(因此是 &str
),而 String::from
這個函數則負責把 &str
指向的資料複製出來,包裝成一份完整的 String
字串。
由於字串拼接實際上做的是擴充原有的 String
所佔用的記憶體大小,然後把新增的字串資料填進去,因此 Rust 內建的字串拼接是設計成 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
的函數:
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}");
}
也就是說,本質上 &str
就只是指向一個資料地址、長度,且只能讀不能寫;String
則是自己擁有一份完整的資料,可以讀也可以寫,如此而已。
只要曉得如何在 String
和 &str
之間轉換,基本上就有能力處理大部分的情況。
然而,若想真正瞭解兩者的性質和差異、精準掌握什麼時候該用 String
、什麼時候該用 &str
,就得涉及記憶體管理的範疇。
首先,程式儲存資料的位置可以粗分為 stack、heap、ROM 三種區域:
Vec
String 實際上就是一個裝滿 u8
元素的 Vec
。
我們現在試著宣告一個 String
字串:
let my_string = String::from("Hello, world!");
當我們宣告一個 String
的時候,String
底下的 Vec
會包含三樣東西:
ptr
(pointer):實際資料內容的記憶體位址len
(length):有效的資料長度cap
(capacity):實際被分配的記憶體大小這三樣東西都會被存放在高效的 stack,並且在產生 String
物件時,系統會在 heap 上分配一個長度為 13 的記憶體,把字串的實際資料放進去:
這裡的 cap
意義在於,它能夠讓程式的底層機制知道這份資料佔用空間的合法範圍。假如我們打算修改資料使之變長(例如我們拼接一段字串上去,讓它的長度從 13 變成 20),那麼系統就需要分配長度至少為 20 的空間給它用,這時候 len
就會變成 20,而 cap
也會變成至少 20。因為分配記憶體空間需要一定的運作開銷,有些情況下系統可能會刻意預先分配一些額外的記憶體空間(例如只需要 20,但系統分配 32),避免下次被寫入的時候又要大費周章分配記憶體空間,這種時候可能就會有 len
為 20、cap
為 32 的情況。
反之,如果我們把字串的資料抹除,覆蓋新的資料上去,但是新的資料較短:
let mut my_string = String::from("Hello, world!");
my_string.replace_range(.., "Hi");
println!("{my_string}"); // -> Hi
這時候系統就不會重新分配記憶體,而是把 len
的值改為 2:
如此一來,系統就會知道這份資料只有前兩格是有意義的,讀取時只需要拿到前兩格的資料就可以停下來了。
若我們想宣告一個可能會被頻繁修改的字串,而且我們確定範圍都在一定值以內,就可以使用 with_capacity
方法:
let mut my_string = String::with_capacity(1024);
在一些用程式讀寫檔案的例子當中,時常會專門宣告一個固定長度的 buffer,把讀到的資料分批寫入 buffer 以後再取出,正是因為這樣可以避免一直重新分配記憶體而拖累速度。
接下來換個情境吧。當我們把一個字串寫死在程式裡:
let my_static_str = "Hello, world!";
字串的實際資料會被放在程式的 ROM 區域:
若我們先產生一個 String
,再獲得它的參考:
let my_string = String::from("Hello, world!");
let my_str = my_string.as_ref();
這時候 my_str
就是一個指向 heap 上的數據的變數了:
&str
參考的對象可以是一個 String
,也可以是一個被寫死在 ROM 上面的字串。
以上就是 String
和 &str
的差別。
其實上面例子中 my_static_str
的型態,我們可以說它是一個 &'static str
,加上了 'static 標籤表示這個字串是從程式執行以來就一直存在,直到程式關閉:
let my_static_str: &'static str = "Hello, world!";
不過這個標籤從 2017 年的 Rust 1.17 版本開始就不是必要的東西了,因為就算你省略了 'static 標籤,編譯器也可以自動推斷出這個字串是 'static 的,畢竟它本來就是個寫死的字串,而不是從其他地方讀取而來。假如你從某些文獻看到 &'static str
這種形式也不用感到驚慌,只要知道它是寫在 ROM 上面的字串就行,本質上還是個 &str
。
在許多程式語言對字串的實作當中,字串底層的資料多半是不可變的。表面上這些字串看起來都可以自由增減、取代,但實際上只要字串被修改了,系統就會在背後重新分配記憶體、重建一個新的字串去取代掉原來的位置,這並不是真正意義上的「字串修改」,它只是語言本身對於這個過程高度抽象化的結果。
若把全部的字串都封裝得讓開發者難以微調,這可能就不是 Rust 想要的方向。作為一個系統級的語言,Rust 必須提供選項讓使用者有辦法親自掌握這些細微的差異,既能選擇使用抽象的高級功能,也能按照需求使用開銷較小的做法,也就有了 String
和 &str
的分別。
除此之外,也有一個理由是基於「所有權」的設計。對於 String
變數和 &str
變數,兩者相差最大的地方就是「一個是真正被擁有的資料,一個是從其他地方借來的」,它們的有效範圍和權限在實務上有很大的不同。因為篇幅太長,打算之後再把所有權單獨拿出來說說,畢竟 Rust 也正是透過嚴格的所有權系統來阻止潛在的安全問題,這不是兩三句就能夠說得完的。
我沒提到的是,其實真要細分起來的話,Rust 的字串至少還有十種:可以存放非 UTF-8 內容的、可以共享所有權的、可以跨執行緒讀取的、固定長度的、被寫入時才複製的、作業系統專用的、檔案路徑專用的……其中有很多都要具備更多關於 Rust 的背景知識才能理解,在這之前還是先聚焦在這些背景知識吧。
Rust
程式設計