C++ weak_ptr

在 C++ 中,std::weak_ptr 是 C++11 标准引入的一种 “弱引用智能指针”,专门用来配合 std::shared_ptr 一起使用。如果说每一个 shared_ptr 都是堆对象的 “共同管理者”,那么 weak_ptr 就只是一个站在旁边的 “旁观者”。

它可以指向由 shared_ptr 管理的堆对象,但不会增加对象的引用计数。也就是说,weak_ptr 既不会阻止目标对象销毁,也不会延长它的存活时间,常用于打破循环引用或临时观察对象。

shared_ptr 的循环引用问题

在使用 weak_ptr 之前,我们先来看看为什么只靠 std::shared_ptr 会遇到麻烦。shared_ptr 能够自动释放堆内存的前提是:当所有外部管理指针都离开作用域后,对象的引用计数必须顺利降为 0。

但是,如果两个类对象 A 和 B 内部各自包含一个 shared_ptr 成员变量,并且让它们互相指向对方,就会形成 “循环引用”。

此时哪怕函数执行结束、外部的局部智能指针都已经销毁,A 和 B 内部的成员变量依然互相持有对方,导致双方的引用计数都卡在 1 无法归 0。下面我们来看一个例子。

示例 1:循环引用导致泄漏

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

class Node {
private:
    std::string name;

public:
    std::shared_ptr<Node> partner;    // 指向伙伴节点

    Node(const std::string& n) : name(n) {
        std::cout << "创建节点:" << name << "\n";
    }

    ~Node() {
        std::cout << "销毁节点:" << name << "\n";
    }
};

int main() {
    auto first = std::make_shared<Node>("A");
    auto second = std::make_shared<Node>("B");

    // 两个节点互相指向对方
    first->partner = second;
    second->partner = first;

    std::cout << "A 的引用计数:" << first.use_count() << "\n";
    std::cout << "B 的引用计数:" << second.use_count() << "\n";
    return 0;
}

运行结果如下。

创建节点:A
创建节点:B
A 的引用计数:2
B 的引用计数:2

分析:

从运行结果可以看出,直到 main() 函数执行结束,控制台都没有输出 “销毁节点:A” 和 “销毁节点:B”,这两个节点的堆内存发生了内存泄漏。

为什么引用计数会降不到 0 呢?以节点 A 为例,它的 2 个引用计数分别来自栈上的局部指针 first,以及节点 B 内部的成员变量 second->partner。我们可以按先后顺序把引用计数的变化拆成以下 3 步来看:

  • 创建两个节点:栈上的 first 指向节点 A,second 指向节点 B,此时 A 和 B 的引用计数都为 1。
  • 互相指向对方:A 内部的 partner 指向了 B,B 内部的 partner 也指向了 A,此时 A 和 B 的引用计数都变成了 2。
  • 离开函数作用域:栈上的局部指针 second 和 first 先后销毁,使 B 和 A 的引用计数各自减 1。但堆上 B 内部的 partner 还指着 A,A 内部的 partner 也还指着 B,导致双方的计数都停留在 1 无法归 0。

到了第 3 步,要想销毁节点 A,必须先销毁节点 B 来释放它内部的 partner。可要想销毁节点 B,又必须先销毁节点 A。结果双方都在等对方先松手,两块内存就这样锁死在了堆上,谁也释放不了。

使用 weak_ptr 打破循环引用

想要解决上面的循环引用问题,方法其实非常简单:我们只需要把节点内部用来互相指向的 shared_ptr 改成 std::weak_ptr 就可以了。因为 weak_ptr 只做观察、不增加引用计数,自然就不会形成互相锁死的环路。

语法:

std::weak_ptr<类型> 弱指针名;
弱指针名 = 共享指针名;

说明:

std::weak_ptr 同样定义在 <memory> 头文件中,它不能直接接收普通裸指针。我们可以先定义一个空的 weak_ptr 再用 shared_ptr 赋值,也可以在定义时直接写成 std::weak_ptr<类型> wp = sp 完成绑定,绑定后目标对象的引用计数不会增加。

下面我们保持示例 1 的代码结构不变,只把 Node 类内部的成员变量 partner 改成 std::weak_ptr<Node>,看看运行结果会发生什么变化。

示例 2:打破循环引用

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

class Node {
private:
    std::string name;

public:
    std::weak_ptr<Node> partner;    // 改为弱引用指针

    Node(const std::string& n) : name(n) {
        std::cout << "创建节点:" << name << "\n";
    }

    ~Node() {
        std::cout << "销毁节点:" << name << "\n";
    }
};

int main() {
    auto first = std::make_shared<Node>("A");
    auto second = std::make_shared<Node>("B");

    first->partner = second;
    second->partner = first;

    std::cout << "A 的引用计数:" << first.use_count() << "\n";
    std::cout << "B 的引用计数:" << second.use_count() << "\n";
    return 0;
}

运行结果如下。

创建节点:A
创建节点:B
A 的引用计数:1
B 的引用计数:1
销毁节点:B
销毁节点:A

分析:

把 Node 类的成员变量 partner 换成 std::weak_ptr<Node> 后,两个节点互相赋值时只是建立了弱引用观察关系,不会增加引用计数,因此节点 A 和 B 的引用计数都保持为 1。

当 main() 执行结束时,栈上的局部指针会按 “后进先出” 的顺序依次销毁。后创建的 second 先销毁,使节点 B 的计数降为 0 并输出 “销毁节点:B”。紧接着 first 销毁,节点 A 的计数也降为 0 并输出 “销毁节点:A”。

使用 lock() 安全访问对象

在使用 weak_ptr 时,有一个重要规则需要记住:weak_ptr 没有重载 -> 和 * 运算符。我们不能像使用 shared_ptr 那样直接写 wp->show() 或 *wp 去访问对象,下面这种写法在编译时会直接报错:

auto sp = std::make_shared<Course>("C++ 教程");
std::weak_ptr<Course> wp = sp;
wp->show();    // 编译报错

初学的小伙伴可能会觉得奇怪:“为什么 C++ 不允许 weak_ptr 直接解引用呢?”

原因其实很好理解:weak_ptr 并不参与管理对象的生命周期,它观察的那个堆对象随时可能已经被最后一个 shared_ptr 销毁了。如果允许直接解引用,一旦对象已经销毁,就会像访问悬空指针一样导致程序崩溃。

因此,如果想要通过 weak_ptr 访问目标对象,我们必须先调用 lock() 成员函数检查对象是否还存在,并获取一个临时的 shared_ptr。

语法:

std::shared_ptr<类型> 临时指针 = 弱指针名.lock();

说明:

调用 lock() 会尝试获取所观察对象的 shared_ptr,存在以下 2 种返回情况:

  • 目标对象还存在:返回一个有效的 shared_ptr,并在该临时指针存活期间将引用计数加 1,保证访问过程中对象不会被销毁。
  • 目标对象已销毁:返回一个空指针 nullptr。

此外,weak_ptr 还提供了 expired() 成员函数,用来单纯判断所观察的对象是否已经销毁。其中 expired 是 “过期” 的意思:如果对象已经销毁,则返回 true;如果对象还存在,则返回 false。下面我们结合例子来看看具体用法。

示例 3:通过 lock() 访问对象

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

class Course {
private:
    std::string name;

public:
    Course(const std::string& n) : name(n) {
        std::cout << "创建课程:" << name << "\n";
    }

    ~Course() {
        std::cout << "销毁课程:" << name << "\n";
    }

    void show() const {
        std::cout << "正在学习:" << name << "\n";
    }
};

void viewCourse(const std::weak_ptr<Course>& wp) {
    std::shared_ptr<Course> temp = wp.lock();
    if (temp != nullptr) {
        temp->show();
    } else {
        std::cout << "目标对象已销毁,无法访问\n";
    }
}

int main() {
    auto sp = std::make_shared<Course>("C++ 教程");
    std::weak_ptr<Course> wp = sp;

    viewCourse(wp);    // 对象存活时访问
    sp.reset();        // 重置 sp,销毁对象

    if (wp.expired()) {
        std::cout << "wp.expired():对象已过期\n";
    }

    viewCourse(wp);    // 对象销毁后再次访问
    return 0;
}

运行结果如下。

创建课程:C++ 教程
正在学习:C++ 教程
销毁课程:C++ 教程
wp.expired():对象已过期
目标对象已销毁,无法访问

分析:

在这个例子中,viewCourse() 函数先调用 wp.lock() 获取临时的共享指针 temp,确认 temp != nullptr 后再调用 temp->show()。

第一次调用 viewCourse(wp) 时,sp 依然持有 Course 对象,访问期间引用计数暂时从 1 升为 2。等函数执行完毕,局部指针 temp 自动销毁,引用计数又退回 1。

随后执行 sp.reset() 放弃唯一的管理权,Course 对象立即销毁。因此后面的 wp.expired() 返回 true,第二次调用 wp.lock() 也返回了空指针 nullptr。

在实际开发中,我们经常把 “获取临时指针” 和 “判断是否为空” 合并写在 if 的条件括号里。对于这个例子来说,下面 2 种方式是等价的:

// 方式 1
std::shared_ptr<Course> temp = wp.lock();
if (temp != nullptr) {
    temp->show();
}

// 方式 2(推荐)
if (auto temp = wp.lock()) {
    temp->show();
}

C++ 支持在 if 条件括号中直接定义指针变量,非空指针会自动判为 true,空指针则判为 false。方式 2 不仅写法更紧凑,还能把 temp 限制在 if 代码块内,访问结束走出花括号就会立即销毁。

注意 访问目标对象时,我们应该直接调用 lock() 获取临时指针并判空,不要拆成 “先判断 !wp.expired(),再调用 wp.lock()” 两步。直接调用 lock() 可以一步完成 “检查是否存在” 与 “锁定对象” 两个动作,既省事又更安全可靠。

三种智能指针选型对比

到这里,C++ 标准库提供的 3 种智能指针我们就全部学完了,它们的使用差异与选型对比如下表所示。

三种智能指针对比
对比维度 std::unique_ptr std::shared_ptr std::weak_ptr
所有权关系 独占所有权(唯一管理者) 共享所有权(多个管理者) 无所有权(仅作旁观者)
创建方式 优先使用 std::make_unique 优先使用 std::make_shared 由已有的 shared_ptr 赋值
能否直接复制 不能复制,可通过 move 转移 可以复制,引用计数加 1 可以复制,引用计数不变
如何访问对象 直接使用 * 和 -> 直接使用 * 和 -> 不能直接解引用,先调 lock()
主要适用场景 日常管理动态对象的首选 多处确需共享同一对象 打破循环引用、临时观察

在实际开发中,我们可以遵循 “从轻到重” 的选型原则:默认优先使用开销最小的 std::unique_ptr。只有确定多处需要共享同一对象时,才改用 std::shared_ptr。如果共享对象之间存在双向引用,再把反向观察的那一端设为 std::weak_ptr。

常见问题

1. 两个不同的类存在双向关联时,应该把哪一方设为 shared_ptr,哪一方设为 weak_ptr?

遇到两个类互相引用的场景时,不能随便挑一边改成 weak_ptr,我们应该顺着 “谁拥有谁、谁决定谁的生命周期” 来判断:

主导生命周期的一方使用 shared_ptr:比如 “部门” 与 “员工” 的关系中,部门负责管理和持有旗下的员工对象,因此 Department 类内部指向 Employee 的指针应该使用 std::shared_ptr。

反向回看的一方使用 weak_ptr:员工对象有时需要查询自己所属的部门信息,但员工并不是部门的拥有者,不应该因为某个员工对象还在就阻止部门销毁,因此 Employee 类内部指回 Department 的指针应该使用 std::weak_ptr。

上一篇:

下一篇:

给站长反馈

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

邮箱:lvyenet@vip.qq.com

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