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 的借用檢查器能在程式碼執行之前、在編譯期就攔截許多這類問題。這是一項真實且重要的優勢,值得肯定。
以下是我對兩者權衡的坦誠看法:
Rust 在編譯器層級強制安全;C++ 則透過標準程式庫和慣用模式提供安全。 兩種方式都是合理的,它們反映了不同的設計哲學。
現代 C++ 在使用其推薦工具的情況下,能防止同類的錯誤。 智慧指標、RAII、
std::array、std::string、std::span、std::optional——這些都不是冷門的程式庫功能,它們是 C++ 推薦的寫法,而且已經推行超過十年了。沒有任何語言是萬靈丹。 Rust 的
unsafe關鍵字存在是有理由的——有時你需要跳脫安全網來處理 FFI、效能優化或底層操作。而且,邏輯錯誤、競態條件(在某些模式下)及設計缺陷在每個語言中都會出現。務實才是重點。 數十億行久經考驗的 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_ptr、std::shared_ptr)取代原始的new/delete - 使用標準容器(
std::string、std::vector、std::array)取代 C 風格緩衝區 - 全面採用 RAII 進行資源管理
- 在 CI 流程中善用 sanitizer(ASan、MSan、UBSan)及靜態分析工具
- 遵循 C++ Core Guidelines
兩種語言都是強大的工具,在現代軟體開發中各有其位。最佳選擇取決於你的專案、團隊和限制條件——而不是網路上的口水戰。
如果你覺得這篇文章有用或發人深省,歡迎分享。如果你有不同的看法,我也很樂意在下方評論區聽到你的意見。最好的工程討論,是用程式碼說話,而不只是空談。
評論