ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++智能指针全解析:unique_ptr、shared_ptr、weak_ptr实战与避坑指南

C++智能指针全解析:unique_ptr、shared_ptr、weak_ptr实战与避坑指南

如果你写过一阵子C++,大概率见过这种场面:程序连续跑几天,内存占用像一条斜线往上走,你盯着进程监视器怀疑人生,却不知道到底是哪块内存在泄漏;或者更惨一点,某处delete了之后还有代码继续访问,程序在用户机器上崩溃,等你去查,调用现场早就没了。这些问题我在用裸指针的阶段几乎全都遇到过。后来转向智能指针,不是因为"它更现代"这种理由,而是因为它把"谁来释放内存"这件事从人脑记忆变成了类型系统的一部分——看到类型,你就知道所有权归属,这让代码的可维护性好了一个数量级。这篇文章就把我对unique_ptr、shared_ptr、weak_ptr的理解和实际踩坑经验完整梳理一遍,适合已经能熟练写C++但还没系统用过智能指针的人,也适合用了不少但总在某些边界场景上懵一下的老手。

1. 从裸指针到智能指针:我们到底在解决什么问题

1.1 三种最常见的指针事故

先别急着看语法,你得先理解智能指针到底想消灭什么。我总结下来,手动管理内存时翻车就翻在下面三种情况。

第一是泄漏。你分配了一块堆内存,但因为某个分支提前return、或者异常被抛出、或者干脆是忘了写delete,这块内存再也没有被释放。

void load() { auto* cfg = new Config(); if (checkSomething()) { return; // cfg 这份内存再也没人管了 } delete cfg; }

这种问题最阴险的地方在于:它不报错。程序还能跑,只是内存缓慢增长,等到你发现的时候,往往已经找不到是哪次调用漏的。

第二是重复释放。两个模块都觉得自己对这块内存有释放责任。

void consume(Config* cfg) { delete cfg; // 函数内释放 } void demo() { auto* cfg = new Config(); consume(cfg); delete cfg; // 二次释放,行为未定义 }

二次delete可能当场崩溃,也可能不崩——因为那部分内存可能已经被操作系统重新分配给别人了,你实际上"删除"了别人的数据。

第三是悬空指针。你保存了一个指针,但对象已经销毁了,后面再用就是访问野内存。

Config* g_cfg = nullptr; void init() { Config cfg; // 栈上对象 g_cfg = &cfg; } // cfg 析构 void use() { g_cfg->name; // 访问已销毁对象 }

这三种事故里,泄漏最隐蔽,重复释放和悬空最致命。用裸指针的时候,你只能靠自律来避免它们,而自律在大型项目里是最靠不住的。

1.2 RAII:智能指针的底层逻辑

智能指针的底层支撑,是C++里被说得最多的那个概念:RAII——Resource Acquisition Is Initialization,资源获取即初始化。

这句话的翻译一直有争议,容易把人绕晕。落到智能指针上其实就一句话:当智能指针对象被创建时,它接管一份堆内存;当它走出作用域、析构函数被调用时,它自动释放这份内存。你不需要记着"哪一行该 delete",编译器在你代码的每一个退出路径上都替你记住了。

这里要强调"每一个退出路径"这六个字。return、异常、break、continue,任何跳出作用域的方式,析构函数都会被调用。这跟手动管理完全不同——手动管理你得在每个退出点都补一个delete,漏一个就泄漏。

1.3 所有权思维:从"什么时候释放"到"谁拥有"

用裸指针的时候,你脑子里得维护一张隐式的表:这块内存是谁申请的、谁负责释放、有没有共享、共享的话最后一个用完的人是谁。人脑维护这种表非常不可靠,尤其在函数跨类、跨模块调用的时候,几个new和delete隔着十万八千里,出错是迟早的事。

智能指针做的改变,是把"所有权"显式地写进类型里:

  • unique_ptr表示独占所有权,一份资源同时只有一个持有者;
  • shared_ptr表示共享所有权,多个持有者共同拥有,最后一个析构的人负责释放;
  • weak_ptr表示观察但不拥有,你只是看着它,不想延长它的生命。

写下代码的那一刻,所有权规则就通过类型传达出来了。别人看你的接口声明,不用翻实现就知道谁该释放内存。这才是智能指针真正的价值:它把运行时才暴露的错误,提前到了编译期和类型检查这一层。

2. unique_ptr:默认选择为什么是它

2.1 独占所有权与移动语义

先说结论:在80%以上的场景里,你的第一选择应该是unique_ptr,而不是shared_ptr。很多从其他语言转过来的朋友容易一上来就"共享",因为在很多语言里所有对象引用天然是共享的;但在C++里,默认应该是"独占",只有确实需要多处访问同一份数据时才升级成shared_ptr。

unique_ptr的语义是独占所有权。它的使用方式非常直接:

#include <memory> #include <string> struct Config { std::string name; int timeout_ms = 0; }; std::unique_ptr<Config> createConfig(const std::string& name) { auto cfg = std::make_unique<Config>(); cfg->name = name; cfg->timeout_ms = 3000; return cfg; // 移动,不拷贝 }

为什么它禁止拷贝?试想,如果允许拷贝,那同一块内存就有了两个 owner,两个 owner 各自的析构函数都会去delete,这就变成二次释放。所以unique_ptr的拷贝构造和拷贝赋值是被显式删除的,只保留移动构造和移动赋值。

移动的语义,你可以理解成"把一线管理权递出去":你把这块内存的钥匙交接给别人,自己手里的指针变空。这个交接是零开销的,只是两个指针值的交换。

我第一次用unique_ptr时有个疑惑:为什么return local unique_ptr能编译通过?因为C++标准对局部对象返回有特殊规定——即使没有显式写std::move,编译器也会把它当作右值来处理,在无法省略拷贝时会自动选择移动构造。所以你只管写return cfg;,不用担心拷贝被禁止。

2.2 make_unique 与自定义删除器

虽然unique_ptr<T>(new T(...))也能用,但我强烈建议一律使用std::make_unique。

auto cfg = std::make_unique<Config>();

原因有两个。第一是代码简短,类型只写一次,不用重复Config。第二是它能避免一种极端但真实的泄漏场景:假设有f(make_unique<A>(), make_unique<B>()),两个实参的求值顺序在一个函数调用里是不确定的,如果第一个make_unique构造成功后,第二个在new B时抛了异常,那第一个就已经分配的那块内存就会泄漏。make_unique把构造和创建动作合成一次调用,从根上消除了这个窗口。

unique_ptr还有一个容易被低估的点:删除器是可以定制的。默认删除器是delete ptr,对普通new出来的对象刚好合适。但有些资源不是new出来的,比如文件句柄、操作系统句柄,就需要你告诉它正确释放方式。

auto file_deleter = [](FILE* f) { if (f) fclose(f); }; std::unique_ptr<FILE, decltype(file_deleter)> file(fopen("log.txt", "w"), file_deleter);

注意,一旦自定义删除器,unique_ptr的模板参数里会多出删除器类型。如果删除器是无捕获的 lambda,编译器通常能优化为零额外空间;如果是有状态函数对象,它会作为成员存储占用一些空间。这点跟shared_ptr不同——shared_ptr的删除器是保存在控制块里的,不随指针对象本体占空间。

2.3 函数返回值与容器元素:移动语义的典型舞台

unique_ptr最舒服的用法之一,就是放进容器。C++11 以后所有标准容器都支持移动语义,于是你可以写出这样的代码:

std::vector<std::unique_ptr<Task>> tasks; tasks.push_back(std::make_unique<Task>(1)); tasks.push_back(std::make_unique<Task>(2)); for (auto& task : tasks) { task->run(); }

注意循环里用的是auto&,不是auto。如果你写for (auto task : tasks),编译器会尝试拷贝每个元素,而unique_ptr禁止拷贝,于是直接编译失败。这个错误其实是一件好事——它在编译期就阻止了你无意间复制所有权。

工厂函数返回unique_ptr也是业界标准姿势。调用者从函数签名就能看出:这个对象归我管,而且只有我能管。所有权转移清晰明了,不会像返回裸指针那样,还要靠注释去约定"谁来释放"。

我的体会是:unique_ptr应当成为你写出new字样的默认方式。哪天你发现自己写了new,先问一句"这里能不能换成make_unique";然后写shared_ptr之前,再问一句"这里真的需要共享吗"。这两句反问能挡掉大部分设计问题。

3. shared_ptr:引用计数背后的控制块设计

3.1 引用计数原理:每份拷贝都在续命

shared_ptr允许真正共享所有权。它的原理是引用计数:每多一份拷贝,计数加一;每销毁一份,计数减一。当计数归零时,最后一个持有者负责释放对象。

auto sp = std::make_shared<Widget>(); auto sp2 = sp; // 引用计数 2 { auto sp3 = sp2; // 引用计数 3 } // sp3 离开作用域,计数变回 2

只要还有一份shared_ptr活着,对象就不会被销毁。这个机制对付"多个模块都要访问同一个配置对象""多个回调都要持有同一个上下文"这类场景特别好用。

但引用计数并不是没有代价的。我这里要展开一个很多人没注意的细节:shared_ptr的管理信息不是存在对象自己内部的。标准库实现里,每个shared_ptr对象通常保存两个指针,一个指向被管理的对象,一个指向控制块。控制块里放着引用计数、弱引用计数、删除器等数据。

控制块本身是堆分配的。也就是说,跟unique_ptr相比,shared_ptr多一次堆分配、多一层间接寻址。这是它"贵"的第一个来源。

3.2 make_shared 的隐蔽优势与那一个例外

所以就有了std::make_shared存在的意义。它会把对象本身和控制块放进同一块连续内存里,一次性分配。

auto sp = std::make_shared<Widget>(args...);

对比一下:

std::shared_ptr<Widget> sp(new Widget(args...));

后者要做两次堆分配:一次给Widget,一次给控制块。前者只做一次。在大量创建shared_ptr的热路径上,这个差距非常明显,缓存局部性也更好。

但make_shared有一个容易被忽视的副作用:对象和控制块在同一个内存块里,意味着对象内存的归还时间要等所有weak_ptr也销毁之后。如果有一个weak_ptr活了很久,而你恰恰对内存释放时机极其敏感,那这部分内存就会比预期晚释放。这种场景比较少见,但如果你维护的是长时间运行的后端服务,又确实存在"活着但不使用"的weak_ptr,可以留意一下。真遇到这种极端情况,可以退回到shared_ptr<Widget>(new Widget(...))这种分开分配的方式。

3.3 线程安全边界:计数安全不等于对象安全

"shared_ptr线程安全吗?"这个问题几乎每次培训都会被问到。标准答案分两层:

第一层,引用计数的增减是线程安全的。标准库保证计数的操作是原子操作,多个线程各自持有同一个对象的shared_ptr拷贝时,一个线程析构不会影响另一个线程的计数。计数归零后也不会出现两个线程同时delete的情况。

第二层,这不代表被指向的对象本身线程安全。如果两个线程同时在同一个对象上调用非 const 方法,那仍然是数据竞争,shared_ptr完全不负责这个。很多刚接触的人会误以为"用了shared_ptr就不用加锁了",这个误解能在生产环境引发非常难查的崩溃。

还有一种情况需要特别提醒:同一个shared_ptr对象实例被多个线程同时读写,是不安全的。比如一个全局shared_ptr变量,一个线程在给它重新赋值,另一个线程在读它,那也得加锁。正确做法是每个线程各自持有同一对象的一份shared_ptr拷贝,而不是共享同一个shared_ptr对象。

4. weak_ptr:打破循环引用的关键工具

4.1 循环引用是怎么形成的

shared_ptr引用计数看起来很美好,但它有一个著名的坑:循环引用。当一个对象被子对象持有、子对象又反过来持有父对象时,两个对象的引用计数都永远不可能归零,析构函数永远不会执行,这就是一种特殊的内存泄漏。

最常见的例子是链表里的双向指针:

struct Node { std::shared_ptr<Node> next; std::shared_ptr<Node> prev; // 问题就在这里 ~Node() { std::cout << "destroyed\n"; } }; void buildRing() { auto a = std::make_shared<Node>(); auto b = std::make_shared<Node>(); a->next = b; b->prev = a; // a 被 b 持有,b 也被 a 持有 } // 函数结束后,两块内存都无法释放

节点 A 被 B 的prev持有一份引用,节点 B 被 A 的next持有一份引用,谁也无法把计数减到零,"destroyed" 永远不会被打印。

这种问题最气人的地方在于:它运行时完全正常,没有报错,只是内存只增不减。你排查泄漏时如果只盯着new和delete,很难想到是智能指针自己造成的循环。

解决办法很简单:把从属关系那一侧的指针换成weak_ptr。父持有子,用shared_ptr;子回指父,用weak_ptr。

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

这样 B 的prev不会增加 A 的计数。A 的计数完全由外部持有者控制,外部一切都销毁时,A 就可以正常析构,B 也随之析构。

4.2 lock() 与 expired() 的正确姿势

weak_ptr本身不拥有对象,所以你不能直接通过它访问目标。要访问,必须调用lock(),它会原子地检查目标是否存活;活着就返回一个有效的shared_ptr,已经销毁就返回一个空的shared_ptr。

std::shared_ptr<Node> getPrev(const Node& node) { if (auto sp = node.prev.lock()) { return sp; // 对象还活着 } return nullptr; // 已经销毁 }

注意weak_ptr提供了一个expired()方法用来查询是否销毁。但我的建议是:不要先expired()再lock()。因为这两个调用之间,对象可能刚好被其他线程销毁了,你拿到的lock()结果还是空的,等于白查一次,还多了一个竞态窗口。直接lock()然后判断返回值是否为 null,一步到位。

weak_ptr自身还有一个小知识点:它保存的指针大小和shared_ptr一样,都是两个指针(一个指向对象,一个指向控制块)。所以你不要以为"不拥有"就意味着"零开销"。创建weak_ptr会原子地增加弱引用计数,销毁时也会原子地减少。只不过它不会触发对象的析构。

4.3 缓存、观察者与其他典型场景

除了打破循环引用,weak_ptr还有两个非常经典的应用场景。

第一个是缓存。缓存里保存weak_ptr,当外部还有强引用时,直接从缓存取;当外部都释放了,缓存条目自动失效,下次再生成一个。

class RecordCache { std::map<std::string, std::weak_ptr<Record>> entries; public: std::shared_ptr<Record> get(const std::string& key) { auto it = entries.find(key); if (it != entries.end()) { if (auto sp = it->second.lock()) { return sp; // 命中且对象存活 } } auto sp = std::make_shared<Record>(key); entries[key] = sp; return sp; } };

这个模式完美避免了"缓存锁住了对象生命周期"的问题。如果用shared_ptr做缓存,即使业务方都已经不用了,缓存还强引用着,对象就永远无法释放。

第二个是观察者模式。被观察者持有一堆观察者的weak_ptr,而不是shared_ptr。这样观察者销毁后,被观察者不会积累悬空的强引用,也不用专门写反注册逻辑。每次广播事件时,用lock()判断一下观察者还在不在。

一句话总结:只想"看一眼"而不想"负责"时,就用weak_ptr。

5. 误用场景排查:那些让我半夜改代码的问题

5.1 把 get() 的返回值长期保存

shared_ptr和unique_ptr都提供了get(),用来返回原始指针。它的存在是为了跟老式 C 接口、或者需要裸指针的第三方库对接。

但get()返回的裸指针有一个隐含约定:它只在智能指针存活期间有效。你不能把它保存起来过夜。

std::shared_ptr<Logger> sp = std::make_shared<Logger>(); g_logger = sp.get(); // 危险:全局裸指针长期挂着 // sp 析构之后,g_logger 就是悬空指针

我见过不止一次因为get()临时指针被存进全局变量、成员变量,然后智能指针析构后还用,导致神秘的偶发崩溃。排查这类问题时,最有效的办法是全局搜索get(),一个个确认返回值有没有被存储。

5.2 this 转 shared_ptr 的两条错误路径与正确姿势

在类的成员函数里想返回"自己"的shared_ptr,这是高频需求。最常见的错误是直接构造:

class A { public: std::shared_ptr<A> getShared() { return std::shared_ptr<A>(this); // 错误:会多出一个控制块 } };

如果你在外面已经有一个shared_ptr<A>管理这个对象,再通过this构造一个,就会产生两个相互不知道对方的控制块。两个控制块的引用计数各计各的,最后谁都认为自己该delete,于是二次释放。这种 bug 往往要等两个引用都销毁时才触发,非常难复现。

正确的做法是继承std::enable_shared_from_this:

class A : public std::enable_shared_from_this<A> { public: std::shared_ptr<A> getShared() { return shared_from_this(); } };

但这里有个容易忽略的前提:shared_from_this()只能在对象已经由一个shared_ptr管理时调用。如果你在栈上创建了 A,没经过shared_ptr就直接调用shared_from_this(),标准库会抛出std::bad_weak_ptr异常。

还有一个细节:继承enable_shared_from_this必须用公有继承。在某些实现里,私有继承会导致编译错误或运行期问题。我刚学的时候就因为这个栽过跟头。

5.3 同一个裸指针构造两个 shared_ptr

这个错误跟上面类似,但发生得更隐蔽:

auto* raw = new Widget(); std::shared_ptr<Widget> a(raw); std::shared_ptr<Widget> b(raw); // 两个控制块,双重释放

两个shared_ptr各自以为自己是唯一 owner,控制块相互独立。当它们都析构时,同一块内存被delete两次。

这种写法的根源是"先发明了一个裸指针,再分发给多个共享指针"。正确做法是从一开始就用make_shared创建第一份,之后的所有共享都通过拷贝shared_ptr本身来完成,而不是重新用裸指针构造。

顺带一提,如果你拿到了一个shared_ptr<Derived>,想把它转成shared_ptr<Base>用于多态存储,直接用拷贝构造或赋值即可,C++ 标准库会正确处理并保持引用计数一致。不要企图先get()出来再转,那是自己给自己挖坑。

5.4 传参方式:值传递、引用传递与性能陷阱

shared_ptr作为函数参数时,写法直接决定了调用开销。

void process(std::shared_ptr<Widget> sp); // 值传递:计数加一又减一,两次原子操作 void process(const std::shared_ptr<Widget>& sp); // 引用传递:不改变计数,零原子操作

如果函数只是"借用"这个对象,调用期间不会持有一份拷贝,那就用const shared_ptr<T>&。如果函数需要把这份引用存起来,或者放到异步任务里,那就必须值传递,因为你需要用拷贝保证对象在异步期间活着。

这里还有一个更极端的建议:如果函数根本不关心共享语义,只关心"我要用一下这个对象",那直接用const Widget&不是更好吗?很多函数接收shared_ptr其实只是顺手,真正需要的是对象本身。用const Widget&就完全绕开了引用计数,调用方也不用非得持有shared_ptr。判断标准很简单:你在这个函数里有没有把参数转成shared_ptr存下来?如果没有,就别用shared_ptr参数,改传引用或裸指针。

C++ 的shared_ptr还支持从Derived到Base的转换,并且会保留正确的删除行为。但反过来——把unique_ptr转成shared_ptr是允许的且推荐的,把shared_ptr转回unique_ptr则意味着要重新管理所有权,除非你能保证没有其他地方持有引用,否则不要这么干。

6. 性能、选型与团队约定:智能指针不是银弹

6.1 三种智能指针的开销对比

既然要做选型,就得把账算清楚。下面这张表是我在 64 位 Linux 环境下实测得出的典型数据,不同标准库实现略有差异,但数量级不会变。

指针类型单指针大小创建开销拷贝/析构开销适用场景
裸指针8 字节无额外开销纯复制地址非拥有观察者、老 C 接口
unique_ptr8 字节(默认删除器)无额外开销禁止拷贝,移动近乎免费独占所有权,默认首选
shared_ptr16 字节控制块堆分配原子计数 +1/-1真正需要共享所有权
weak_ptr16 字节随控制块原子弱计数 +1/-1打破循环、缓存、观察者

unique_ptr的近乎零开销是它成为默认选择的最大底气。它的尺寸跟裸指针一样大,释放逻辑在编译期就确定了,运行时不比裸指针多花一分钱。

shared_ptr的成本要具体算。拷贝一次,就是一次原子递增;析构一次,一次原子递减。在单线程程序里原子操作还有额外的屏障开销。更不用说每次make_shared的那一次堆分配。如果在高频路径上频繁拷贝shared_ptr,这部分开销是真实可测的。

weak_ptr的成本也不可忽略。每次lock()都是一次原子读加一次条件判断,多数情况下还会顺带增加强计数。所以缓存热点路径上,weak_ptr::lock并不便宜。

6.2 什么时候仍然该用裸指针

听了这么多智能指针的好处,我得泼一盆冷静的水:裸指针在C++里没有被完全淘汰,也不应该被完全淘汰。

合法的裸指针使用场景有几个。第一,非拥有的"借用"指针,借用期间智能指针的生命周期被调用栈明确保证,这时候用裸指针最轻便。第二,跟 C 风格接口互操作,很多库的函数签名就是T*,你躲不开。第三,性能敏感的极热路径上,实测确认shared_ptr拷贝是瓶颈,且对象生命周期有严格作用域保证,可以退回到手动管理——但这个决定应该由性能剖析数据来驱动,而不是凭感觉。

真正要拒绝的,是"用裸指针存储所有权"的旧代码模式。一个成员变量是Widget*,却不知道谁释放,这就是事故现场。团队约定里必须有一条:代码库中的裸指针默认是非拥有的。这样看到裸指针时,读者的第一反应才是"哦,这里只是借看一下",而不是"这块内存到底谁管"。

6.3 调试与验证工具:让问题在测试环境现形

智能指针能解决大部分问题,但解决不了所有问题。剩下的那些问题,靠人眼很难看出来,得靠工具。

我最推荐的是 Sanitizer 系列。编译时加上这几个选项:

g++ -fsanitize=address -fsanitize=leak -g -O0 main.cpp -o main

AddressSanitizer 能在运行期检测到堆缓冲区溢出、栈缓冲区溢出、悬空指针访问;LeakSanitizer 能检测内存泄漏,包括shared_ptr循环引用造成的泄漏。注意 LeakSanitizer 默认随 AddressSanitizer 一起开启,它会报告所有"未释放但仍被引用"的地方,循环引用的对象就会在这里现形。

Valgrind 也很经典,但开销大得多,适合测试环境跑慢速复现。静态分析工具则可以在编译期就提示可疑的shared_ptr用法,比如把get()的返回值赋给全局变量。

我在实际排查循环引用时,经常用一招:给关键类的析构函数加日志,然后跑一遍业务流程,看哪些析构函数没有输出。没有输出的类,就是被循环引用卡住的地方。

6.4 团队规范的几条约定

最后说几条我在实际项目里验证过的团队规范,不一定适合所有团队,但可以作为参考。

接口层面,工厂函数统一返回智能指针,且能用unique_ptr就绝不返回shared_ptr。调用方如果需要共享,自己再转换。参数层面,借用别所有权就传const T&或裸指针,真的需要存一份再传shared_ptr。存储层面,成员变量用智能指针声明所有权,裸指针成员变量一票否决——除非你有强烈的性能理由并写清楚注释。新增代码里尽量不再出现 barenew,所有动态对象从make_unique或make_shared起步。

这一套落地之后,我最大的感受是:代码审查时的讨论重心从"这里要不要 delete""这个指针传给谁会释放"变成了"这里的所有权边界设计是否合理"。后者比前者有价值得多。智能指针不是银弹,它不会替你设计架构,但当你用法正确的时候,它能把你的注意力从内存管理的泥潭里解放出来,放到真正需要思考的业务逻辑上去。

最后再分享一个我自己的习惯:写新模块时,先全部用智能指针把所有权关系理清楚;跑通功能之后,如果剖析发现某处确实是性能瓶颈,再针对那一小段做优化,甚至局部退回裸指针,但每一次退回都要用注释说明为什么这个位置可以安全地使用裸指针。这样做,既能保住代码的长期可维护性,又不会被"性能"两个字绑架。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进