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 的借用检查器能在代码运行之前,在编译时捕获许多此类问题。这是一个真实且显著的优势,值得认可。

以下是我对两者权衡的客观看法:

  1. Rust 在编译器层面强制保障安全;C++ 通过标准库和编程惯用法提供安全保障。 两种方式都是有效的,它们反映了不同的设计哲学。

  2. 现代 C++ 在使用推荐工具时能防止同类缺陷。 智能指针、RAII、std::arraystd::stringstd::spanstd::optional——这些不是冷门的库功能,而是编写 C++ 的推荐方式,并且已经推行了超过十年。

  3. 两种语言都不是万能的。 Rust 的 unsafe 关键字存在是有原因的——有时你需要跳出安全网来进行 FFI、性能优化或底层操作。而逻辑缺陷、竞态条件(在某些模式下)和设计错误存在于每一种语言中。

  4. 务实很重要。 数十亿行经过实战检验的 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_ptrstd::shared_ptr)代替原始的 new/delete
  • 使用标准容器(std::stringstd::vectorstd::array)代替 C 风格缓冲区
  • 全面采用 RAII 进行资源管理
  • 在 CI 流水线中利用 sanitizer(ASan、MSan、UBSan)和静态分析
  • 遵循 C++ Core Guidelines

两种语言都是强大的工具,在现代软件开发中各有一席之地。最佳选择取决于你的项目、团队和约束——而非网上的争论。

如果你觉得这篇文章有用或引发了你的思考,欢迎分享。如果你有不同的见解,我很乐意在下方评论区看到你的观点。最好的工程讨论来自用代码论证,而非仅靠观点。