10、移动语义与智能指针:独占所有权、引用计数与循环引用

智能指针和移动语义,这两样东西放在一起讲,其实是有原因的。你想想看,std::unique_ptr 本身就是移动语义的完美体现——它不能被拷贝,只能被移动。而 std::shared_ptr 虽然可以拷贝,但它的引用计数更新机制,本质上也是在玩「所有权转移」的游戏。

我个人习惯把智能指针看作是「带策略的指针包装器」。不同的策略,决定了它们怎么管理资源、怎么传递所有权。今天我们就来拆开看看,这三种常见场景到底是怎么回事。

10.1 std::unique_ptr:独占所有权的移动

std::unique_ptr 的设计哲学很简单:一个资源,只能有一个主人。你不能复制它,因为复制意味着两个指针指向同一块内存,那谁负责释放?

但你可以移动它。移动之后,原来的指针就变成了空指针,新的指针接管资源。这就是独占所有权的精髓。

#include <memory>
#include <iostream>

class Resource {
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource released\n"; }
    void doWork() { std::cout << "Working...\n"; }
};

int main() {
    std::unique_ptr<Resource> ptr1 = std::make_unique<Resource>();
    // std::unique_ptr<Resource> ptr2 = ptr1; // 编译错误!不能拷贝
    
    std::unique_ptr<Resource> ptr2 = std::move(ptr1); // 移动,所有权转移
    if (!ptr1) {
        std::cout << "ptr1 is now empty\n";
    }
    ptr2->doWork();
    // 离开作用域时,ptr2 自动释放 Resource
    return 0;
}

我在项目中遇到过好几次这样的问题:有人试图把 unique_ptr 放进容器,然后不小心写了拷贝操作,编译报错后一脸懵。其实你只要记住——unique_ptr 只能移动,不能拷贝。想放进 vector?用 std::move 或者直接在容器里 emplace_back 构造。

小技巧: 函数返回 unique_ptr 时,不需要显式写 std::move。因为 C++11 开始,局部变量返回时会自动被视为右值,编译器会帮你做移动操作。这叫「返回值优化(RVO)」的延伸。

10.2 std::shared_ptr:引用计数的移动

shared_ptr 就温和多了。它允许多个指针共享同一块资源,内部维护一个引用计数。每当你拷贝一个 shared_ptr,计数加一;每当你销毁一个,计数减一。计数归零时,资源被释放。

但移动 shared_ptr 呢?移动不会增加引用计数。因为移动的本质是「把所有权从 A 转给 B」,A 不再指向资源,所以计数不变。这一点很多人会搞混。

#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<int> sp1 = std::make_shared<int>(42);
    std::cout << "sp1 use_count: " << sp1.use_count() << "\n"; // 1
    
    std::shared_ptr<int> sp2 = sp1; // 拷贝,计数 +1
    std::cout << "after copy: " << sp1.use_count() << "\n"; // 2
    
    std::shared_ptr<int> sp3 = std::move(sp1); // 移动,计数不变
    std::cout << "after move - sp1: " << (sp1 ? "not null" : "null") << "\n";
    std::cout << "after move - sp2 use_count: " << sp2.use_count() << "\n"; // 还是 2
    // sp1 已经空了,但 sp2 和 sp3 共享资源,计数为 2
    return 0;
}

我曾经在代码审查时看到有人写 std::move(shared_ptr) 之后,还去检查原指针的引用计数,发现没变,就以为移动没生效。其实移动生效了——原指针变成了空,但引用计数确实没变,因为资源没有被复制,只是换了个主人而已。

关键区别:
  • 拷贝 shared_ptr → 引用计数 +1
  • 移动 shared_ptr → 引用计数不变,原指针置空
  • 拷贝 unique_ptr → 不允许(编译错误)
  • 移动 unique_ptr → 所有权转移,原指针置空

10.3 移动与循环引用:shared_ptr 的陷阱

好了,现在来说一个坑——循环引用。说白了就是两个对象互相持有对方的 shared_ptr,导致引用计数永远降不到零,内存泄漏。

为什么会这样?假设 A 持有 B 的 shared_ptr,B 也持有 A 的 shared_ptr。当外部引用都释放后,A 的计数是 1(被 B 持有),B 的计数也是 1(被 A 持有)。谁也释放不了谁,死锁了。

#include <memory>
#include <iostream>

struct Node {
    std::shared_ptr<Node> next;
    ~Node() { std::cout << "Node destroyed\n"; }
};

int main() {
    std::shared_ptr<Node> a = std::make_shared<Node>();
    std::shared_ptr<Node> b = std::make_shared<Node>();
    
    a->next = b;
    b->next = a; // 循环引用!a 和 b 都不会被释放
    
    // 离开作用域时,不会打印 "Node destroyed"
    return 0;
}

我在项目中遇到过这种问题,当时是一个双向链表用了 shared_ptr,结果程序跑着跑着内存就涨上去了。排查了半天才发现是循环引用。

解决方案很简单:用 weak_ptr 打破循环。让其中一个方向使用弱引用,不增加计数。

#include <memory>
#include <iostream>

struct Node {
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev; // 用 weak_ptr 打破循环
    ~Node() { std::cout << "Node destroyed\n"; }
};

int main() {
    std::shared_ptr<Node> a = std::make_shared<Node>();
    std::shared_ptr<Node> b = std::make_shared<Node>();
    
    a->next = b;
    b->prev = a; // weak_ptr,不增加引用计数
    
    // 离开作用域时,a 和 b 都会被正确释放
    return 0;
}
注意: weak_ptr 不能直接访问资源。你需要调用 lock() 方法获取一个临时的 shared_ptr,然后才能使用。如果资源已经被释放,lock() 返回空指针。

10.4 知识体系图

下面这张图总结了智能指针与移动语义的核心关系,你可以对照着理解:

智能指针与移动语义核心关系 std::unique_ptr 独占所有权 只能移动,不能拷贝 移动后原指针置空 std::shared_ptr 共享所有权 拷贝时引用计数 +1 移动时引用计数不变 std::weak_ptr 弱引用,不增加计数 用于打破循环引用 需调用 lock() 访问 移动 拷贝/移动 循环引用场景 对象 A 持有 B 的 shared_ptr 对象 B 持有 A 的 shared_ptr 互相引用 → 内存泄漏 解决方案:用 weak_ptr 打破

嗯,这张图应该能帮你理清思路。简单来说:unique_ptr 是独生子,shared_ptr 是共享家庭,weak_ptr 是旁观者——不参与计数,只负责观察。

10.5 避坑指南

最后,分享几个我踩过的坑:

  • 不要用 auto_ptr:C++17 已经废弃了,它用拷贝语义模拟移动,坑很多。用 unique_ptr 替代。
  • 不要在 shared_ptr 的构造函数里传裸指针:如果你用 new 出来的指针构造两个 shared_ptr,它们会各自维护一个独立的引用计数,导致 double free。用 std::make_shared 最安全。
  • 循环引用一定要用 weak_ptr 打破:我曾经在树形结构中忘了这一点,结果内存泄漏查了两天。后来养成了习惯:父子关系中,父用 shared_ptr 指向子,子用 weak_ptr 指向父。
  • 移动后不要再使用原指针:除非你重新赋值。移动后的 unique_ptrshared_ptr 是空状态,解引用会崩溃。

好了,这一章的内容就到这里。智能指针和移动语义的结合,说白了就是 C++ 现代资源管理的核心。你只要记住「所有权」和「引用计数」这两个概念,大部分场景都能应对。


公众号:蓝海资料掘金营,微信deep3321