C++ 智能指针的核心并不是“自动释放内存”,而是用类型明确表达对象的所有权。当智能指针离开作用域时,它会自动完成资源释放,这正是 RAII(Resource Acquisition Is Initialization,资源获取即初始化)的典型应用。
1. 为什么需要智能指针
使用裸指针管理动态内存时,程序员必须确保每一条执行路径都能正确调用 delete:
1
2
3
4
5
6
7
8
9
10
11
| void process() {
auto* model = new Model();
if (!model->load()) {
delete model;
return;
}
run(*model);
delete model;
}
|
一旦中途抛出异常、增加新的 return,或者所有权在多个函数之间传递,就容易产生:
使用 std::unique_ptr 后,析构过程由对象生命周期自动管理:
1
2
3
4
5
6
7
8
9
| void process() {
auto model = std::make_unique<Model>();
if (!model->load()) {
return;
}
run(*model);
} // 自动析构 Model
|
2. std::unique_ptr:独占所有权
std::unique_ptr<T> 表示:当前资源只有一个所有者。它不能复制,只能移动。
1
2
3
4
5
6
7
8
9
| #include <memory>
auto model = std::make_unique<Model>("model.onnx");
// 错误:不能复制独占所有权
// auto model2 = model;
// 正确:转移所有权
auto model2 = std::move(model);
|
移动后,model 变为空指针,资源由 model2 管理。
函数接口如何表达所有权
1
2
3
4
| void observe(const Model& model); // 只使用,不接管
void observe(const Model* model); // 可为空的观察者
void consume(std::unique_ptr<Model> model); // 接管所有权
std::unique_ptr<Model> createModel(); // 返回所有权
|
如果函数只临时使用对象,不要为了“看起来安全”而传递智能指针;使用引用或裸指针作为非拥有型观察者即可。
数组
1
2
| auto buffer = std::make_unique<float[]>(1024);
buffer[0] = 1.0f;
|
现代代码更推荐 std::vector<float>,因为它同时保存长度信息,也更方便传给算法接口。
自定义删除器
智能指针不只能管理 new 创建的内存,还能管理文件、系统句柄或 C API 资源:
1
2
3
4
5
6
7
8
| #include <cstdio>
#include <memory>
using FilePtr = std::unique_ptr<FILE, decltype(&std::fclose)>;
FilePtr openFile(const char* path) {
return FilePtr(std::fopen(path, "rb"), &std::fclose);
}
|
对于 ONNX Runtime、OpenVINO 或相机 SDK,也可以用同样方式把对应的释放函数放入删除器。
cv::Mat、std::vector、std::string 本身已经使用 RAII,不需要再套一层智能指针。
3. std::shared_ptr:共享所有权
std::shared_ptr<T> 允许多个对象共同拥有同一资源。最后一个所有者销毁时,资源才会释放。
1
2
3
4
5
6
| auto session = std::make_shared<InferenceSession>();
auto worker1 = session;
auto worker2 = session;
std::cout << session.use_count() << '\n';
|
它通常包含两个部分:
- 指向实际对象的指针;
- 控制块,其中保存强引用计数、弱引用计数和删除器。
优先使用 std::make_shared:
1
| auto session = std::make_shared<InferenceSession>(config);
|
这通常能把对象和控制块放在一次内存分配中,同时避免构造过程中的异常安全问题。
不要因为“不确定”就使用 shared_ptr
引用计数会增加内存占用,并带来原子计数操作。更重要的是,它容易掩盖本应明确的生命周期设计。只有资源确实需要被多个长期存在的对象共同拥有时,才应该使用它。
线程安全不等于对象安全
不同 shared_ptr 实例对同一控制块进行复制和销毁通常是线程安全的,但它指向的对象并不会因此自动变成线程安全:
1
2
3
4
| auto model = std::make_shared<Model>();
// 引用计数可以安全变化,
// 但多个线程同时调用 model->run() 是否安全,仍由 Model 决定。
|
4. std::weak_ptr:观察共享对象
std::weak_ptr<T> 不增加强引用计数,用来观察由 shared_ptr 管理的对象。
1
2
3
4
5
6
7
| std::weak_ptr<InferenceSession> weakSession = session;
if (auto locked = weakSession.lock()) {
locked->run();
} else {
// 对象已经被释放
}
|
不要先调用 expired() 再使用对象,因为两次操作之间对象仍可能被其他线程释放。直接调用 lock(),得到一个临时 shared_ptr。
解决循环引用
1
2
3
4
5
6
7
8
9
| struct Child;
struct Parent {
std::shared_ptr<Child> child;
};
struct Child {
std::weak_ptr<Parent> parent;
};
|
如果 Parent 和 Child 都使用 shared_ptr 指向对方,它们的引用计数永远不会归零。将不负责拥有对方的一侧改成 weak_ptr,即可打破循环。
5. 常见错误
用同一个裸指针创建多个 shared_ptr
1
2
3
4
| auto* raw = new Model();
std::shared_ptr<Model> a(raw);
std::shared_ptr<Model> b(raw); // 错误:两个独立控制块,会重复释放
|
正确做法是复制已有的 shared_ptr:
1
2
| auto a = std::make_shared<Model>();
auto b = a;
|
随意调用 get()
1
| Model* raw = model.get(); // 只借用,不拥有
|
get() 返回的裸指针不能被 delete,也不应该被保存到超过智能指针生命周期的位置。
混淆 release() 和 reset()
1
2
3
4
5
6
7
| auto ptr = std::make_unique<Model>();
Model* raw = ptr.release(); // 放弃所有权,不释放对象
delete raw;
ptr.reset(new Model()); // 释放旧对象并接管新对象
ptr.reset(); // 释放并置空
|
除非必须把资源交给接管所有权的旧式 C API,否则应谨慎使用 release()。
对 this 重新创建 shared_ptr
如果对象已经由 shared_ptr 管理,不要写:
1
| std::shared_ptr<Model> self(this); // 可能形成第二个控制块
|
需要安全获得自身的 shared_ptr 时,应继承 std::enable_shared_from_this<T>:
1
2
3
4
5
6
| class Model : public std::enable_shared_from_this<Model> {
public:
std::shared_ptr<Model> self() {
return shared_from_this();
}
};
|
6. C ABI 推理库中的用法
C++ 智能指针不能直接跨 C ABI 导出,也不适合直接暴露给 C# P/Invoke。常见做法是对外提供不透明句柄,对内使用 RAII。
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
27
28
29
30
31
32
33
| class Runtime {
public:
explicit Runtime(const char* modelPath);
void detect(const unsigned char* data, int width, int height);
};
extern "C" {
void* CreateRuntime(const char* modelPath) {
try {
return new Runtime(modelPath);
} catch (...) {
return nullptr;
}
}
void Detect(void* handle,
const unsigned char* data,
int width,
int height) {
if (!handle) {
return;
}
auto* runtime = static_cast<Runtime*>(handle);
runtime->detect(data, width, height);
}
void DestroyRuntime(void* handle) {
delete static_cast<Runtime*>(handle);
}
}
|
在 DLL 内部,Runtime 可以继续使用智能指针管理模型资源:
1
2
3
4
5
| class Runtime {
private:
std::unique_ptr<Ort::Session> session_;
std::vector<float> inputBuffer_;
};
|
关键原则是:在哪个模块创建,就在哪个模块释放。C# 只保存句柄,并确保最终调用 DestroyRuntime;不要让 C# 直接释放由 C++ DLL 分配的对象。
如果一个模型会被多个推理任务长期共享,可以在内部使用 shared_ptr<Session>;如果每个 Runtime 独占自己的模型会话,则优先使用 unique_ptr。
7. 如何选择
| 需求 | 推荐类型 |
|---|
| 单一对象独占资源 | std::unique_ptr<T> |
| 多个对象共同决定资源生命周期 | std::shared_ptr<T> |
| 观察共享资源,不延长生命周期 | std::weak_ptr<T> |
| 必须可为空,但不拥有资源 | 裸指针 T* |
| 必须存在且不拥有资源 | 引用 T& |
| 连续动态数组 | std::vector<T> |
实际工程中可以遵循一个简单顺序:
- 默认让对象直接按值存在;
- 必须动态分配时,优先
unique_ptr; - 确实存在共享所有权时,才使用
shared_ptr; - 用
weak_ptr 表达非拥有关系并打破循环引用; - 除非接口明确转移所有权,否则裸指针只作为观察者。
智能指针真正解决的是资源生命周期和所有权表达问题。选对所有权模型,比单纯把 new/delete 替换成 shared_ptr 更重要。