---
# System prepended metadata

title: Rust 所有權系統的先修課：記憶體洩漏
tags: [程式設計, Rust]

---

# Rust 所有權系統的先修課：記憶體洩漏

學 Rust 語言肯定要知道什麼是所有權系統。若只問「該如何打敗 Rust 的借用檢查器」，其實隨便搜一下都有很多相關資料。不過比起能夠通過編譯，我覺得弄懂這些設計的背後緣由更加重要，而這些細節也有不少是在我學 Rust 半年多以來才慢慢領悟到的事情。

今天不直接談所有權系統，先來講講所有程式語言都可能遇上的記憶體洩漏問題吧。

<br/>

## ► 程式當中的記憶體洩漏問題

之前稍微提過字串在 stack 和 heap 上面的行為。

我提到，stack 區域用來存放一些比較單純的變數、函數等等，這些記憶體空間的申請和釋放過程都是由編譯器設計、管理，而當變數離開作用域（scope）的時候，它所佔用的空間就會被釋放。

以 C 語言作例：
```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 上面要求系統動態分配記憶體：
```C
#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 的問題了。

既然是我們手動申請的，也就意味著系統並不知道這些空間什麼時候該釋放。當我們不再需要使用這個字串的時候，就必須手動釋放它：
```C
char *my_string = make_dynamic_string(capacity);
printf("%s", my_string);
free(my_string);
```

為了實現複雜的應用場景，動態分配記憶體的功能是必要的，畢竟本來就不是所有的資料都有固定長度、能夠被 stack 裝得下，就像範例當中的可變長度字串。

然而，這樣的設計很容易遇上工程師忘記釋放資源的問題，一旦工程師忘記手動釋放它，那塊申請來的記憶體空間就會一直被作業系統認為有人使用，直到程式結束為止。假如這樣的問題在程式運作的過程不斷發生，那麼整個程式所佔用的記憶體就會隨著時間越來越大，導致它無法長時間穩定運作，這種對記憶體失去完整控制的情形，就是所謂的記憶體洩漏（memory leak）。

<br/>

## ► 預防記憶體洩漏的方案

為了解決這種容易忘記手動釋放的情況，近代常見的程式語言如 Java、JavaScript、Golang、Python、C# 等等都利用垃圾收集（Garbage Collection，簡稱 GC）的機制來解決這樣的麻煩。垃圾收集的思想很簡單，就是在程式語言內建一個機制，定期（或被動地）判斷是否有不再需要的資源，自動把這些資源釋放掉，就像在房間地板隨時備個掃地機器人一樣。

這些語言的 GC 具體實作細節因不同語言而異：有些語言會給物件維護一個引用計數器，有些會把不同長短生命週期的物件分類，有些會對所有物件一個一個檢查引用狀態，它們都有各自的優缺點。由於它們要嘛在許多物件內部都封裝了一個額外的引用計數器，要嘛必須定期把這些引用物件遍歷一次，這些做法都難免需要犧牲一點性能或是資源，不然就是需要使用比較複雜的演算法。

比方說，實作引用計數器策略的 Python 在以下的場景：
```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()
```

於是依序發生了這樣的過程：
1. 當我們產生 "ABCDE" 字串並存入 `my_str` 的時候，這個字串本身內部的引用計數從 0 變成 1
2. 當字串被傳入 `my_func_inner` 的時候，引用計數從 1 變成 2
3. 當字串被傳入 `print` 的時候，引用計數從 2 變成 3，而 `print` 函數結束時它又從 3 變成 2
4. 當 `my_func_inner` 函數結束，引用計數從 2 變成 1
5. 當 `my_func` 函數結束，字串內部的引用計數從 1 變成 0
6. 引用計數器因為到達 0，系統藉此得知這個字串不再有人需要使用，因而釋放字串所佔用的記憶體空間

除此之外，也有一派的程式語言不依賴 GC 策略，而是資源取得即初始化（Resource Acquisition Is Initialization，簡稱 RAII），最具代表性的是 C++。

這個詞我覺得無論中英文都不是很好懂，就不要太細究這個名稱了。首先資源的取得和釋放應該要是成雙成對的，這個取得、準備資源的流程叫做「建構」（所以產生物件的那些 `new()` 函式，我們稱它為建構子），而釋放資源的時候則有「解構」。C++ 的策略是讓物件本身可以定義解構函數，把釋放資源的過程定義在解構函數裡面，這樣當變數離開作用域、生命週期結束的時候，系統自動嘗試呼叫物件的解構函式，那麼資源就會自動釋放掉了。

像前面 C 語言實作簡單字串的例子當中，如果換成是 C++ 的話，我們就能給這個自訂的 String 物件做更完善的封裝，定義它在變數離開作用域的時候由系統自動呼叫 `free(self)` 之類的行為來把這些佔用的記憶體空間釋放掉，也就能更好地避免記憶體空間可能會忘記被釋放的問題了。

之後再接著說說 Rust 是怎麼透過所有權系統和 RAII 的思想，更優雅地解決各種常見的安全問題。

<br/>

###### tags: `Rust` `程式設計`
