C# 有垃圾回收,C++ 没有一个负责回收所有对象的托管堆,但这不等于现代 C++ 要在业务代码里到处手写 delete。C++ 的核心做法是把资源所有权放进对象,让析构函数随着作用域结束自动执行,这就是 RAII。
1. struct 和 class
两者都可以有字段、成员函数、构造函数、继承关系和访问控制。主要语言差异是默认权限:struct 成员和继承默认 public,class 默认 private。
| |
这段代码里:
- 构造函数保证对象一旦构造成功,名称就有效;
explicit防止单个字符串被意外隐式转换为TemperatureSensor;- 成员初始化列表直接初始化
name_; - 成员函数末尾的
const表示不修改当前对象的可观察状态; noexcept表示函数承诺不让异常逃出;[[nodiscard]]提醒调用者不要无意忽略返回值。
2. RAII 管的不只是内存
RAII 全称 Resource Acquisition Is Initialization。资源可以是内存,也可以是文件、锁、套接字、设备句柄或相机采集会话。
| |
当 File 离开作用域时,析构函数会运行;正常返回和异常展开都会触发这种自动清理。这里禁止复制,是因为两个对象若持有同一个 FILE*,可能重复关闭。
真实项目更常直接使用标准库已有的 std::ifstream,上例只是展示如何把一对 open / close 封装成所有权类型。
3. 优先遵循 Rule of Zero
如果类的成员已经会正确管理自身资源,外层类通常不需要手写析构、复制或移动操作:
| |
std::string 和 std::vector 会自动管理其内存,因此 Batch 的编译器生成操作通常已经正确。这称为 Rule of Zero:尽量让资源管理下沉到专门类型,业务类型不直接处理裸资源。
只有直接拥有特殊资源时,才需要认真设计析构、复制构造、复制赋值、移动构造和移动赋值。常见概括是 Rule of Five,但它不是要求每个类都机械写五个函数。
4. 复制与移动表达不同语义
| |
移动后的 first 仍是有效对象,但其具体值通常只保证满足该类型文档规定的“有效但未指定状态”;可以销毁或重新赋值,不应假设它仍包含原字符串。
std::move 本身不移动数据,它只是把表达式转换为允许选择移动操作的值类别。真正发生什么由目标类型的构造或赋值操作决定。
按值传参并不必然很慢。现代编译器可进行复制消除;自 C++17 起,当函数返回与返回类型相同的类类型纯右值时,结果对象会由该表达式直接初始化,不调用复制或移动构造函数。返回命名局部变量的 NRVO 仍不是强制保证。类型也可以提供低成本移动,因此应先让接口语义清楚,再依据测量结果优化。
5. 智能指针表达所有权
| |
std::unique_ptr<T>表示独占所有权,不可复制,可以移动;std::shared_ptr<T>通过引用计数表达共享所有权;std::weak_ptr<T>观察由shared_ptr管理的对象而不增加强引用,可用于打破共享所有权环。
不要因为“更保险”就默认使用 shared_ptr。它增加状态、原子引用计数等成本,也可能因环形引用让对象无法释放。C++ Core Guidelines 建议除非确实需要共享所有权,否则优先 unique_ptr。
裸指针和引用仍有合理用途:它们通常表达非拥有访问。关键不是“代码里绝不能出现 T*”,而是接口能否区分拥有者和借用者。
6. 异常与错误边界
C++ 异常会沿调用栈展开,并析构已经完整构造的自动对象:
| |
但异常安全依赖 RAII。如果先取得裸资源,后续步骤抛异常,就可能泄漏。
异常也不能跨越 C ABI 或 P/Invoke 边界。不同语言运行时不知道如何可靠传播和销毁对方的异常对象。导出函数应在 C++ 边界捕获全部异常,转换为错误码或明确的错误对象:
| |
noexcept 函数若仍有异常逃出,程序会调用 std::terminate;所以不能只写声明而忘记封闭所有可能抛出的路径。
7. 模板先理解用途,不必立刻深挖
模板让代码按类型生成实例:
| |
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 内部。