C++ 智能指针与 unique_ptr

手动使用 new 和 delete 管理堆内存时,一旦函数分支变多或中途提前返回,漏掉一句释放代码就会造成内存泄漏。为了既能灵活使用堆内存,又不用到处手写 delete,现代 C++ 提供了能够自动释放堆内存的 “智能指针(Smart Pointer)”。

C++ 智能指针简介

智能指针并不是一种新的底层地址语法,而是标准库基于 RAII 封装出来的管理类。它在创建时接管动态分配的堆对象,等智能指针本身离开作用域销毁时,就会在析构函数里自动把所管理的堆对象释放掉,同时又保留了普通指针的使用习惯。

从 C++11 标准开始,C++ 在 <memory> 头文件中提供了以下 3 种现成的智能指针:

  • std::unique_ptr:独占所有权,同一时刻只能有一个智能指针管理该对象,是日常管理动态对象的首选方案。
  • std::shared_ptr:共享所有权,内部通过引用计数记录管理者数量,适合多个地方共同持有同一个对象的场景。
  • std::weak_ptr:弱引用指针,只观察对象而不增加引用计数,专门配合 shared_ptr 解决循环引用问题。

在这 3 种智能指针中,std::unique_ptr 是实际开发中最常用、开销最小的一种 “独占式智能指针”。所谓的 “独占”,指的是在同一时刻,一块动态分配的堆内存只能归一个 unique_ptr 对象管。

C++ 三种智能指针的所有权区别

当这个独占的 unique_ptr 对象离开作用域销毁时,它所管理的堆对象也会跟着自动销毁,不需要我们再手写释放语句。

创建和使用 unique_ptr

在 C++ 中,如果想要使用 std::unique_ptr,我们需要先引入 <memory> 头文件。创建 unique_ptr 最推荐的方式,是调用 C++14 标准引入的 std::make_unique 函数。

语法:

std::unique_ptr<类型> 指针名 = std::make_unique<类型>(初始化参数);

说明:

  • std::make_unique<类型>(初始化参数):在堆上创建指定类型的对象,并返回管理它的 unique_ptr。尖括号 <> 内填写目标类型,圆括号 () 内填写初始值或构造参数。如果留空写成 std::make_unique<int>(),则默认初始化为 0。
  • 访问目标对象:unique_ptr 重载了 * 和 -> 运算符。我们可以通过 *指针名 解引用读写目标对象,也可以通过 指针名->成员 访问类对象的成员,用法与普通指针一致。

示例 1:管理基本类型数据

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> p = std::make_unique<int>(60);
    std::cout << "修改前:" << *p << "\n";

    *p = 85;     // 解引用修改堆上的值
    std::cout << "修改后:" << *p << "\n";

    return 0;    // 离开作用域自动释放
}

运行结果如下。

修改前:60
修改后:85

分析:

std::make_unique<int>(60) 表示在堆上创建了一个初值为 60 的整数,并交给智能指针 p 独占管理。随后我们通过 *p = 85 修改它的值,等 main() 执行结束时,p 会自动释放这块堆内存。

细心的小伙伴可能发现了,赋值号左边写了 std::unique_ptr<int>,右边又写了 std::make_unique<int>(60),类型 int 重复写了两遍。既然右边已经写明了类型,左边完全可以配合 auto 关键字来简化书写,下面 2 种方式是等价的:

// 方式 1
std::unique_ptr<int> p = std::make_unique<int>(60);

// 方式 2(推荐)
auto p = std::make_unique<int>(60);

在实际开发中,我们更推荐使用方式 2,后面的例子也都会采用这种简写形式。

另外,在一些早期代码中,有时也会见到直接把 new 传给智能指针的旧写法,比如 std::unique_ptr<int> p(new int(60))。平时写新代码时,优先使用 std::make_unique 会更加简洁安全。

接下来我们定义一个带两个构造参数的自定义类 Device,看看 std::make_unique 如何传递参数,以及智能指针离开作用域时自动触发析构函数的过程。

示例 2:管理自定义类对象

#include <iostream>
#include <memory>
#include <string>

class Device {
private:
    std::string name;
    int power;

public:
    Device(const std::string& n, int p) : name(n), power(p) {
        std::cout << "创建设备:" << name << "(" << power << "W)\n";
    }

    ~Device() {
        std::cout << "销毁设备:" << name << "\n";
    }

    void run() const {
        std::cout << name << "正在运行\n";
    }
};

int main() {
    auto dev = std::make_unique<Device>("路由器", 24);
    dev->run();
    (*dev).run();
    return 0;
}

运行结果如下。

创建设备:路由器(24W)
路由器正在运行
路由器正在运行
销毁设备:路由器

分析:

在这个例子中,std::make_unique<Device>("路由器", 24) 会把圆括号里的两个实参直接传递给 Device 类的构造函数,在堆上创建并初始化对象。

随后我们分别通过 dev->run() 和 (*dev).run() 调用成员函数,平时写代码直接使用箭头运算符 -> 会更加直观。当 main() 执行结束、dev 离开作用域时,它会自动调用析构函数输出 “销毁设备:路由器”。

转移所有权与函数传递

既然 unique_ptr 代表 “独占所有权”,就像一把唯一的房门钥匙,那么它就不支持像普通整数或普通指针那样直接复制。

原因其实很好理解:如果允许把 first 直接复制给 second,两个 unique_ptr 就会同时认为那块堆内存归自己管。等离开作用域时,它们会对同一个堆对象重复释放两次,直接引发程序崩溃。

因此,C++ 从语法上禁止了 unique_ptr 的复制操作,下面这种写法在编译时会直接报错:

auto first = std::make_unique<int>(80);
std::unique_ptr<int> second = first;    // 编译报错

1. 使用 std::move() 转移所有权

如果确实需要把堆对象转交给另一个 unique_ptr 管理,既然不能配第二把钥匙,我们就可以把手里这把唯一的钥匙直接 “移交” 给对方。在 C++ 中,我们可以使用 std::move() 来转移 unique_ptr 的所有权。

语法:

std::unique_ptr<类型> 新指针名 = std::move(原指针名);

说明:

std::move() 定义在 <utility> 头文件中,表示允许将原指针持有的对象管理权移交出去。移交完成后,新指针开始接管该堆对象,原指针则自动变为空指针 nullptr。关于移动操作的底层原理,下一章还会展开介绍,这里先掌握它的移交用法即可。

示例 3:转移独占所有权

#include <iostream>
#include <memory>
#include <utility>

int main() {
    auto first = std::make_unique<int>(80);

    // 转移所有权
    std::unique_ptr<int> second = std::move(first);

    if (first == nullptr) {
        std::cout << "first 已变为空指针\n";
    }
    std::cout << "second 管理的值:" << *second << "\n";

    return 0;
}

运行结果如下。

first 已变为空指针
second 管理的值:80

分析:

执行 std::move(first) 之后,原本由 first 独占管理的整数 80 移交给了 second,同时 first 自身变成了 nullptr。判断智能指针是否为空时,写 if (first == nullptr) 与 if (!first) 是等价的。

这里小伙伴们要注意两点:第一,堆上的整数 80 本身并没有复制成两份,转移的只是对它的管理权。第二,管理权一旦转走,原来的 first 就已经是空指针了,千万不要再写 *first 去解引用访问它,最后负责释放堆内存的也变成了 second。

2. 向函数传递 unique_ptr

初学 unique_ptr 时,很多小伙伴容易在函数传参上卡壳:如果把形参写成 std::unique_ptr<Device> ptr,调用时直接写 func(dev) 会编译报错,因为按值传参会尝试复制智能指针。

在实际开发中,我们可以根据函数 “要不要接管所有权” 分成以下 2 种情况处理:

  • 仅临时使用对象:如果函数只是读取或修改对象内容,并不接管它的销毁工作,形参直接写成对象的引用(如 const Device& 或 Device&),调用时传入 *dev 即可。
  • 彻底移交管理权:如果确实要把堆对象交给该函数全权接管,形参写成 std::unique_ptr<Device>,调用时通过 std::move(dev) 显式移交所有权。

示例 4:函数借用与接管对比

#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Device {
    std::string name;

    Device(const std::string& n) : name(n) {
        std::cout << "创建设备:" << name << "\n";
    }

    ~Device() {
        std::cout << "销毁设备:" << name << "\n";
    }

    void check() const {
        std::cout << "检查状态:" << name << "\n";
    }
};

// 仅临时使用对象
void inspectDevice(const Device& d) {
    d.check();
}

// 接管对象所有权
void recycleDevice(std::unique_ptr<Device> ptr) {
    std::cout << "已接管并准备回收\n";
}

int main() {
    auto dev = std::make_unique<Device>("摄像头");

    inspectDevice(*dev);              // 传入解引用后的对象
    recycleDevice(std::move(dev));    // 移交所有权

    std::cout << "回到 main() 末尾\n";
    return 0;
}

运行结果如下。

创建设备:摄像头
检查状态:摄像头
已接管并准备回收
销毁设备:摄像头
回到 main() 末尾

分析:

从运行结果可以看出,调用 inspectDevice(*dev) 时接收的是对象的常引用,只是临时借用一下对象,函数返回后 dev 依然持有该摄像头对象。

随后的 recycleDevice(std::move(dev)) 则把管理权彻底移交给了形参 ptr。因此当 recycleDevice() 执行结束时,摄像头对象直接跟着形参 ptr 一起销毁,“销毁设备:摄像头” 出现在了 “回到 main() 末尾” 之前。

3. 从函数中返回 unique_ptr

所有权转移还有一种非常实用的场景:在函数内部动态创建好对象,再将 unique_ptr 作为返回值交给调用者。当函数返回局部的 unique_ptr 时,编译器会自动移交所有权,我们既不需要手动写 std::move(),也不需要调用者手写释放代码。

示例 5:从函数返回 unique_ptr

#include <iostream>
#include <memory>
#include <string>

struct Device {
    std::string name;

    Device(const std::string& n) : name(n) {
        std::cout << "创建设备:" << name << "\n";
    }

    ~Device() {
        std::cout << "销毁设备:" << name << "\n";
    }

    void show() const {
        std::cout << "当前设备:" << name << "\n";
    }
};

std::unique_ptr<Device> createDevice(const std::string& name) {
    return std::make_unique<Device>(name);
}

int main() {
    auto myDev = createDevice("交换机");
    myDev->show();
    return 0;
}

运行结果如下。

创建设备:交换机
当前设备:交换机
销毁设备:交换机

分析:

把 createDevice() 的返回值设为 std::unique_ptr<Device> 后,函数返回时会自动将堆对象的管理权转交给 main() 里的 myDev,并在 main() 结束时自动销毁,再也不用担心外部调用者忘记释放内存了。

unique_ptr 常用成员函数

除了创建对象和转移所有权之外,unique_ptr 还提供了几个常用的成员函数,方便我们在函数结束前提前释放对象、更换管理目标或读取底层地址,如下表所示。

unique_ptr 常用成员函数
成员函数 功能说明
ptr.reset() 提前销毁当前管理的对象,并将 ptr 置为 nullptr
ptr.reset(new_ptr) 先销毁当前管理的旧对象,再接管 new_ptr 指向的新对象
ptr.get() 返回内部保存的底层裸指针,仅用于临时读取地址,不转移所有权
ptr.release() 放弃管理权并返回底层裸指针,同时将 ptr 置空,不会销毁对象

在这些成员函数中,平时用得最多的是 reset()。下面我们通过一个例子来看看它是如何提前释放或更换对象的。

示例 6:提前释放或更换对象

#include <iostream>
#include <memory>
#include <string>

struct Device {
    std::string name;

    Device(const std::string& n) : name(n) {
        std::cout << "创建设备:" << name << "\n";
    }

    ~Device() {
        std::cout << "销毁设备:" << name << "\n";
    }
};

int main() {
    auto dev = std::make_unique<Device>("路由器");

    std::cout << "1. 更换管理对象:\n";
    dev.reset(new Device("显示器"));

    std::cout << "2. 提前清空指针:\n";
    dev.reset();

    std::cout << "3. 准备退出 main()\n";
    return 0;
}

运行结果如下。

创建设备:路由器
1. 更换管理对象:
创建设备:显示器
销毁设备:路由器
2. 提前清空指针:
销毁设备:显示器
3. 准备退出 main()

分析:

在上面代码中,执行 dev.reset(new Device("显示器")) 时,dev 会先把原先管理的 “路由器” 对象销毁掉,再接管新创建的 “显示器” 对象。如果不想手写 new,我们也可以直接赋一个新的 make_unique,下面 2 种写法的效果是一样的:

// 方式 1(推荐)
dev = std::make_unique<Device>("显示器");

// 方式 2
dev.reset(new Device("显示器"));

随后执行无参的 dev.reset() 时,不等 main() 执行结束,程序就会立即销毁当前管理的 “显示器” 对象,并将 dev 置为空指针。因此 “销毁设备:显示器” 出现在了 “3. 准备退出 main()” 之前。

至于表格中的 get() 和 release(),它们主要用于和只接收普通裸指针的旧函数对接:

auto p = std::make_unique<int>(80);
int* raw1 = p.get();        // 仅读取底层地址
int* raw2 = p.release();    // 交出管理权并置空 p
delete raw2;                // 需手动释放

其中,p.get() 只是借出底层地址供外部临时读取,管理权仍在 p 手里,千万不要对 raw1 手动执行 delete。而 p.release() 则是彻底交出管理权,本身不会销毁堆对象,必须由接收方负责释放。

注意 不要把 reset() 和 release() 搞混了:reset() 会直接销毁堆对象并释放内存,而 release() 只是交出管理权,如果调用后没人接手释放就会造成内存泄漏。

在实际开发中,由于 unique_ptr 不需要维护额外的计数器,它的内存占用和运行效率几乎与普通裸指针一样。

只要动态对象在同一时刻只需要一个明确的管理者,我们都应该优先选择 unique_ptr。只有确定多个地方需要共同持有同一个对象时,才考虑使用 std::shared_ptr。

常见问题

1. unique_ptr 可以用来管理动态数组吗?

语法上是可以的,比如写成 auto arr = std::make_unique<int[]>(5),离开作用域时它会自动调用 delete[] 按数组方式释放内存。

不过 unique_ptr<int[]> 没有提供获取元素个数的成员函数,也不支持动态扩容。平时在项目里需要动态数组时,直接使用 std::vector 会方便很多,因此 unique_ptr 主要还是用于管理单个动态对象。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号