C++ 智能指针的所有权与使用

文章目录

    • [1. RAII 把资源释放放进对象生命周期](#1. RAII 把资源释放放进对象生命周期)
    • [2. unique_ptr 表达独占所有权](#2. unique_ptr 表达独占所有权)
      • [2.1 移动改变所有者,不搬动被管理对象](#2.1 移动改变所有者,不搬动被管理对象)
      • [2.2 get、release 和 reset 的责任不同](#2.2 get、release 和 reset 的责任不同)
      • [2.3 创建方式与 const](#2.3 创建方式与 const)
    • [3. shared_ptr 共享生命周期,不复制被管理对象](#3. shared_ptr 共享生命周期,不复制被管理对象)
      • [3.1 一个资源只能建立一次初始接管关系](#3.1 一个资源只能建立一次初始接管关系)
      • [3.2 make_shared 的作用与取舍](#3.2 make_shared 的作用与取舍)
    • [4. weak_ptr 观察存活状态,lock 临时取得所有权](#4. weak_ptr 观察存活状态,lock 临时取得所有权)
    • [5. 释放方式必须与资源类型匹配](#5. 释放方式必须与资源类型匹配)
      • [5.1 动态数组](#5.1 动态数组)
      • [5.2 文件与自定义删除器](#5.2 文件与自定义删除器)
    • [6. 小结](#6. 小结)

在前面实现容器时,我们已经多次遇到了"谁申请、谁释放"的情况。到了实际程序里,创建对象和使用对象往往发生在不同函数中:函数可以返回对象,容器可以保存对象,几个模块也可能共同使用对象。此时,只有一个地址远远不够,还必须约定谁承担释放责任。

这种释放责任就是本文所说的所有权。智能指针把所有权规则放进类的构造、复制、移动和析构中,让编译器参与检查,让对象生命周期负责收尾。

1. RAII 把资源释放放进对象生命周期


RAII是ResourceAcquisition Is Initialization的缩写,他是一种管理资源的类的设计思想,本质是一种利用对象生命周期来管理获取到的动态资源,避免资源泄漏,这里的资源可以是内存、文件指针、网络连接、互斥锁等等。

普通局部指针离开作用域,只会结束指针变量本身的生命周期,不会自动 delete 它指向的对象:

cpp 复制代码
// 问题片段:DoWork() 可能抛异常,delete 就可能执行不到。
Widget* p = new Widget;
DoWork();
delete p;

提前 return 也有相似问题。若有两次申请,第二次 new 失败时,第一次申请的资源还需要有人处理。但如果给每条路径补释放语句,就会让清理逻辑与业务逻辑相互纠缠在一起。

RAII(Resource Acquisition Is Initialization),通常译作"资源获取即初始化"。常见的做法是:让一个对象在初始化时取得资源,析构时释放资源 。例如,vector 替我们管理它的动态数组;智能指针则适合管理需要独立生命周期的动态对象。

下面是完整程序。throw 抛出异常后,程序寻找对应的 catch;在这里,从 Work 传播到 main 的过程中,已经构造成功的局部对象会被析构,这个过程称为栈展开。

cpp 复制代码
#include <iostream>
#include <memory>
#include <stdexcept>  

struct Resource
{
    int id;
    explicit Resource(int n) 
        : id(n) 
    { 
        std::cout << "open " << id << std::endl; 
    }
    ~Resource() 
    { 
        std::cout << "close " << id << std::endl; 
    }
};

void Work(bool fail) 
{
    // unique_ptr:独占式智能指针,离开作用域自动释放资源
    std::unique_ptr<Resource> first(new Resource(1));
    std::unique_ptr<Resource> second(new Resource(2));

    // 抛出异常,函数提前退出
    if (fail) throw std::runtime_error("work failed");
    std::cout << "work done" <<std::endl;
}

int main() 
{
    try {
        Work(true);
    }
    catch (const std::exception& e) {
        // 捕获抛出的异常
        std::cout << "caught: " << e.what() << std::endl;
    }
}

运行示例:

两个 unique_ptr 按构造的逆序析构,分别释放资源。释放发生在进入外层 catch 之前。若第二个资源申请失败,第一个智能指针已经构造完成,也能在相应栈展开中清理第一个资源。

RAII 依赖于对象的正常析构;进程被强制结束、调用某些直接终止进程的接口等情况,不属于它能保证完成清理的范围。析构和删除器也应避免向外抛异常,尤其不能在异常传播中再抛出另一个异常。

2. unique_ptr 表达独占所有权


<memory> 中的 std::unique_ptr<T> 管理一个对象,默认用 delete 释放它。

"独占"表示的是同一份资源只有一个这样的所有者;它允许别人临时访问,但不允许随意再造一个所有者。

cpp 复制代码
std::unique_ptr<Date> p(new Date(2026, 10, 22));
if (p) {
    p->_day = 23;
}
// std::unique_ptr<Date> q = p; // 错误:复制被禁止。

*p、p->member 沿用普通指针的访问习惯,空指针仍然不能解引用。

2.1 移动改变所有者,不搬动被管理对象

前一组 C++11 文章已经讲过:std::move 本身只是转换表达式的值类别,实际动作由移动操作完成。下面的完整程序将同时比较移动赋值、观察指针和主动交出资源。

cpp 复制代码
#include <iostream>
#include <memory>
#include <utility> 

struct Item
{
    int id;
    // 显式构造函数,并防止隐式类型转换
    explicit Item(int n) : id(n) {}
    // 析构函数,打印销毁信息,观察对象生命周期
    ~Item() { std::cout << "destroy " << id << std::endl; }
};

int main() 
{
    // 输出bool值以true/false形式打印
    std::cout << std::boolalpha;

    // 创建两个unique_ptr,分别管理Item(1)、Item(2)堆对象
    std::unique_ptr<Item> a(new Item(1));
    std::unique_ptr<Item> b(new Item(2));

    // observer保存a管理的原始裸指针,仅用于观察,不负责内存释放
    Item* observer = a.get();

    // 移动语义:把a的管理权转移给b
    // 原来b持有的Item(2)会被自动销毁;a变为空智能指针
    b = std::move(a);

    // 判断a是否为空,打印b管理对象的id
    std::cout << "a empty: " << !a << ", b: " << b->id << std::endl;
    // 比较裸指针,验证observer和b现在指向同一个对象
    std::cout << "same object: " << (observer == b.get()) << std::endl;

    // release:放弃b对对象的管理权,返回原始裸指针
    // release不会释放内存,内存释放责任交给调用者
    Item* raw = b.release();
    // b已经变成空指针;raw仍然指向原来的Item(1)
    std::cout << "b empty: " << !b << ", released: " << raw->id << std::endl;

    // 手动释放release返回的堆内存
    delete raw; 

    // reset:让b接管新堆对象Item(3)
    b.reset(new Item(3));
    // 不带参数reset:释放b当前管理的对象,b置空
    b.reset();

    return 0;
}

运行示例:

  • destroy 2:执行b = std::move(a),b 原本持有 Item (2),unique_ptr 赋值移动前会销毁原有托管对象,打印销毁信息。

  • a empty: true, b: 1:移动后 a 失去管理权变为空,b 接管原来 a 指向的 Item (1),输出 a 为空,b 托管对象 id=1。

  • same object: true:observer 保存的是最初 a.get () 拿到的裸指针,移动后 b 接管同一堆对象,两个裸指针地址相等,输出 true。

  • b empty: true, released: 1:b.release()让 b 放弃对象管理权,b 变成空指针;返回的 raw 指针仍然指向 Item (1),打印对应信息。

  • destroy 1:手动执行delete raw,释放 release 交出的堆对象 Item (1),触发析构打印。

  • destroy 3:b.reset()无参调用,释放 b 托管的 Item (3),触发析构打印;main 结束时 b 已是空智能指针,不会再有额外销毁输出。

b 原来管理对象 2,移动赋值时先处理它原有的资源,然后接管对象 1。对象 1 的地址没变,也没有调用 Item 的移动构造。
操作后 a.get() == nullptr,a 自身仍是有效的智能指针对象,可以判空、重新赋值和销毁。称其为空智能指针。

2.2 get、release 和 reset 的责任不同

接口 操作后谁负责资源 是否立即释放旧资源
p.get() 仍由 p 负责,返回的裸指针只供观察 否
p.release() p 放弃责任并变空,调用方必须接好责任 否
p.reset() p 变空 释放原有资源
p.reset(new T(...)) p 接管新资源 释放原有资源

release() 容易被误解为"释放内存",实际意思是交出所有权。

不要写 delete p.get()、p.reset(p.get())之类的代码,也不要用 p.get() 再构造一个独立所有者。这些写法都会破坏已有管理关系,进而导致指针悬空访问或重复释放。

2.3 创建方式与 const

cpp 复制代码
// make_unique 从 C++14 起提供。
auto p = std::make_unique<Date>(2026, 10, 22);

它把对象构造和独占管理写成一次调用,通常优先采用;

声明 能否改所有权关系 能否通过它修改 T
unique_ptr<T> 可以移动、reset 可以
const unique_ptr<T> 不能移动交出或 reset 可以
unique_ptr<const T> 可以移动、reset 不可以

智能指针的 const 与所指对象的 const 是两回事。这与 T* const、const T* 的区别相呼应。

3. shared_ptr 共享生命周期,不复制被管理对象


有些对象确实需要多个持有者共同维持生命期:一个模块先结束使用时,另一个模块还需要对象活着。这个时候就可以用 std::shared_ptr<T>。

cpp 复制代码
#include <iostream>
#include <memory>
#include <utility>

struct Date 
{
    int day;
    explicit Date(int n) : day(n) {}
    ~Date() { std::cout << "destroy Date " << day << std::endl; }
};

int main() 
{
    std::cout << std::boolalpha;

    // make_shared:在堆上创建Date对象,并用shared_ptr托管,引用计数初始 = 1
    auto a = std::make_shared<Date>(10);
    std::cout << "owners: " << a.use_count() << std::endl;

    // 局部作用域,b、c仅在这个大括号内有效
    {
        // shared_ptr 拷贝:a 和 b 共享同一个堆对象,引用计数 +1
        auto b = a;
        // 移动语义:把b的管理权转移给c;b变为空,引用计数不变(只是转移,不新增共享者)
        auto c = std::move(b);

        // use_count:查看当前共享该对象的智能指针数量
        std::cout << "owners: " << a.use_count() << ", b empty: " << !b << std::endl;

        // c修改共享对象的成员,a、c指向同一个对象,修改互相可见
        c->day = 11;
        std::cout << "a sees: " << a->day << std::endl;

        // a.reset():a放弃对对象的共享所有权;引用计数减1,a变成空shared_ptr
        a.reset();
        std::cout << "c owners: " << c.use_count() << std::endl;
    }
    // 离开局部作用域,c、b依次析构;c是最后一个持有者,Date对象在此处销毁
    std::cout << "scope ended" << std::endl;
    return 0;
}

运行示例:

  • b = a 复制了一份所有权,引用计数从 1 变为 2;

  • c 从 b 移动构造,接替 b 的那一份,计数仍为 2。

  • b 变空。修改 c->day 后,a 也看到 11,因为它们访问同一个 Date。

  • a.reset() 只撤销 a 的份额,不会影响 c 继续使用。最后 c 离开作用域,强引用计数降到 0,才销毁 Date。

引用计数不是对象成员的访问次数,也不是所有保存相同地址的变量数量。

3.1 一个资源只能建立一次初始接管关系

cpp 复制代码
// 错误示例片段
int* raw = new int(7);
std::shared_ptr<int> a(raw);
std::shared_ptr<int> b(raw); // 独立建立另一组管理信息,不会自动找到 a。

两个对象保存着相同的地址,却分别认为自己要负责最终释放,结果就是重复释放的未定义行为。正确的方式应该是复制已有的 shared_ptr:auto b = a;。

"相同地址"和"相同所有权组"不是同一件事。

3.2 make_shared 的作用与取舍

std::make_shared<T>(args...) 从 C++11 起提供,将参数转发给 T 的构造函数并返回 shared_ptr<T>。如果只需要单一所有者,应优先用独占指针,而不是为了能调用 make_shared 就选择共享。

对象与控制块合并分配也有取舍:最后一个强引用消失时,T 的析构函数照常执行;若仍有弱引用,合并分配的整块存储可能要等弱引用全部消失后才能回收。

函数 C++ 版本 返回类型 适用场景
make_unique<T>(args) C++14 unique_ptr<T> 独占所有权(单个所有者),首选
make_shared<T>(args) C++11 shared_ptr<T> 需要多个地方共享同一个对象,有引用计数开销

4. weak_ptr 观察存活状态,lock 临时取得所有权


裸指针不会增加共享计数,但对象销毁后,它也不能可靠地告诉我们"我现在已经失效"。std::weak_ptr<T> 关联已有的共享所有权组,能判断该组管理的对象是否仍有强所有者,却不让对象因为自己而继续存活。

它不能直接从 new T 接管资源,也没有 * 和 ->。访问前要调用 lock():对象仍存活时返回共享同一组所有权的 shared_ptr,否则返回空指针。这里的 lock 表示尝试取得强引用,不是给对象数据加互斥锁。

cpp 复制代码
#include <iostream>
#include <memory>
#include <string>

int main() 
{
    std::cout << std::boolalpha;

    // 创建shared_ptr,管理字符串"hello",引用计数初始 = 1
    auto a = std::make_shared<std::string>("hello");
    // 拷贝shared_ptr,引用计数+1,现在a、b共享对象,计数=2
    auto b = a;
    // weak_ptr 弱引用,只观察对象,不增加引用计数,不会阻止对象销毁
    std::weak_ptr<std::string> watch = a;

    // weak_ptr的use_count:查看当前托管对象的shared_ptr所有者数量
    std::cout << "owners: " << watch.use_count() << std::endl;

    // a放弃所有权,引用计数减1,此时只剩b持有,计数=1
    a.reset();
    {
        // lock():尝试把weak_ptr提升为shared_ptr;对象存活则返回有效shared_ptr,否则返回空
        auto hold = watch.lock();
        if (hold) {  
            // b放弃所有权,引用计数减1;现在hold是唯一持有者,计数 = 1
            b.reset();
            // 修改字符串内容
            *hold += "!";
            // hold.use_count():当前shared_ptr持有者数量
            std::cout << *hold << ", owners: " << hold.use_count() << std::endl;
        }
        // 离开局部作用域,hold析构,引用计数归零,字符串对象被销毁
    }

    // expired():判断weak_ptr观察的对象是否已经被销毁
    std::cout << "expired: " << watch.expired() << std::endl;

    // 再次lock,对象已经销毁,得到空shared_ptr
    auto empty = watch.lock();
    std::cout << "lock empty: " << !empty << std::endl;
}

运行示例:

  • watch 不增加计数。a.reset() 后,b 仍持有字符串;

  • hold = watch.lock() 又取得一个份额。即使随后 b.reset(),hold 仍保证这段访问期间字符串存活。

  • hold 离开作用域后,最后一个强引用消失,watch 才过期。

说 weak_ptr "完全不支持 RAII"也不准确。它不负责延长被观察对象的生命,却仍通过自身的构造和析构维护对管理信息的弱引用。

5. 释放方式必须与资源类型匹配


5.1 动态数组

unique_ptr<T> 默认 delete,unique_ptr<T[]> 默认 delete[],数组形式提供下标访问,不提供单对象形式的 *、->。

cpp 复制代码
#include <iostream>
#include <memory>

int main() 
{
    // unique_ptr管理动态数组,使用数组版本unique_ptr,会自动调用delete[]释放内存
    std::unique_ptr<int[]> values(new int[3] {1, 2, 3});
    // unique_ptr数组支持[]下标访问
    std::cout << "array sum: " << values[0] + values[1] + values[2] << std::endl;

    // C++11中shared_ptr默认使用delete释放,管理数组必须自定义删除器,调用delete[]
    auto delete_array = [](int* p) {
        std::cout << "delete shared array" << std::endl;
        delete[] p; 
    };

    // shared_ptr托管动态int数组,第二个参数传入自定义lambda删除器
    std::shared_ptr<int> shared(new int[2] {4, 5}, delete_array);
    // shared_ptr不支持[]运算符,要用.get()获取裸指针,再下标访问
    std::cout << "shared array sum: " << shared.get()[0] + shared.get()[1] << std::endl;
}

运行示例:

版本区别放在使用点记住即可:

写法 支持版本与用途
unique_ptr<T[]>(new T[n]) C++11,独占数组
make_unique<T[]>(n) C++14,创建独占数组
shared_ptr<T[]>(new T[n]) C++17,共享数组并支持 []

把 new T[n] 交给默认 delete 的单对象指针,是释放方式不匹配的未定义行为。

5.2 文件与自定义删除器

文件流关闭时调用 fclose。下面的完整程序使用临时文件,检查打开失败的情况:

cpp 复制代码
#include <cstdio>
#include <iostream>
#include <memory>

struct FileCloser 
{
    void operator()(std::FILE* p) const noexcept 
    {
        if (p) 
            fclose(p);
    }
};
int main() 
{
    // 不依赖工作目录中已有某个文件;关闭后临时文件由系统移除。
    std::unique_ptr<std::FILE, FileCloser> file(tmpfile());
    if (!file) 
    {
        std::cerr << "cannot open temporary file" << std::endl;
        return 1;
    }
    if (fputs("42\n", file.get()) == EOF) 
        return 2;
    rewind(file.get());
    int value = 0;
    if (fscanf(file.get(), "%d", &value) != 1) 
        return 3;
    std::cout << "file value: " << value << std::endl;
}

临时文件创建与读写成功时,输出:

对象在析构时会调用 FileCloser。

删除器形式 unique_ptr 的典型写法(片段) shared_ptr 的典型写法(片段)
仿函数 D unique_ptr<T, D> p(raw, D{}); shared_ptr<T> p(raw, D{});
函数指针 Free unique_ptr<T, decltype(&Free)> p(raw, &Free); shared_ptr<T> p(raw, &Free);
lambda 变量 del unique_ptr<T, decltype(del)> p(raw, del); shared_ptr<T> p(raw, del);

unique_ptr 的删除器类型属于智能指针类型的一部分;shared_ptr 的智能指针类型不因删除器不同而改变,删除行为由控制块保存。

需要注意的是:shared_ptr<T>(raw, del) 即使 raw 是空的,也可以创建计数为 1 的所有权组,并在最终释放时调用 del(nullptr)。因此用于 shared_ptr<FILE> 的文件删除器尤其应妥善处理空值。

6. 小结


  • std::unique_ptr:独占所有权,同一时刻只能有一个指针管理资源;

  • std::shared_ptr:共享所有权,通过引用计数管理资源;多个指针可持有同一资源,计数归零时释放资源;

  • std::weak_ptr:弱引用,不增加引用计数,仅观察shared_ptr管理的资源;使用lock()可提升为shared_ptr,用于解决循环引用问题。

  • 自定义删除器:可定制资源释放逻辑;unique_ptr模板参数携带删除器类型;shared_ptr支持类型擦除,即使传入空裸指针也会执行删除器。

  • 数组支持:unique_ptr<T[]>C++11 可用,支持下标访问;shared_ptr<T[]>C++17 才支持,早期版本管理数组需自定义删除器。

详细信息可进一步查阅:

相关推荐
砚底藏山河1 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
IT召唤狮1 小时前
开源企业级零信任项目Pangolin,零信任能挡住PHP核弹漏洞吗?
开发语言·开源·php
知已难寻1 小时前
03 · OpenStack的计算管理 Nova
运维·服务器·开发语言·云原生·云计算·openstack·开源软件
m0_719640871 小时前
2026年高校科研机构轮式双臂机器人二次开发平台搭建测评指南
经验分享·机器人
Rover Ramble1 小时前
C++ 用互斥锁实现读写锁
开发语言·c++
你怎么知道我是队长2 小时前
C++零基础入门第2周:数据类型、运算符与表达式完全指南(附20段可运行代码+避坑清单)
c++
say_fall2 小时前
【Linux系统编程】Ext 文件系统:磁盘与存储、文件与目录、现代计算机究竟如何寻找文件
linux·运维·服务器·数据库·c++
码流子2 小时前
移动收费怎么做?手持终端+App+收费中心的架构实践
java·开发语言·架构
雪落漂泊2 小时前
C++算法 滑动窗口篇(下)
开发语言·c++