C++ 與 Rust 之間的記憶體安全討論,已經成為軟體工程領域中最熱門的話題之一。政府機構紛紛表態、研討會專題演講層出不窮,雙方陣營各持己見、立場鮮明。

讓我先把話說在前頭:Rust 是一門非常優秀的語言。 它的所有權模型和借用檢查器確實具有創新性,能在編譯期就攔截一整類的錯誤。如果你正在啟動一個新專案,且 Rust 適合你的團隊和生態系統,那絕對是個好選擇。

與此同時,C++ 仍然是全球最講究效能的軟體之基石:作業系統核心、遊戲引擎、瀏覽器、資料庫、金融系統。這絕非巧合,也不是因為那些團隊沒聽說過 Rust。

這篇文章想探討的,是在這場論戰中經常被忽略的一個重點:現代 C++(C++11 及之後的版本)提供了強大的工具來撰寫記憶體安全的程式碼。 智慧指標、RAII、具有邊界檢查的容器,以及豐富的標準程式庫,可以處理 Rust 編譯器所強制避免的同類錯誤。兩種語言只是對同一個問題採取了不同的設計理念:編譯器強制 vs. 開發者自選慣用寫法

與其抽象地爭論,不如讓我用具體、可執行的範例來說明。

免責聲明: 本文所表達的觀點為個人見解,基於我的專業經驗。所有程式碼範例皆為教學目的而刻意簡化。每種語言都有其取捨;本文旨在促進有建設性的討論,而非貶低任何語言或其社群。實際情況因人而異。

範例 1:懸空指標(Use-After-Free)

這是最常見的記憶體安全錯誤之一。指標在其指向的記憶體被釋放後仍然被使用。

不良 C++(不好的做法)

 1#include <iostream>
 2
 3int main() {
 4    int* ptr = new int(42);
 5    std::cout << "Value: " << *ptr << std::endl;
 6
 7    // ptr is now dangling (it still holds the address of freed memory)
 8    delete ptr;
 9
10    // Undefined behavior: reading freed memory
11    std::cout << "After delete: " << *ptr << std::endl;
12
13    return 0;
14}

問題出在哪? 開發者使用 new 手動配置記憶體,再用 delete 手動釋放,之後卻繼續使用這個原始指標。這是典型的 use-after-free 錯誤。問題不是 C++「允許」了這件事,而是開發者在有現代替代方案的情況下,使用了 1990 年代的寫法。

良好 C++(現代做法)

 1#include <iostream>
 2#include <memory>
 3
 4int main() {
 5    auto ptr = std::make_unique<int>(42);
 6    std::cout << "Value: " << *ptr << std::endl;
 7
 8    // Explicitly releases the memory
 9    ptr.reset();
10
11    if (!ptr) {
12        std::cout << "Pointer is null, safe to handle." << std::endl;
13    }
14
15    return 0;
16}

改進了什麼?

std::unique_ptr 擁有資源的所有權。當它離開作用域或被 reset 時,記憶體會被釋放,同時 指標可以進行 null 檢查。沒有懸空指標,沒有未定義行為。這是自 2011 年起的標準 C++。

範例 2:緩衝區溢位

讀寫超出陣列邊界是另一個經典的安全漏洞來源。

不良 C++(不好的做法)

 1#include <iostream>
 2#include <cstring>
 3
 4int main() {
 5    char buffer[10];
 6
 7    // Copying more data than the buffer can hold
 8    const char* source = "This string is way too long for the buffer";
 9    std::strcpy(buffer, source);
10
11    std::cout << "Buffer: " << buffer << std::endl;
12    return 0;
13}

問題出在哪?

開發者使用了 C 時代的函式(strcpy),搭配固定大小的 C 陣列,完全沒有邊界檢查。這種程式碼讓 C++ 背了黑鍋,但它根本不是現代 C++,而是用 C++ 編譯器編譯的 C 程式碼。

良好 C++(現代做法)

 1#include <iostream>
 2#include <string>
 3
 4int main() {
 5    std::string buffer;
 6
 7    // std::string manages its own memory and grows as needed
 8    buffer = "This string is way too long for a fixed buffer, but std::string handles it.";
 9
10    std::cout << "Buffer: " << buffer << std::endl;
11
12    // Need a substring? Bounds-checked:
13    try {
14        std::string sub = buffer.substr(0, 10);
15        std::cout << "Substring: " << sub << std::endl;
16
17        // This would throw std::out_of_range:
18        // std::string bad_sub = buffer.substr(9999, 10);
19    } catch (const std::out_of_range& e) {
20        std::cout << "Caught: " << e.what() << std::endl;
21    }
22
23    return 0;
24}

改進了什麼?

std::string 自行管理記憶體,不可能溢位。使用 .at() 或傳入無效索引的 .substr() 進行越界操作時,會拋出可捕獲的例外。這個功能從 C++98 就已經存在於標準之中。

範例 3:陣列越界存取

不良 C++(不好的做法)

 1#include <iostream>
 2
 3int main() {
 4    int arr[5] = {10, 20, 30, 40, 50};
 5
 6    // Accessing index 10 (way out of bounds)
 7    std::cout << "arr[10] = " << arr[10] << std::endl;
 8
 9    return 0;
10}

問題出在哪?

原始 C 陣列完全沒有邊界檢查。開發者存取了一個無效的索引,得到的是垃圾值(更糟的情況是產生安全漏洞)。再說一次,這是開發者選擇使用最原始的資料結構所導致的問題。

良好 C++(現代做法)

 1#include <iostream>
 2#include <array>
 3#include <stdexcept>
 4
 5int main() {
 6    std::array<int, 5> arr = {10, 20, 30, 40, 50};
 7
 8    try {
 9        // .at() performs bounds checking and throws on invalid access
10        std::cout << "arr.at(2) = " << arr.at(2) << std::endl;
11        std::cout << "arr.at(10) = " << arr.at(10) << std::endl;
12    } catch (const std::out_of_range& e) {
13        std::cout << "Caught out-of-range: " << e.what() << std::endl;
14    }
15
16    return 0;
17}

改進了什麼?

std::array 是一個固定大小的容器,它知道自身的長度。.at() 方法會在執行期進行邊界檢查,超出範圍時拋出例外。自 C++11 起即可使用。

範例 4:雙重釋放(Double Free)

不良 C++(不好的做法)

 1#include <iostream>
 2
 3int main() {
 4    int* ptr = new int(100);
 5    std::cout << "Value: " << *ptr << std::endl;
 6
 7    delete ptr;
 8    // Double free (undefined behavior, potential crash or exploit)
 9    delete ptr;
10
11    return 0;
12}

問題出在哪?

開發者對同一塊記憶體釋放了兩次。這可能會破壞堆積配置器的內部狀態,而且是已知的安全漏洞攻擊手法。這又是一個手動記憶體管理的失誤。

良好 C++(現代做法)

 1#include <iostream>
 2#include <memory>
 3
 4int main() {
 5    auto ptr = std::make_unique<int>(100);
 6    std::cout << "Value: " << *ptr << std::endl;
 7
 8    // Memory is freed exactly once when ptr goes out of scope.
 9    // You literally cannot double-free with unique_ptr.
10    // Calling reset() is also safe:
11    ptr.reset();
12    // No-op. No crash. No UB.
13    ptr.reset();
14
15    std::cout << "Done safely." << std::endl;
16    return 0;
17}

改進了什麼?

std::unique_ptr 保證了單一所有權語義。記憶體只會被釋放一次——不論是在指標離開作用域時,或是呼叫 reset() 時。對已經為 null 的 unique_ptr 呼叫 reset() 是安全的空操作。

範例 5:共享所有權的錯誤與正確做法

不良 C++(不好的做法)

 1#include <iostream>
 2
 3void use(int* p) {
 4    std::cout << "Using: " << *p << std::endl;
 5}
 6
 7int main() {
 8    int* shared = new int(99);
 9
10    use(shared);
11
12    delete shared;
13
14    // Another function tries to use the same pointer (use-after-free)
15    use(shared);
16
17    return 0;
18}

問題出在哪?

程式碼的多個部分共享一個原始指標,卻沒有明確的所有權歸屬。一旦某個部分刪除了記憶體,其他所有部分就都持有了懸空指標。

良好 C++(現代做法)

 1#include <iostream>
 2#include <memory>
 3
 4void use(std::shared_ptr<int> p) {
 5    std::cout << "Using: " << *p
 6              << " (ref count: " << p.use_count() << ")" << std::endl;
 7}
 8
 9int main() {
10    auto shared = std::make_shared<int>(99);
11
12    use(shared);
13    use(shared);
14
15    // Memory freed automatically when the last shared_ptr goes out of scope.
16    std::cout << "Final ref count: " << shared.use_count() << std::endl;
17
18    return 0;
19}

改進了什麼?

std::shared_ptr 使用參照計數來管理共享所有權。記憶體只在最後一個參照它的 shared_ptr 被銷毀時才會釋放。沒有懸空指標,不需要手動 delete。自 C++11 起即可使用。

範例 6:RAII——現代 C++ 的安全之道

RAII(Resource Acquisition Is Initialization,資源取得即初始化)或許是 C++ 中最重要的慣用模式。概念很簡單:將資源的生命週期綁定到物件的生命週期。當物件被建立時,它取得資源;當物件被銷毀(離開作用域)時,它釋放資源。不需要手動清理,也不會有遺漏的可能。

智慧指標只是 RAII 的其中一種應用。這個模式適用於任何資源:檔案、互斥鎖、網路 socket、資料庫連線、GPU 控制代碼——凡是你想得到的都行。

不良 C++(手動資源管理)

 1#include <iostream>
 2#include <fstream>
 3#include <stdexcept>
 4
 5void process_file(const std::string& filename) {
 6    FILE* f = fopen(filename.c_str(), "r");
 7    if (!f) {
 8        throw std::runtime_error("Cannot open file");
 9    }
10
11    char buf[256];
12    if (!fgets(buf, sizeof(buf), f)) {
13        // Oops - if we throw or return here, we leak the file handle
14        throw std::runtime_error("Cannot read file");
15    }
16
17    // If any exception is thrown above, fclose is never called
18    fclose(f);
19    std::cout << "Read: " << buf << std::endl;
20}
21
22int main() {
23    try {
24        process_file("example.txt");
25    } catch (const std::exception& e) {
26        std::cerr << "Error: " << e.what() << std::endl;
27    }
28    return 0;
29}

問題出在哪?

開發者使用了 C 風格的 fopen/fclose。如果在開啟和關閉之間拋出了例外,檔案控制代碼就會洩漏。隨著資源數量和錯誤路徑的增加,要可靠地關閉所有資源會變成維護的噩夢。

良好 C++(使用 std::ifstream 的 RAII)

 1#include <iostream>
 2#include <fstream>
 3#include <string>
 4
 5void process_file(const std::string& filename) {
 6    std::ifstream file(filename);
 7    if (!file.is_open()) {
 8        throw std::runtime_error("Cannot open file");
 9    }
10
11    std::string line;
12    if (!std::getline(file, line)) {
13        throw std::runtime_error("Cannot read file");
14    }
15
16    // No need to close (std::ifstream closes automatically when it goes out of scope, even if an exception is thrown)
17    std::cout << "Read: " << line << std::endl;
18}
19
20int main() {
21    try {
22        process_file("example.txt");
23    } catch (const std::exception& e) {
24        std::cerr << "Error: " << e.what() << std::endl;
25    }
26    return 0;
27}

改進了什麼?

std::ifstream 是檔案控制代碼的 RAII 包裝器。檔案在建構函式中開啟,在解構函式中關閉——保證 如此,無論函式是正常回傳、拋出例外,還是提前回傳。設計上就杜絕了資源洩漏。

RAII 用於自訂資源(互斥鎖)

RAII 不侷限於標準程式庫。以下展示它如何應用在執行緒同步上:

 1#include <iostream>
 2#include <mutex>
 3#include <thread>
 4#include <vector>
 5
 6std::mutex mtx;
 7int shared_counter = 0;
 8
 9void increment(int times) {
10    for (int i = 0; i < times; ++i) {
11        // std::lock_guard locks the mutex on construction,
12        // unlocks it on destruction (end of scope, even if an exception occurs)
13        std::lock_guard<std::mutex> lock(mtx);
14        ++shared_counter;
15    }
16}
17
18int main() {
19    std::vector<std::thread> threads;
20    for (int i = 0; i < 4; ++i) {
21        threads.emplace_back(increment, 10000);
22    }
23
24    for (auto& t : threads) {
25        t.join();
26    }
27
28    // Always prints 40000 (no data race, no forgotten unlock)
29    std::cout << "Counter: " << shared_counter << std::endl;
30
31    return 0;
32}

撰寫你自己的 RAII 包裝器

你可以將 RAII 應用到任何資源。以下是一個極簡範例:

 1#include <iostream>
 2#include <stdexcept>
 3
 4// A generic RAII wrapper (acquires in constructor, releases in destructor)
 5class SocketHandle {
 6    int fd_;
 7public:
 8    explicit SocketHandle(int fd) : fd_(fd) {
 9        if (fd_ < 0) throw std::runtime_error("Invalid socket");
10        std::cout << "Socket " << fd_ << " acquired." << std::endl;
11    }
12
13    ~SocketHandle() {
14        if (fd_ >= 0) {
15            std::cout << "Socket " << fd_ << " released." << std::endl;
16            fd_ = -1;
17        }
18    }
19
20    // Prevent copying (single ownership, like unique_ptr)
21    SocketHandle(const SocketHandle&) = delete;
22    SocketHandle& operator=(const SocketHandle&) = delete;
23
24    // Allow moving
25    SocketHandle(SocketHandle&& other) noexcept : fd_(other.fd_) {
26        other.fd_ = -1;
27    }
28
29    int get() const { return fd_; }
30};
31
32void do_network_stuff() {
33    SocketHandle sock(42); // "Acquired"
34    std::cout << "Using socket " << sock.get() << std::endl;
35    // Even if an exception were thrown here, the socket is released.
36} // sock goes out of scope (destructor called, socket released)
37
38int main() {
39    do_network_stuff();
40    std::cout << "Back in main - no resource leak." << std::endl;
41    return 0;
42}

核心要點:

RAII 不是權宜之計,也不只是一條「最佳實務建議」——它是 C++ 的基本設計模式,只要持續套用,就能從結構上杜絕資源洩漏。它早於 Rust 的所有權模型,而且在許多方面啟發了 Rust 的設計。

Rust 的表現如何?

Rust 的借用檢查器能在程式碼執行之前、在編譯期就攔截許多這類問題。這是一項真實且重要的優勢,值得肯定。

以下是我對兩者權衡的坦誠看法:

  1. Rust 在編譯器層級強制安全;C++ 則透過標準程式庫和慣用模式提供安全。 兩種方式都是合理的,它們反映了不同的設計哲學。

  2. 現代 C++ 在使用其推薦工具的情況下,能防止同類的錯誤。 智慧指標、RAII、std::arraystd::stringstd::spanstd::optional——這些都不是冷門的程式庫功能,它們是 C++ 推薦的寫法,而且已經推行超過十年了。

  3. 沒有任何語言是萬靈丹。 Rust 的 unsafe 關鍵字存在是有理由的——有時你需要跳脫安全網來處理 FFI、效能優化或底層操作。而且,邏輯錯誤、競態條件(在某些模式下)及設計缺陷在每個語言中都會出現。

  4. 務實才是重點。 數十億行久經考驗的 C++ 程式碼正在生產環境中運行。對許多團隊而言,採用現代 C++ 實務、更好的工具和靜態分析,比起全面重寫更切實——而且確實能帶來真正的安全性提升。

工具方面呢?

現代 C++ 開發者擁有強大的安全工具,甚至能攔截語言本身無法強制避免的問題:

工具用途
AddressSanitizer (ASan)偵測 use-after-free、緩衝區溢位、堆疊溢位
MemorySanitizer (MSan)偵測讀取未初始化的記憶體
UndefinedBehaviorSanitizer (UBSan)偵測有號整數溢位、空指標解參照等問題
Valgrind記憶體洩漏偵測與效能分析
Clang-Tidy靜態分析,附帶現代化建議
C++ Core Guidelines業界標準的安全現代 C++ 指南

-fsanitize=address 編譯本文中的「不良」範例,就能在執行期立即標記出問題。工具本身是存在的,問題在於開發者是否使用。

總結

C++ 和 Rust 從不同的角度來處理記憶體安全:

  • Rust 將安全設為預設,要求你使用 unsafe 來明確退出安全機制。
  • C++ 賦予開發者完整的控制權,並透過標準程式庫、RAII 及成熟的慣用模式來提供安全保障。

C++ 程式碼庫中大多數的記憶體安全問題,來自於使用過時的寫法——原始的 new/delete、C 風格陣列、像 strcpy 這樣的函式——而非語言十多年來早已提供的現代工具。

好消息是,C++ 的前進方向已經清晰且有據可循:

  • 使用智慧指標(std::unique_ptrstd::shared_ptr)取代原始的 new/delete
  • 使用標準容器(std::stringstd::vectorstd::array)取代 C 風格緩衝區
  • 全面採用 RAII 進行資源管理
  • 在 CI 流程中善用 sanitizer(ASan、MSan、UBSan)及靜態分析工具
  • 遵循 C++ Core Guidelines

兩種語言都是強大的工具,在現代軟體開發中各有其位。最佳選擇取決於你的專案、團隊和限制條件——而不是網路上的口水戰。

如果你覺得這篇文章有用或發人深省,歡迎分享。如果你有不同的看法,我也很樂意在下方評論區聽到你的意見。最好的工程討論,是用程式碼說話,而不只是空談。