C++ 与 Rust 之间的内存安全讨论已成为软件工程领域最活跃的话题之一。政府机构纷纷发表了意见,各类技术会议为此设立了专题演讲,双方阵营都持有强烈的观点。
我先表明立场:Rust 是一门优秀的语言。 它的所有权模型和借用检查器确实具有创新性,能够在编译时捕获整类缺陷。如果你正在启动一个新项目,且 Rust 适合你的团队和技术生态,那是一个很好的选择。
与此同时,C++ 依然是全球性能要求最苛刻的软件的基石:操作系统内核、游戏引擎、浏览器、数据库和金融系统。这并非偶然,也不是因为这些团队没听说过 Rust。
我在这篇文章中想探讨的,是争论中经常被忽视的一个要点:现代 C++(C++11 及之后的版本)提供了编写内存安全代码的强大工具。 智能指针、RAII、带边界检查的容器以及丰富的标准库,解决的正是与 Rust 编译器所强制执行的同类缺陷。两种语言只是对同一个问题采取了不同的设计路径:编译器强制执行 vs. 开发者选择的编程惯用法。
与其在抽象层面争论,不如让我通过具体的、可运行的示例来说明我的观点。
免责声明: 本文中表达的观点仅代表个人立场,基于我的专业经验。所有代码示例均为教学目的而刻意简化。每种语言都有其取舍;本文旨在促进深思熟虑的讨论,而非贬低任何语言或其社区。实际情况因人而异。
示例 1:悬垂指针(释放后使用)
这是最常见的内存安全缺陷之一。指针在其指向的内存被释放后仍继续被使用。
糟糕的 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 释放内存,然后继续使用原始指针。这是一个教科书式的释放后使用缺陷。问题不在于 C++ “允许"了这种情况发生——问题在于开发者在有现代替代方案的情况下仍使用了上世纪 90 年代的编程模式。
良好的 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 拥有资源的所有权。当它超出作用域或被重置时,内存会被释放,同时 指针变为可进行空值检查的状态。不存在悬垂指针,也不存在未定义行为。这自 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:双重释放
糟糕的 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() 时。对一个已经为空的 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(资源获取即初始化)也许是 C++ 中最重要的编程惯用法。其思想很简单:将资源的生命周期绑定到对象的生命周期。对象创建时获取资源,对象销毁时(超出作用域时)释放资源。无需手动清理,也不会遗忘。
智能指针只是 RAII 的一种应用。这种模式适用于任何资源:文件、互斥锁、网络套接字、数据库连接、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) | 检测释放后使用、缓冲区溢出、栈溢出 |
| 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
两种语言都是强大的工具,在现代软件开发中各有一席之地。最佳选择取决于你的项目、团队和约束——而非网上的争论。
如果你觉得这篇文章有用或引发了你的思考,欢迎分享。如果你有不同的见解,我很乐意在下方评论区看到你的观点。最好的工程讨论来自用代码论证,而非仅靠观点。
评论