Rust程式語言的擁有權概念使它能不使用垃圾回收(Garbage Collection)機制就能保證記憶體的安全性,也是Rust的一大特點,因此瞭解擁有權的工作原理是很重要的。



什麼是「擁有權」?

在開始瞭解「擁有權」之前,我們先來加深一下對於堆疊(stack)和堆積(heap)的認識。

堆疊和堆積分別是指程式在執行階段所用的兩個記憶體區塊。在大部分的程式語言中,我們平常不太需要去思考資料到底是要存在堆疊還是堆積中。但是在像是Rust這樣的系統程式語言,資料存在於堆疊或堆積中所產生的差異還是不容忽視的。

堆疊和堆積有著不一樣的結構。存入堆疊中的資料和從堆疊中拿出的資料順序是相反的,為先進後出(FILO, First-in-Last-out)的性質。將堆疊處理資料的方式想像成是在疊盤子:當我們要增加更多的盤子的時候,會將盤子放在這疊盤子的最上方;而當我們之後需要盤子的時候,就會從這疊盤子的最上方開始拿。將資料加入堆疊中的動作稱作「push」,而將資料從堆疊中拿出的動作稱為「pop」。

堆疊因為其存取資料的方式,所以處理速度很快。在加入資料的時候完全不需要去尋找可用的空間來存放,而取得資料的時候也完全不需要去進行搜尋,因為其關注的位置永遠是在那疊資料的最上方。存入堆疊中的資料大小必須是已知且是固定的。

若是要存的資料無法在編譯階段就決定好大小,或是這筆資料的大小之後可能會有變動,那我們可以將這筆資料放在堆積中。堆積的記憶體區塊相對比較自由,當我們要加入資料的時候,作業系統會去尋找並配置足夠的記憶體空間,然後回傳這塊記憶體空間位置的地址,也就是「指標」(pointer)來給Rust程式使用,這樣的流程稱為「allocating」。指標是一個大小已知且固定的值,可以存入堆疊,在使用時會先從堆疊中取得指標,再依照指標所對應的記憶體位置來存取堆積中實際的資料。

堆積的資料處理方式也可以想像成:我們現在正在一間餐廳裡等待帶位,我們必須要先告訴店家要用餐的人數,店家就會根據我們的人數來選擇適當的桌子,即便我們可能有些人還沒到場,但這個桌子的位置已經都預留給我們這群人使用了,所以後來才到的人,可以根據桌號來找到我們,並且有空位可以直接坐下。

存取堆積的資料會比存取堆疊資料的速度還要來得慢,因為堆積的資料必須要透過指標來取得實際資料存放的位置。試想一下,現在有個服務生要替許多桌的客人點餐,最快速的點餐方式就是各桌的客人一次就將所有的餐點好。如果A桌的人點了一樣餐點,B桌的人點一樣餐點,然後A桌的人又要再點一樣餐點,後來B桌的人也是又要再點一樣,這樣服務生就必須在這兩桌之間跑來跑去,效率就會比較差。此外,「allocating」的堆積空間大小愈大,需要的搜尋時間就會愈多。

當我們的程式碼呼叫函數時傳進函數內的參數和在函數宣告內的變數都會「push」進堆疊中,其中當然也可以有指向堆積記憶體空間用的指標。在函數結束執行後,這些值包括指標都會自動被「pop」出來。然而,在堆積中的儲存的資料都要另外使用別的方式進行追蹤,還要將已經沒有用到的資料清空。因為如果堆積中的資料沒有進行特別維護的話,即便沒有任何的指標指向該堆積中的資料,作業系統和程式也依然會繼續在記憶體中保留這些無用的資料,執行時間愈久所佔用的記憶體就會愈多,造成記憶體洩漏(memory leak)。所以有些程式語言提供了「垃圾回收機制」,能夠定時檢查堆積記憶體空間中,哪些資料是無用的,對其進行清理的動作。而Rust程式語言則沒有垃圾回收機制,它是利用「擁有權」的概念,訂定一些規則,讓編譯器能夠在編譯階段就能夠進行相關檢查,避免記憶體洩漏、或是存取到已經被清除的堆積記憶體位置的情形發生,並且不會消耗任何執行階段的運算資源。

擁有權的規則

首先,讓我們先看以下有關於擁有權的規則:

1. 每個Rust中的值所對應的變數,稱作該值的「擁有者」(owner)。
2. 同一時間,每個值只能分別有一個「擁有者」。
3. 當離開scope時,屬於該scope底下的「擁有者」所持有的值都會被消滅。

使用範圍(scope)

「scope」就是在一個程式範圍下能夠使用的東西,舉例來說:

大括號「{}」可以建立出一個程式敘述區塊,同時也會進入一個新的「scope」。程式第1行,遇到了左大括號「{」,所以當程式執行到這裡時,會進入一個新的「scope」。「s」變數是在程式第3行才宣告出來,因此程式第2行無法使用s變數。程式第3行宣告了「s」變數,並且在堆疊中建立了「1」這個值,其「擁有者」為「s」變數。程式第3行之後,就可以透過「s」變數來取得堆疊中的「1」這個值。程式第6行,遇到了右大括號「}」,因此會離開目前的這個「scope」,在這個「scope」下宣告出來的擁有者「s」變數所對應的值「1」會從堆疊中移除,其實也就是我們先前提到的函數在執行結束後自動「pop」堆疊的觀念。

我們在前一章節所介紹的資料型別通通都是儲存在堆疊中,所以在離開其擁有者所屬的「scope」之後,這些值就會被自動消滅。接下來我們來看看當資料是儲存在堆積時,Rust是怎麼處理的吧!

String型別

「String」是Rust內建的其中一種結構體,在製作猜數字程式的時候我們用過它來儲存使用者輸入的文字。我們並不知道在程式執行階段,使用者輸入的文字到底會有多長,在這資料長度不確定的情況下,可以知道「String」結構體的值,其文字部份是會儲存在堆積中的。舉個例子:

以上程式,我們使用了String模組提供的「from」函數來產生一個存著「hello」文字的String結構體的實體,「hello」文字的部份是透過其它的結構體(Vec)存在於堆積中。這個先不管,總之,String結構體有一部份的資料是儲存在堆積中的。

如以下程式:

程式第4行使用了String結構實體的「push_str」方法來改變原先String結構實體所儲存的文字。程式執行結果如下:

hello, world!

針對String結構體,可以先歸納出以下兩點:

1. String結構體所使用的記憶體必須要在程式執行階段可進行配置。

2. 需要有個方式來讓String結構體在執行階段所使用的記憶體空間,能夠在我們用完它之後還給作業系統。

第1點,我們在使用String模組提供的「from」函數和String結構實體的「push_str」方法都會在堆積中配置新的記憶體空間,這個到目前已經沒什麼問題了。至於第2點,我們該如何撰寫程式才能釋放原本String結構實體所使用到的堆積空間呢?Rust程式語言沒有垃圾回收的機制的話,難道需要寫「free」之類的函數嗎?

嘿嘿,神奇的來了,事實上,Rust還是一樣會根據擁有權規則的第3點,不管是堆疊還是堆積的資料,都會進行消滅,完全不需要加入任何其它的程式碼。舉例來說:

以上這段程式碼可以通過編譯,而且是安全的,不會有記憶體洩漏的問題。

至於為什麼Rust可以確定該堆積中的資料是可以清除的,就是根據擁有權的規則的第2點啦!所以不必擔心還有什麼其它變數也在使用相同堆積中的資料。

讓我們繼續延伸下去,先看一下底下的程式碼:

以上這段程式先宣告出變數「x」,將其指派了「5」這個值。緊接著又宣告了變數「y」,將其指派了變數「x」所對應的值。這樣將一個變數指派給另一個變數的方式稱作「複製」(copy)。Rust在進行資料「複製」時,預設會去複製變數所對應在堆疊中的值。所以此時堆疊空間會儲存兩個「5」,它們的擁有者分別是變數「x」和變數「y」。

如果程式碼是寫成:

由於String屬於結構體,不屬於整數、浮點數、布林、字元、數組、陣列等基本資料型別,且String結構並未實作「Copy」這個特性,因此Rust會不知道怎麼樣對「s1」變數所對應的String結構實體進行複製,此時Rust的應對方式就不會是「複製」,而是「移動」(move)。因此「s1」變數的值會移動給「s2」變數,經過移動的「s1」變數自此就不能夠再被使用,且String結構實體的擁有者也會變成「s2」變數。例如:

以上程式第5行會編譯錯誤。

雖然String結構體並未實作「Copy」特性,但它有實作「Clone」特性,因此若將以上程式改成以下的樣子,就可以通過編譯。

函數和擁有權

呼叫函數時傳遞參數的方式和以上介紹的「將一個變數指派給另一個變數」的方式一樣,如果要傳遞的值屬於基本資料型別,或是有實作「Copy」特性,就會使用「複製」的方式來傳值;如果不是,就會使用「移動」的方式來傳值。

舉例來說:

以上程式,若將第6行解除註解,程式會編譯失敗;若將第13行解除註解,程式依然可以編譯成功。

再來看看以下這個函數:

如果依照我們目前所知道的Rust觀念來進行推論的話,這個函數似乎是有一些問題存在的。因為我們在函數的主體scope中宣告「some_string」變數,又將「some_string」作為回傳值,那麼當離開這個函數主體的scope時,那個「hello」文字的String結構實體不是就會被消滅嗎?回傳出去的「some_string」變數所對應的值還能正常使用?

以上的推論是錯的。事實上,函數的回傳值也適用「複製」和「移動」的觀念。以上函數在回傳「some_string」變數所對應的值之後,「some_string」變數就算是一個已經被「移動」過的變數了,此時「hello」文字的String結構實體的擁有者就不是「some_string」變數。所以當離開此函數的主體scope時,並不會把「hello」文字的String結構實體消滅掉。

雖然Rust程式語言這樣的設計可以讓程式變得很安全,不會去錯誤存取堆積中的資料,也可以保證不會有記憶體洩漏的情形發生。但如果每次使用「移動」的參數傳遞方式呼叫函數後,變數都會因為已經被「移動」而無法使用,好像也挺困擾的。舉例來說:

以上程式看起來很合理,但是在程式第6行會編譯失敗,因為「s」變數已經被「移動」而無法使用了。

嗯,或許我們可以把傳進函數的「s」變數再藉由該函數回傳回來?

以上程式的確可以通過編譯,但是這樣的寫法實在是太白痴了。別緊張,我們還有一個很重要的觀念還沒學到,那就是「參考」(reference)。

參考和借用

再次改寫剛才的「calculate_length」程式:

程式第9行,我們將「calculate_length」函數的「s」參數改為「&String」型別。並且在程式第4行呼叫「calculate_length」函數時使用「&s」來代入參數。「&」符號代表「參考」(reference)。在存取變數時如果在變數前方加上「&」,表示要取得這個變數的「參考」,而參考的型別就是該值的型別名稱前再加上「&」。也就是說「&s」的型別為「&String」,因此可以代入至「calculate_length」函數的第1個參數。

變數的「參考」其實就是一個指到該變數在記憶體中位置的指標,透過指派「參考」給其它變數,或是作為參數傳進函數內,都不會導致原本的變數被「移動」。也因此改寫之後的程式是可以正常編譯和執行的。

在Rust程式語言中,參考型別和一般型別的變數都是使用「.」來存取其型別定義的欄位或是方法。若在參考型別的變數前加上星號「*」,則表示要對該參考進行「解參考」(dereferencing)。事實上,以上程式的第10行會等於:

由於這種寫法不好看,而且會讓程式碼變得冗長,所以Rust直接允許像使用一般型別的變數存取其成員的方式來使用參考型別的變數,會自動進行「解參考」的動作。

我們將指派變數的參考給其它變數,或是以參考傳遞函數參數的方式稱為「借用」(borrowing)。就像是在現實生活中,如果某人擁有某個東西,我們可以跟他借來使用一樣,這個東西的擁有者並不會因此而改變。

接著來看一下這個程式:

以上程式會編譯錯誤,因為程式第7行,定義「some_string」參數的型別時,並沒有使用「mut」關鍵字,因此這個參數是不可變的,自然就無法使用會改變自身值的「push_str」方法。

我們預期這個程式,在執行「change」函數之後,「main」函數中宣告的「s」變數所對應的String結構實體儲存的文字會被改變。也就是說我們在程式第2行,宣告「s」變數時,就應該要加上「mut」關鍵字了,當然程式第7行,定義「some_string」參數的型別時,也要加上「mut」關鍵字。

改寫後的程式碼如下:

實際編譯程式,會發現程式第4行還是會錯,這是因為「&String」和「&mut String」是不一樣的型別。所以我們還要將程式第4行進行改寫:

在變數前加上「&mut」,可以取得其可變參考。

為了維持程式的安全性,使用可變參考有個很大的限制,那就是一個變數在一個scope下,同時只能夠有一個可變參考。舉例來說:

以上程式,第5行會出現編譯錯誤,因為程式第4行已經有一個變數「s」可變的參考了。

Rust程式語言這樣的設計方式可以避免掉使用多個可變參考而可能造成的「資料競爭」(data race)問題。

資料競爭發生的條件如下:

1. 同時使用兩個或兩個以上的指標存取同一筆資料。
2. 在存取資料的同時,有一個以上的指標正在被用來修改其所指的資料。
3. 沒有加入同步的機制。

在程式執行階段遇到資料競爭問題的話會非常麻煩,因為程式的執行結果每次都會不一樣,以致於很難找出發生問題的源頭。不過,Rust程式語言透過限定可變參考數量的機制,在編譯階段就避免掉這個問題,省下很多麻煩。

在一個scope下,不可變參考和可變參考也不能同時使用。舉例來說:

可變的變數被借給其它變數使用之後,在尚未歸還前,都不能再被改變。舉例來說:

以上程式可以通過編譯,但是如果將第4行取消註解,程式就會編譯失敗,因為變數「a」的值已經借給變數「b」使用。

變數被借給其它變數使用之後,在尚未歸還前,都不能再被「移動」。舉例來說:

以上程式,由於變數「a」借給變數「b」使用,所以第4行會編譯錯誤。

切片(slice)

除了參考之外,還有另外一種資料型別沒有擁有權,那就是「切片」。「切片」型別能夠讓我們參考到某個集合內連續的元素,而不是整個集合。

來寫個程式練習看看吧!實作一個函數,這個函數可以取得傳入這個函數的字串值的第一個「字」(word)。

首先,我們要先定義這個函數的簽名,也就是函數名稱和函數的參數。

我們將這個函數取名為「first_word」,並且讓它有一個參數可以傳入型別為「&String」的值,因為我們不想要拿走傳進來的值的擁有權,所以在這裡使用參考型別。簽名定義好之後,我們該讓這個函數回傳什麼東西呢?根據題目,我們應該要回傳「s」參數的子字串(substring),且這個子字串是「s」參數字串的第一個字,以我們現在學到Rust基礎,似乎還不能處理「取得子字串」的問題。沒關係,我們就先讓函數回傳這個子字串的「長度」吧!

接著要來實作這個「first_word」函數的主體,為了要取得第一個字,我們需要去搜尋輸入的字串第一個空格字元出現的位置,在這個空格之前的子字串就是這個字串的第一個字。如果這個字串沒有空格,那第一個字就是這個字串本身。

可以撰寫出以下程式:

程式第2行,使用了String結構實體的「as_bytes」方法,直接將記憶體中使用UTF-8編碼的字串資料,以不可變參考的方式回傳出來,型別為「&[u8]」,也就是8位元的無號整數陣列。

程式第4行,使用了for迴圈,且迭代器另外呼叫了「enumerate」方法,可以使走訪的值變成一個數組。數組內第一個值為元素在陣列中的索引值,型別為「usize」;數組內的第二個值為元素的值,型別為「&u8」,為了避免元素在走訪時被複製,迭代器都會以元素的參考型別來回傳元素。但是在這裡我們卻使用「&item」來接收元素值,而不是直接使用「item」,到底是怎麼回事呢?

先看一下這個程式碼:

以上程式,變數「a」、「b」、「c」、「d」的型別分別是「i32」、「&i32」、「i32」、「i32」。第3行程式,將變數「a」的參考存到變數「b」。第4行程式,將變數「b」的值,也就是變數「a」的參考,存到變數「c」。第4行程式,將變數「b」的值,也就是變數「a」的參考「解參考」之後的值,「複製」之後,存到變數「d」。也就是說,這段程式的變數「a」、「b」、「c」,其實最終都是使用同一個記憶體空間,而變數「d」則是使用跟前三者不同記憶體空間。在宣告變數時,在變數前面接上「&」,表示使用這個變數時,要自動對這個變數進行「解參考」。

回到剛才的「first_word」函數,如果程式第4行的「&item」是寫成「item」的話,那麼它在第5行用來判斷是否為空格字元的時候,就必須加上星號「*」進行「解參考」。如下:

此外,也可以用單獨一個變數來儲存利用「enumerate」方法進行走訪所回傳的數組。如下:

不管我們用的方式是什麼,藉由目前「first_word」函數的邏輯,我們已經可以知道我們要找的子字串位於原本字串中的索引範圍了,剩下的工作就是讓「first_word」函數也能夠回傳這個索引範圍中的子字串。

字串切片

「字串切片」就是一個指到某個字串的子字串的參考。使用時的程式語法如下:

類似取得變數的參考的語法,先在變數前加上「&」後,再接中括號「[]」,並在中括號內決定要參考的索引範圍「start..end」,這裡要注意的是,參考到的元素並不包括索引值為「end」的元素。

每次建立出一個切片的時候,Rust程式並不會去複製新的值出來,取而代之的是他會產生出一個儲存著記憶體起始位址和資料的長度的結構實體。像是以上程式的「hello」變數,其被指派的值會記錄著「s[0]」的記憶體位址,和「5」這個長度。這樣的作法可以避免在使用切片後會在記憶體中複製出同樣的資料,減少記憶體的用量。

「..」為Rust的範圍語法,我們在前面的章節介紹for迴圈也有使用過,不算陌生,只不過當它用在「切片」的時候,會有幾個特殊的寫法。

例如:

以上兩行宣告「slice」變數的敘述,都是使用相同的值。省略索引範圍「start..end」中的「start」部份,會自動從索引「0」開始算起。

再舉一個例子:

以上兩行宣告「slice」變數的敘述,都是使用相同的值。索引範圍「start..end」的「start」和「end」,除了直接在程式碼內寫上數值之外,也可以用變數或是表示式代替。省略「end」部份的話,會自動算到集合中最後的元素。

當然,索引範圍的「start」和「end」也可以都省略。例如:

以上兩行宣告「slice」變數的敘述,都是使用相同的值。索引範圍只寫「..」的話,代表要包含所有的元素。

字串型別和陣列型別不能混為一談,雖然字串型別可以使用切片的方式利用索引值來取得記憶體中使用UTF-8編碼的字串原始資料,也就是「u8」型別陣列,但是它不能夠直接像陣列一樣使用中括號「[]」加上一個索引值來指定要存取哪個字元。

例如:

以上程式會編譯失敗,因為Rust的字串基於安全考量,並不提供這樣的用法。這個部份在之後的章節會詳細介紹。

然而如果改用字串切片的方式來取得子字串,程式就可以通過編譯了:

此外,也是基於安全考量,即便Rust替字串加入了方便的切片功能,但因為Rust預設的字串型別使用UTF-8編碼,在取得原始資料後,必須要能夠準確地將索引範圍切在字元和字元之間。會這麼說是因為,使用UTF-8編碼字元,一個字元可能會以1個位元組(byte,或是「u8」)、2個位元組、3個位元組,甚至是4個位元組來表示,如果進行字串切片的時候正好把本應該用來一起表示為一個字元的資料切開,程式就會發生panic,使用時要非常小心。

例如:

以上程式可以通過編譯,但是會在執行到第3行的時候發生panic,因為「中」這個字必須要用3個位元組的資料來表示,而指派給「c」變數的字串切片範圍卻只有切到資料的第1個位元組。

以上程式才可以編譯並執行成功。由於字串型別跟陣列等集合類型的型別不同,算是特別支援的,Rust有替字串切片訂定了一個型別「&str」,且直接寫在程式碼內的字串,型別就是使用「&str」。

在瞭解切片和字串切片的限制之後,我們來改寫「first_word」函數吧!改寫後程式碼如下:

程式第1行,讓「first_word」函數的回傳型別為字串切片,也就是「&str」。程式第6行,當for迴圈找到空格字元的索引位置後,就直接回傳這個索引位置之前範圍的字串切片。程式第10行,若都沒有找到空格字元,則會將整個傳入函數的字串轉成字串切片來回傳。

撰寫「main」函數來呼叫「first_word」函數試試看,程式如下:

程式執行結果:

hello

來小改一下「main」函數,讓「first_word」函數直接傳入寫在程式碼的字串值。

程式如下:

以上程式第14行會編譯錯誤,因為「first_word」函數的第一個參數必須傳入「&String」型別的值,而不是「&str」。咦,這個「first_word」函數好像不是很好用耶?別緊張,在習慣上,如果字串不可變的話,都會使用「&str」型別來傳遞字串值。修改後的程式如下:

程式第1行,將「first_word」函數的第一個參數型別改為「&str」,可以成功編譯並執行。同時,雖然「first_word」函數的第一個參數型別改為「&str」了,但依然可以傳入「&String」型別的值,因為「&String」到「&str」可以自動轉型。

其它切片

除了字串型別能夠切片之外,還有其它屬於集合類型的型別也可以轉成切片型別。以陣列來說,參考以下程式碼:

以上程式,「a」變數的型別為「[i32; 5]」,是一種陣列型別,而「slice」變數的型別為「&[i32]」,是一種切片型別。程式執行結果如下:

[1, 2, 3, 4, 5]
[2, 3]

「println!」巨集可以在大括號「{}」內加上「:?」來將不同型別的值格式化成字串。

這邊要注意的是,如果只是取得「a」變數的參考,也就是「&a」的話,其型別為「&[i32; 5]」,跟「&[i32]」並不相同。也就是說,在傳遞不可變的陣列時,我們可以使用其切片型別,如此一來就不必事先知道陣列的長度,也可以使用原本的陣列參考型別。

舉例來說:

執行結果為:

[1, 2, 3, 4, 5]
[1, 2, 0, 0, 5]

至於其它的集合型別和切片用法將會在之後的章節介紹。

總結

我們在這個章節中學會了Rust的擁有權概念還有切片的用法,也已經深刻體會Rust的嚴謹程式碼究竟是怎麼樣的風格了。在下一章節中,將會介紹結構體的用法。