☰
深入解析C++中的智能指针
2026/10/22 2:51:04 网站建设 项目流程

前言


智能指针解决的核心问题是所有权:一块动态分配的内存到底由谁负责释放、什么时候释放。裸指针只表达「指向」,不表达「拥有」,于是删除责任只能靠约定和注释维持。一旦控制流提前返回、抛出异常,或者两个模块都以为自己不该释放,泄漏和重复释放就出现了。


围绕智能指针,常见的误解有四个。第一,认为「用了智能指针就不会泄漏」。shared_ptr的循环引用恰恰会造成稳定泄漏。第二,认为std::move会移动对象。它只做类型转换,真正的所有权转移由移动构造完成。第三,认为shared_ptr线程安全。它的控制块引用计数是线程安全的,但通过它访问被管理对象没有任何同步保证。第四,认为智能指针一定比裸指针慢或一定更快。空 deleter 的unique_ptr通常与裸指针同尺寸,而按值传递shared_ptr会带来引用计数的原子操作开销,具体取决于用法。


本文按unique_ptr、shared_ptr、weak_ptr三条线索展开,重点讲所有权模型、控制块、循环引用和enable_shared_from_this,最后给出常见错误的对照。示例以 C++17 为基准。


一、unique_ptr:独占所有权


std::unique_ptr<T>表示唯一所有权:同一时刻只有一个unique_ptr拥有对象,它不可拷贝、只可移动。析构时对持有的指针调用delete(或自定义删除器)。


创建对象优先用std::make_unique<T>(args...)(C++14 起)。它把分配和构造放在一个表达式里,避免「先new再传给构造函数」过程中抛异常导致泄漏,也让代码里不出现裸new。


// 适用于 C++17
auto p = std::make_unique<int>(42); // 单个对象
auto arr = std::make_unique<int[]>(10); // 动态数组,C++14 起支持

注意std::unique_ptr<T[]>是特化版本,析构时调用delete[],不能用delete去释放它。数组版本不提供operator*,用operator[]访问元素。


自定义删除器是unique_ptr的第二个模板参数。它可以管理任何「需要成对操作」的资源,比如FILE*:


// 适用于 C++17
#include <cstdio>
#include <memory>

struct FileCloser {
void operator()(std::FILE* f) const noexcept {
if (f != nullptr) std::fclose(f);
}
};

using FilePtr = std::unique_ptr<std::FILE, FileCloser>;

FilePtr open_file(const char* path) {
return FilePtr(std::fopen(path, "r"));
}

删除器的类型是unique_ptr类型的一部分,所以unique_ptr<T, D1>和unique_ptr<T, D2>是不同类型,不能互相赋值。删除器必须能对指针执行调用,并且不应抛出异常——析构函数默认是noexcept,删除器抛异常会直接std::terminate。


unique_ptr可以转移所有权:auto q = std::move(p);之后p变为空。不要读被移动后的p所指向的内容,它已经不再拥有任何对象。


二、shared_ptr:共享所有权与控制块


std::shared_ptr<T>表示共享所有权。它内部有两个指针:一个指向被管理对象,一个指向控制块(control block)。控制块里存放强引用计数、弱引用计数、删除器和分配器。



  • 拷贝shared_ptr使强引用计数加一,销毁使计数减一;计数归零时销毁被管理对象。

  • 控制块的引用计数操作是原子的,所以多个线程各自持有并拷贝/销毁不同的shared_ptr实例是安全的。

  • 但被管理对象本身的读写没有任何保护,多个线程通过同一个shared_ptr修改对象仍然是数据竞争。


创建方式优先std::make_shared<T>(args...)。它把对象和控制块分配在一次分配里,通常比shared_ptr<T>(new T)少一次分配,缓存局部性也更好。


用同一个裸指针构造两个shared_ptr是典型错误:


// 适用于 C++17
// ❌ 两个独立控制块,析构时同一块内存被 delete 两次
int* raw = new int(1);
std::shared_ptr<int> a(raw);
std::shared_ptr<int> b(raw);

正确做法是从已有的shared_ptr拷贝,或用make_shared:auto b = a;。


shared_ptr还支持别名构造(aliasing constructor),让一个shared_ptr共享另一个shared_ptr的控制块,但指向不同的子对象。它在需要返回对象内部成员、同时延长整个对象生命周期时很有用:


// 适用于 C++17
struct Widget {
int id;
int data[4];
};

auto owner = std::make_shared<Widget>();
std::shared_ptr<int> alias(owner, &owner->id); // 指向成员,但共享 owner 的控制块

alias的引用计数与owner共享;owner销毁前,alias保证成员仍然有效。


shared_ptr还有几个值得知道的成员:use_count()返回强引用计数,但标准明确它只是近似值,多线程下不可靠,不要用它做逻辑判断;reset()放弃当前所有权;get()返回裸指针但不转移所有权,把get()的结果交给另一个shared_ptr又会回到双重释放的坑。


三、weak_ptr:观察而不拥有


std::weak_ptr<T>指向由shared_ptr管理的对象,但不增加强引用计数。它用来表达「我看着这个对象,但不影响它的生命周期」。


主要用途是打破循环引用。如果两个对象用shared_ptr互相指向,引用计数永远不归零,对象永远不会被销毁:


// 适用于 C++17
// ❌ 父持有子、子持有父,双方计数都至少为 1
struct Node {
std::vector<std::shared_ptr<Node>> children;
std::shared_ptr<Node> parent;
};

把其中一个方向改成weak_ptr即可。通常「子指向父」用weak_ptr,因为父的生命周期更长、由外部持有。


使用weak_ptr的正确姿势是lock():它尝试把弱引用提升为shared_ptr,成功则对象在返回的指针存活期间一定有效;失败(对象已销毁)返回空的shared_ptr。


// 适用于 C++17
if (auto p = wp.lock()) {
p->do_something(); // 安全:p 持有强引用
}

不要写成if (!wp.expired()) { wp.lock()->... }。两步之间对象可能被其他线程销毁,lock()会返回空指针,解引用就是 UB。lock()本身是原子的,直接检查它的返回值即可。


weak_ptr也不增加计数,所以它不能直接访问对象,必须lock()或expired()。expired()同样有竞态窗口,只适合「大概判断」,不适合「判断后放心使用」。


四、enable_shared_from_this


有时类内部需要把自己的shared_ptr传出去,比如把this注册到观察者列表。如果直接写std::shared_ptr<T>(this),会创建第二个控制块,最终双重释放。


标准库提供的解法是继承std::enable_shared_from_this<T>,然后调用shared_from_this():


// 适用于 C++17
#include <memory>

class Session : public std::enable_shared_from_this<Session> {
public:
std::shared_ptr<Session> self() {
return shared_from_this();
}
};

前提是对象已经由shared_ptr管理。如果对象建在栈上,或者在shared_ptr接管之前调用shared_from_this(),C++17 起会抛出std::bad_weak_ptr(早期标准中是未定义行为)。在构造函数里调用同样不行,因为此时shared_ptr还没有完成对对象的接管。


完整示例代码


// 适用于 C++17
#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

struct Node : std::enable_shared_from_this<Node> {
std::string name;
std::vector<std::shared_ptr<Node>> children;
std::weak_ptr<Node> parent; // 反向边用 weak_ptr,避免循环

explicit Node(std::string n) : name(std::move(n)) {}
};

void add_child(const std::shared_ptr<Node>& parent,
const std::shared_ptr<Node>& child) {
child->parent = parent; // 弱引用,不增加计数
parent->children.push_back(child); // 强引用,父拥有子
}

int main() {
auto root = std::make_shared<Node>("root");
auto leaf = std::make_shared<Node>("leaf");
add_child(root, leaf);

if (auto p = leaf->parent.lock()) {
std::cout << "parent: " << p->name << '\n';
}

std::cout << "root count: " << root.use_count() << '\n';
std::cout << "leaf count: " << leaf.use_count() << '\n';

root->children.clear(); // 释放子对象
std::cout << "after clear, leaf alive: "
<< (leaf->parent.lock() != nullptr) << '\n';

return 0;
}

几个观察点:



  • root的use_count包含局部变量root本身和leaf->parent(若是强引用才会加一,这里是弱引用所以不加)。

  • leaf的计数来自局部变量leaf和root->children中的一份。

  • children.clear()之后,leaf对象由局部变量leaf继续持有,所以仍然存活;如果局部变量也离开作用域,对象才会销毁并让parent变空。

  • use_count()只适合单线程调试观察,不要写进业务逻辑。


如果确实需要「父指向子、子指向父」都保持强引用,就必须在某个时刻手动reset()其中一条边,否则两个对象会一起泄漏。这正是weak_ptr存在的意义。


常见坑点



  1. ❌ 用同一裸指针构造两个shared_ptr。


✅ 用make_shared,或从已有shared_ptr拷贝/移动。



  1. ❌ 两个对象用shared_ptr互相持有,造成循环引用。


✅ 把反向边改成weak_ptr,访问前lock()。



  1. ❌if (!wp.expired()) wp.lock()->f();存在竞态窗口。


✅if (auto p = wp.lock()) p->f();



  1. ❌std::shared_ptr<T>(this)在成员函数里创建新控制块。


✅ 继承enable_shared_from_this<T>,用shared_from_this()。



  1. ❌unique_ptr之间赋值:p = q;


✅p = std::move(q);,并明确q之后为空。



  1. ❌ 用delete释放unique_ptr<T[]>管理的内存。


✅ 数组版本自动调用delete[];不要手动干预。



  1. ❌ 给unique_ptr配一个与分配方式不匹配的删除器(例如new配free)。


✅ 删除器必须与分配方式配对;C 分配用free,C++ 分配用delete/delete[]。



  1. ❌ 函数参数按值接收shared_ptr,仅为读取对象。


✅ 不延长生命周期时用const T&或T*;确实要共享所有权时才按值传递。


总结




类型所有权拷贝主要用途代价



unique_ptr独占不可拷贝,可移动默认选择,RAII 管理单对象/数组通常与裸指针同尺寸

shared_ptr共享可拷贝多方共享生命周期控制块 + 原子计数

weak_ptr不拥有可拷贝打破循环、观察者、缓存提升时需lock()





场景选择



局部资源、工厂返回值unique_ptr

多处共享同一对象shared_ptr

子指向父、缓存索引weak_ptr

需要多态删除基类虚析构 + 智能指针



智能指针的关键不是「用了就安全」,而是想清楚谁拥有、拥有多久、何时释放。默认从unique_ptr起步,确有多方共享需求时再升级到shared_ptr,并且始终检查是否存在环。所有权模型理顺了,泄漏和双重释放自然就少了。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询