C++ 基础与原生互操作(三):对象、RAII 与现代资源管理

C# 有垃圾回收,C++ 没有一个负责回收所有对象的托管堆,但这不等于现代 C++ 要在业务代码里到处手写 delete。C++ 的核心做法是把资源所有权放进对象,让析构函数随着作用域结束自动执行,这就是 RAII。

1. structclass

两者都可以有字段、成员函数、构造函数、继承关系和访问控制。主要语言差异是默认权限:struct 成员和继承默认 publicclass 默认 private

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdexcept>
#include <string>
#include <utility>

class TemperatureSensor
{
public:
    explicit TemperatureSensor(std::string name)
        : name_(std::move(name))
    {
        if (name_.empty())
        {
            throw std::invalid_argument("name must not be empty");
        }
    }

    [[nodiscard]] const std::string& name() const noexcept
    {
        return name_;
    }

private:
    std::string name_;
};

这段代码里:

  • 构造函数保证对象一旦构造成功,名称就有效;
  • explicit 防止单个字符串被意外隐式转换为 TemperatureSensor
  • 成员初始化列表直接初始化 name_
  • 成员函数末尾的 const 表示不修改当前对象的可观察状态;
  • noexcept 表示函数承诺不让异常逃出;
  • [[nodiscard]] 提醒调用者不要无意忽略返回值。

2. RAII 管的不只是内存

RAII 全称 Resource Acquisition Is Initialization。资源可以是内存,也可以是文件、锁、套接字、设备句柄或相机采集会话。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <cstdio>
#include <stdexcept>

class File
{
public:
    explicit File(const char* path)
        : handle_(std::fopen(path, "rb"))
    {
        if (handle_ == nullptr)
        {
            throw std::runtime_error("failed to open file");
        }
    }

    ~File()
    {
        std::fclose(handle_);
    }

    File(const File&) = delete;
    File& operator=(const File&) = delete;

private:
    std::FILE* handle_;
};

File 离开作用域时,析构函数会运行;正常返回和异常展开都会触发这种自动清理。这里禁止复制,是因为两个对象若持有同一个 FILE*,可能重复关闭。

真实项目更常直接使用标准库已有的 std::ifstream,上例只是展示如何把一对 open / close 封装成所有权类型。

3. 优先遵循 Rule of Zero

如果类的成员已经会正确管理自身资源,外层类通常不需要手写析构、复制或移动操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <string>
#include <vector>

class Batch
{
public:
    void add(double value)
    {
        values_.push_back(value);
    }

private:
    std::string recipe_name_;
    std::vector<double> values_;
};

std::stringstd::vector 会自动管理其内存,因此 Batch 的编译器生成操作通常已经正确。这称为 Rule of Zero:尽量让资源管理下沉到专门类型,业务类型不直接处理裸资源。

只有直接拥有特殊资源时,才需要认真设计析构、复制构造、复制赋值、移动构造和移动赋值。常见概括是 Rule of Five,但它不是要求每个类都机械写五个函数。

4. 复制与移动表达不同语义

1
2
3
std::string first = "camera-1";
std::string copied = first;            // copied 拥有独立值
std::string moved = std::move(first);  // 允许转移 first 的资源

移动后的 first 仍是有效对象,但其具体值通常只保证满足该类型文档规定的“有效但未指定状态”;可以销毁或重新赋值,不应假设它仍包含原字符串。

std::move 本身不移动数据,它只是把表达式转换为允许选择移动操作的值类别。真正发生什么由目标类型的构造或赋值操作决定。

按值传参并不必然很慢。现代编译器可进行复制消除;自 C++17 起,当函数返回与返回类型相同的类类型纯右值时,结果对象会由该表达式直接初始化,不调用复制或移动构造函数。返回命名局部变量的 NRVO 仍不是强制保证。类型也可以提供低成本移动,因此应先让接口语义清楚,再依据测量结果优化。

5. 智能指针表达所有权

1
2
3
4
#include <memory>

auto unique_sensor = std::make_unique<TemperatureSensor>("camera-1");
auto shared_sensor = std::make_shared<TemperatureSensor>("camera-2");
  • std::unique_ptr<T> 表示独占所有权,不可复制,可以移动;
  • std::shared_ptr<T> 通过引用计数表达共享所有权;
  • std::weak_ptr<T> 观察由 shared_ptr 管理的对象而不增加强引用,可用于打破共享所有权环。

不要因为“更保险”就默认使用 shared_ptr。它增加状态、原子引用计数等成本,也可能因环形引用让对象无法释放。C++ Core Guidelines 建议除非确实需要共享所有权,否则优先 unique_ptr

裸指针和引用仍有合理用途:它们通常表达非拥有访问。关键不是“代码里绝不能出现 T*”,而是接口能否区分拥有者和借用者。

6. 异常与错误边界

C++ 异常会沿调用栈展开,并析构已经完整构造的自动对象:

1
2
3
4
5
6
7
void run_device(); // 设备工作逻辑,可能抛出异常

void acquire_and_run()
{
    File config("device.bin");
    run_device(); // 即使这里抛异常,config 仍会被析构
}

但异常安全依赖 RAII。如果先取得裸资源,后续步骤抛异常,就可能泄漏。

异常也不能跨越 C ABI 或 P/Invoke 边界。不同语言运行时不知道如何可靠传播和销毁对方的异常对象。导出函数应在 C++ 边界捕获全部异常,转换为错误码或明确的错误对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
extern "C" int run_device_safely() noexcept
{
    try
    {
        run_device();
        return 0;
    }
    catch (const std::exception&)
    {
        return 1;
    }
    catch (...)
    {
        return 2;
    }
}

noexcept 函数若仍有异常逃出,程序会调用 std::terminate;所以不能只写声明而忘记封闭所有可能抛出的路径。

7. 模板先理解用途,不必立刻深挖

模板让代码按类型生成实例:

1
2
3
4
5
6
7
template <typename T>
T clamp(T value, T minimum, T maximum)
{
    if (value < minimum) return minimum;
    if (maximum < value) return maximum;
    return value;
}

std::vector<double>std::unique_ptr<TemperatureSensor> 都是模板实例。模板通常需要在使用点看到定义,因此大量实现会出现在头文件中。编译错误也可能展开为很长的模板诊断,先找到最靠近自己调用代码的类型不匹配通常更有效。

模板是 C++ 内部很强的抽象工具,但不应直接出现在 P/Invoke 接口里。跨语言调用需要的是运行时可定位的具体导出符号,而不是让 C# 实例化 C++ 模板。

8. C++ 对象为什么不宜直接导出给 C#

直接导出 C++ 类会把许多实现细节变成 ABI 契约:

  • 类的内存布局、对齐和虚函数表;
  • 构造、析构和名称修饰规则;
  • 编译器、标准库与运行库版本;
  • 异常模型和内存分配器;
  • 模板实例与内联实现。

如果 DLL 和调用者都由同一 C++ 工具链统一构建,这种接口可以工作;但 P/Invoke 不理解这些 C++ 语义。更稳妥的办法是在 DLL 内部保留现代 C++ 类,对外只暴露不透明句柄和 C 函数。第五篇会实现这种“外 C、内 C++”的分层。

9. 本篇小结

现代 C++ 的资源安全核心不是手工记住每一个 delete,而是把所有权交给具有析构函数的对象。局部对象、标准容器、unique_ptr 和专用句柄类型让清理随生命周期自动发生。跨到 P/Invoke 边界时,不导出这些实现类型,而是把它们藏在 DLL 内部。

参考资料

Licensed under CC BY-NC-SA 4.0