C++ 智能指针:从 RAII 到工程实践

系统理解 unique_ptr、shared_ptr、weak_ptr 的所有权语义、常见陷阱,以及在 C ABI 和推理库中的正确用法。

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::Matstd::vectorstd::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';

它通常包含两个部分:

  1. 指向实际对象的指针;
  2. 控制块,其中保存强引用计数、弱引用计数和删除器。

优先使用 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;
};

如果 ParentChild 都使用 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>

实际工程中可以遵循一个简单顺序:

  1. 默认让对象直接按值存在;
  2. 必须动态分配时,优先 unique_ptr
  3. 确实存在共享所有权时,才使用 shared_ptr
  4. weak_ptr 表达非拥有关系并打破循环引用;
  5. 除非接口明确转移所有权,否则裸指针只作为观察者。

智能指针真正解决的是资源生命周期和所有权表达问题。选对所有权模型,比单纯把 new/delete 替换成 shared_ptr 更重要。

记录工业视觉、C++ 与端侧 AI 部署实践