本篇文章有不少程式碼,如果這裡的排版讓你感到閱讀困難,請服用 HackMD 好讀版。
學 Rust 語言肯定要知道什麼是所有權系統。若只問「該如何打敗 Rust 的借用檢查器」,其實隨便搜一下都有很多相關資料。不過比起能夠通過編譯,我覺得弄懂這些設計的背後緣由更加重要,而這些細節也有不少是在我學 Rust 半年多以來才慢慢領悟到的事情。
今天不直接談所有權系統,先來講講所有程式語言都可能遇上的記憶體洩漏問題吧。
? 程式當中的記憶體洩漏問題
之前稍微提過字串在 stack 和 heap 上面的行為。
我提到,通常 stack 區域用來存放一些比較單純的變數、函數等等,這些記憶體空間的申請和釋放過程都是由編譯器設計、管理,而當變數離開作用域(scope)的時候,它所佔用的空間就會被釋放。
以 C 語言作例:
#include <stdio.h> void check_var_sum(int x, int y, int z) { int sum = x+y+z; if (sum % 2 == 0) { printf("The sum %d is even.\n", sum); return; } printf("The sum %d is odd.\n", sum); } int main() { check_var_sum(1, 2, 3); return 0; } |
可以看到我在函數 check_var_sum 當中宣告了一個區域變數 sum,接著令系統在 stack 安排一個 int 大小的空間,把 x+y+z 的總和放進去。因為變數 sum 的作用域只在 check_var_sum 範圍當中,所以當這個函數結束(返回)的時候,sum 所佔用的記憶體就會自動被釋放(free),那塊 int 大小的記憶體空間就又可以被使用了。
在一些用例比較複雜的場合下,C 語言允許使用者在 heap 上面要求系統動態分配記憶體:
#include <stdio.h> #include <stdlib.h> char *make_dynamic_string(unsigned int capacity) { char *new_string = malloc(capacity * sizeof(char)); for (unsigned int i=0; i<capacity; i++) { new_string[i] = 'Q'; // fill with 'Q' } return new_string; } int main() { unsigned int capacity = 0; printf("Input the capacity of string: "); scanf("%d", &capacity); char *my_string = make_dynamic_string(capacity); printf("%s", my_string); return 0; } |
這麼做的話,分配的長度就可以由使用者來輸入,再經由 malloc(memory allocate)動態分配記憶體。實際執行以上範例就能看見,當輸入 6 的時候,會得到一個長度為 6 的字串 "QQQQQQ";當輸入 8 的時候,會得到一個長度為 8 的字串 "QQQQQQQQ"。這裡為了方便解說,就暫時不考慮 C 語言字串結尾必須是 0x00 的問題了。
既然是我們手動申請的,也就意味著系統並不知道這些空間什麼時候該釋放。當我們不再需要使用這個字串的時候,就必須手動釋放它:
char *my_string = make_dynamic_string(capacity); printf("%s", my_string); free(my_string); |
為了實現複雜的應用場景,動態分配記憶體的功能是必要的,畢竟本來就不是所有的資料都有固定長度、能夠被 stack 裝得下,就像範例當中的可變長度字串。
然而,這樣的設計很容易遇上工程師忘記釋放資源的問題,一旦工程師忘記手動釋放它,那塊申請來的記憶體空間就會一直被作業系統認為有人使用,直到程式結束為止。假如這樣的問題在程式運作的過程不斷發生,那麼整個程式所佔用的記憶體就會隨著時間越來越大,導致它無法長時間穩定運作,這種對記憶體失去完整控制的情形,就是所謂的記憶體洩漏(memory leak)。
? 預防記憶體洩漏的方案
為了解決這種容易忘記手動釋放的情況,近代常見的程式語言如 Java、JavaScript、Golang、Python、C# 等等都利用垃圾收集(Garbage Collection,簡稱 GC)的機制來解決這樣的麻煩。垃圾收集的思想很簡單,就是在程式語言內建一個機制,定期(或被動地)判斷是否有不再需要的資源,自動把這些資源釋放掉,就像在房間地板隨時備個掃地機器人一樣。
這些語言的 GC 具體實作細節因不同語言而異:有些語言會給物件維護一個引用計數器,有些會把不同長短生命週期的物件分類,有些會對所有物件一個一個檢查引用狀態,它們都有各自的優缺點。由於它們要嘛在許多物件內部都封裝了一個額外的引用計數器,要嘛必須定期把這些引用物件遍歷一次,這些做法都難免需要犧牲一點性能或是資源,不然就是需要使用比較複雜的演算法。
比方說,實作引用計數器策略的 Python 在以下的場景:
def my_func_inner(str_to_print): print(str_to_print) def my_func(): my_str = "ABCDE" my_func_inner(my_str) def main(): my_func() |
於是依序發生了這樣的過程:
- 當我們產生 "ABCDE" 字串並存入 my_str 的時候,這個字串本身內部的引用計數從 0 變成 1
- 當字串被傳入 my_func_inner 的時候,引用計數從 1 變成 2
- 當字串被傳入 print 的時候,引用計數從 2 變成 3,而 print 函數結束時它又從 3 變成 2
- 當 my_func_inner 函數結束,引用計數從 2 變成 1
- 當 my_func 函數結束,字串內部的引用計數從 1 變成 0
- 引用計數器因為到達 0,系統藉此得知這個字串不再有人需要使用,因而釋放字串所佔用的記憶體空間
除此之外,也有一派的程式語言不依賴 GC 策略,而是資源取得即初始化(Resource Acquisition Is Initialization,簡稱 RAII),最具代表性的是 C++。
這個詞我覺得無論中英文都不是很好懂,就不要太細究這個名稱了。首先資源的取得和釋放應該要是成雙成對的,這個取得、準備資源的流程叫做「建構」(所以產生物件的那些 new() 函式,我們稱它為建構子),而釋放資源的時候則有「解構」。C++ 的策略是讓物件本身可以定義解構函數,把釋放資源的過程定義在解構函數裡面,這樣當變數離開作用域、生命週期結束的時候,系統自動嘗試呼叫物件的解構函式,那麼資源就會自動釋放掉了。
像前面 C 語言實作簡單字串的例子當中,如果換成是 C++ 的話,我們就能給這個自訂的 String 物件做更完善的封裝,定義它在變數離開作用域的時候由系統自動呼叫 free(self) 之類的行為來把這些佔用的記憶體空間釋放掉,也就能更好地避免記憶體空間可能會忘記被釋放的問題了。
之後再接著說說 Rust 是怎麼透過所有權系統和 RAII 的思想,更優雅地解決各種常見的安全問題。
HackMD 好讀版:https://hackmd.io/@upk1997/rust-ownership-0
縮圖素材原作者:Karen Rustad T?lva(CC0 1.0)