当年:你手里只有 new,和一句“记得 delete”
那会儿还没什么“智能指针”。
你写的就是 C with classes。
能跑就行。
能上线就更行。
资源这件事。
基本靠自觉。
new 之后。
你得自己去把 delete 找回来。
找不回来。
就等着哪天线上给你脸色看。
线上啪一下:一个 return 就够你喝一壶
我见过最经典的一种事故。
是一个小服务。
平时挺老实。
一到凌晨流量一抖。
内存曲线就开始缓慢爬坡。
第二天早上。
进程被 OOM 杀掉。
你打开代码。
大概是这种味道。
void handle(bool ok) {
Foo* p = new Foo();
if (!ok) return;
work(p);
delete p;
}
这篇文章里的代码。
为了短。
默认省略 #include 和命名空间。
你把它当成“说明问题的示意代码”就行。
ok 这条分支一走。
delete 就再也见不到了。
当时最要命的是。
它不一定立刻爆。
它会慢慢攒。
攒到你以为“昨天也没事”的那个阈值。
然后啪一下。
当时的土办法:把清理写在“回家路上”
那时候大家也不是没想办法。 最常见的是搞一个“清理出口”。 你可以把它理解成:无论中途怎么拐弯,最后都得从同一个门出去。
void handle(bool ok) {
Foo* p = new Foo();
if (!ok) goto cleanup;
work(p);
cleanup:
delete p;
}
这招能救急。 但资源一多、分支一多,你很快就会写得像在排雷。
更糟的是,后来你学会了异常。
return 还算厚道。
异常是直接掀桌子走人。
void handle() {
Foo* p = new Foo();
work_maybe_throw();
delete p;
}
work_maybe_throw() 一抛,delete 根本来不及跑。
这就是“人肉 GC”最难受的地方。 你得为所有退出路径买单。
有人换了个想法:让对象替你“收尾”
后来有一批人想明白一件事。
清理动作不该散落在每个 return 前。
它应该跟着“拥有者”走。
你只要保证拥有者会按时析构,剩下的交给语言。 这里的“析构”可以先理解成:对象要离开作用域时,编译器会自动调用一个“收尾函数”。
你可以先用一个最土的例子感受一下“作用域”。
下面的示例为了专注在行为上。
省略了 #include 之类的细节。
你可以把 puts 当成“打印一行字符串”。
struct Loud {
~Loud() { puts("bye"); }
};
void f() {
{
Loud x;
puts("work");
}
puts("after");
}
大括号里面那段。 就是一个作用域。
x 一离开这个作用域。
析构函数就会被调用。
所以你会看到 work 之后。
立刻打印 bye。
这点对异常也成立。 异常把你“赶出”作用域。 但它赶不走析构。
struct Guard {
Foo* p;
~Guard() { delete p; }
};
你把 delete 塞进析构函数。
中途 return 也好,抛异常也好。
析构会替你把尾巴收掉。
这招后来有个名字,叫 RAII。 你先别急着背缩写。 把它当成一句人话就行:谁拿了资源,谁负责在走的时候还回去。
顺手补一点历史。
RAII 这个说法。 很多资料会把它和 Bjarne Stroustrup 讨论异常安全、资源管理的思路联系在一起。
你可以把它理解成 C++ 的一种“道德观”。 资源是借来的。 借了就得还。 别靠记性。
后来《Effective C++》这类书又把这件事说得更工程化。 再后来 Boost 里一堆 smart pointer。 基本就是社区把 RAII 这套东西做成“顺手的工具”。
有位老前辈总结得更狠。
让编译器替你记事。
标准库的早期尝试:auto_ptr 为什么被拉黑
到了 C++98/03,标准库确实给过一个“智能”的指针:std::auto_ptr。
它的初心是好的。
但它生在一个尴尬的年代。
那个年代还没有 move。 于是它为了“看起来能拷贝”,做了一件很反直觉的事:拷贝 = 转移所有权。
std::auto_ptr<Foo> a(new Foo());
std::auto_ptr<Foo> b = a; // a 被掏空
你写的是“拷贝”。 发生的是“交接”。
这在容器里就更刺激。 因为容器最爱拷贝。 于是大家很快形成共识:别碰它。
但说句公道话。 在 C++11 之前。 大家也不是只能干瞪眼。
Boost 早就提供了 shared_ptr/weak_ptr。
还有 scoped_ptr 这种“只能在作用域里活”的指针。
再后来。
这些东西还进过 TR1。
你可能见过一些老代码写 std::tr1::shared_ptr。
那个年代的意思大概是。
标准库还没“正式收编”。 但大家已经在工程里用熟了。
它们在工程上很好用。 只是那时候语言里没有 move。 很多“所有权交接”的写法只能做到别扭,做不到顺滑。
C++11 的变化:把“所有权”写进类型里
C++11 做了一件很关键的事。
它承认“所有权”是代码的一部分。
以前你靠口头约定和代码评审盯着。
现在类型自己就能把责任写清楚。
于是有了三兄弟:unique_ptr、shared_ptr、weak_ptr。
它们不是“更聪明”。
它们只是把当年你靠脑子硬记的东西。
搬到了编译器能检查的地方。
unique_ptr:默认选它,责任最清楚
先从最像“当年的你”的那个开始。
你 new 的东西,你负责到底。
只不过这次你不用在每个分支前写 delete。
收尾动作跟着对象走。
std::unique_ptr<Foo> p(new Foo());
work(p.get());
p 一离开作用域,就自动释放。
get() 只是把里面那根裸指针“借出来”。
你别去 delete 它。
负责还钱的人还是 p。
你看到这里。
可能会问。
“那我是不是就再也不用管了?”
大多数时候是的。
但它真正值钱的点不是自动 delete。
是它不让你乱来。
它不能拷贝。
因为拷贝会让“谁负责”变成谜。
你想把责任交出去。
得明确地交。
std::unique_ptr<Foo> make() {
return std::unique_ptr<Foo>(new Foo());
}
这就很像一个合同。
函数返回 unique_ptr。
读的人就知道。
“拿到它的人负责善后。”
这里再给一个你会遇到的真实分岔。
你到底是“借给函数看一眼”。
还是“把家当交给它”。
void observe(const Foo* p);
void take(std::unique_ptr<Foo> p);
第一个签名。
等价于“别删,我只是用用”。
第二个签名。
等价于“给你了,你说了算”。
这才是 unique_ptr 的爽点。
还有一个新手一定会皱眉的点。
你可能会试图把 unique_ptr 塞进容器。
然后编译器跟你说不行。
原因不是你写错了。 是它在保护你。
容器在搬运元素时。
需要“移动”。
而 unique_ptr 只能移动,不能拷贝。
std::vector<std::unique_ptr<Foo>> v;
std::unique_ptr<Foo> p(new Foo());
v.push_back(std::move(p));
std::move 你可以先把它当成一句话。
“我不打算再用 p 了。”
这一句说出口。 所有权就交给容器。
p 之后通常就变空。
别再拿它当真对象用。
顺手再补一句。
std::move 本身不搬东西。
它更像一句声明。
“这玩意你可以拿走。”
如果你只学过 C。 这里最容易误解的是。
“那 p 还能不能用?”
通常不能。 或者说。 你只能把它当成“可能为空的盒子”。 用之前先判断。
这也是 C++11 真正补齐的一块。 在 C++11 之前。 标准库没法把“交接所有权”这件事写得又安全又自然。
再补两个你用起来会很顺手的小动作。
一个是 reset。
意思是“我换一份资源”。
std::unique_ptr<Foo> p(new Foo());
p.reset(new Foo());
旧的那份会被自动释放。 你不用操心。
另一个是返回值。
你可以很自然地把 unique_ptr 当成“工厂函数的返回物”。
std::unique_ptr<Foo> make() {
return std::unique_ptr<Foo>(new Foo());
}
读代码的人一眼就懂。 这玩意是谁创建的。 最后谁负责收尾。
再给一个你写小项目经常会遇到的场景。
资源不一定是 new/delete。
也可能是 FILE* 这种“拿了就得关”的句柄。
这个例子同样省略了 #include <cstdio>。
重点是看“析构时自动关”。
std::unique_ptr<FILE, int(*)(FILE*)>
f(std::fopen("a.txt", "r"), &std::fclose);
你不用到处写 fclose。
它会跟着 f 的生命周期走。
shared_ptr:共享很方便,但你得付账
有些东西确实没法说清“唯一主人”。
比如你写了个小项目。
里面有个缓存对象。
请求线程要用,异步回调也要用。
谁先走谁后走你说不准。
这时候 shared_ptr 就上场了。
auto p = std::shared_ptr<Foo>(new Foo());
a(p);
b(p);
它背后有一个计数。
你可以把它想成“还有几个人没放手”。
最后一个人放手,对象才会被释放。
这个思路其实不算 C++ 独创。
更早的工程世界里。 很多组件系统都用过引用计数。
比如 Windows 的 COM。
对象会暴露 AddRef() / Release()。
你拿到一次就加一次。
不用了就减一次。
shared_ptr 做的事很像。
只是它把加减计数变成了自动。
不用你手写。
这也是为什么很多人会说。
C++11 的 shared_ptr/weak_ptr。
很大程度上参考了 Boost 的实现和使用习惯。
但共享也会带来副作用。
责任会变模糊。
模糊久了,系统里就到处是 shared_ptr。
然后你开始付账:性能账、排查账。
共享不是免费的。
还有一个坑。
是新手最爱踩的。
某天线上突然来一条:double free or corruption。
你很可能在代码里看到这种东西。
同一个裸指针。
被塞进了两个 shared_ptr。
Foo* raw = new Foo();
std::shared_ptr<Foo> a(raw);
std::shared_ptr<Foo> b(raw);
这不是“共享”。
这是两个人各拿了一本账。
都以为自己能在最后 delete。
于是你会得到一个很难看的双重释放。
你可以把它记成一句更直白的话。
同一根裸指针。 只能“签一次合同”。
合同签多了。 每份合同都以为自己能收尾。 就会打起来。
要共享。
就从一开始就共享。
最顺手的写法是 make_shared。
auto sp = std::make_shared<Foo>();
auto a = sp;
auto b = sp;
这里的洞见其实挺朴素。
shared_ptr 不只是“一个指针”。
它还带着一份“账本”。
那份账本在堆上,记录计数和怎么释放。
为什么大家老爱推荐 make_shared。
一个很实际的原因是。
shared_ptr(new Foo) 往往要分两块内存。
一块放对象。
一块放账本。
而 make_shared 常常能把这两块合在一起。
少一次分配。
也少一点碎片和开销。
还有一个小细节。 对新手很实用。
你把 shared_ptr 传给函数。
传法不一样。
代价也不一样。
void f(std::shared_ptr<Foo> p);
void g(const std::shared_ptr<Foo>& p);
f 这种按值传。
会多一次“记账”。
g 这种按引用传。
更像“我只看,不参与拥有”。
如果函数只是临时用一下。 优先用引用。
如果你想看一个“正确的共享”长什么样。
就别从裸指针开始。
从第一个 shared_ptr 开始。
auto a = std::make_shared<Foo>();
auto b = a;
这才是真共享。 它们共用同一本账。
对新手来说。
这条规则几乎能挡掉 80% 的 shared_ptr 事故。
weak_ptr:别把彼此绑死
shared_ptr 最经典的翻车不是慢。
是循环引用。
你以为对象会在退出时析构。
结果日志永远不打印。
你写个双向结构。
两边都用 shared_ptr。
看起来很合理。
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev;
};
然后你发现它们永远不会析构。
因为 next 和 prev 互相“顶着计数”。
复现也不复杂。
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->prev = a;
你以为 a、b 离开作用域就结束了。
但它们互相抓着对方。
计数永远到不了 0。
weak_ptr 的意思很直白。
我能看见你。
但我不拥有你。
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev;
};
当你真要用 prev。
你得先问一句。
“你还在吗?”
if (auto p = node->prev.lock()) {
use(*p);
}
lock() 会给你一个临时的 shared_ptr。
你可以把它理解成“我想用一下,你给我一张短期通行证”。
成功说明对象还活着。 失败就别硬来,它已经走了。
除了拆循环。
weak_ptr 还有一个很常见的用法。
做缓存。
你希望“有人在用就留着”。 没人用就算了。
std::weak_ptr<Foo> cache;
std::shared_ptr<Foo> get() {
if (auto p = cache.lock()) return p;
auto p = std::make_shared<Foo>();
cache = p;
return p;
}
这里的感觉像这样。 缓存只是“记个地址”。 但不负责养着它。
真有人需要。
就 lock() 试一下。
锁到了就复用。
锁不到就新建。
这三种指针到底怎么选
如果你只记一条。
先选 unique_ptr。
责任最清楚。
只有当你真的需要“共享生命周期”。
才上 shared_ptr。
而 weak_ptr 大多数时候是用来拆循环。
或者说,用来给 shared_ptr 还债。
横向对比:几种活法,各自的代价
手写 new/delete。
优点是简单直接。
代价是你得为所有退出路径负责。
goto cleanup 这类写法。
能救火。
但资源一多就会失控。
你会写得越来越像在维护一张流程图。
RAII(包括 unique_ptr)。
把“还债”动作绑定到作用域。
它不是让你少写几行。
而是让错误更难发生。
引用计数(shared_ptr)。
适合“真的说不清谁先走”的场景。
代价是共享会扩散。
一扩散就开始难排查。
GC(比如 Java/C# 那套)。 让你很少直接写释放。 但它换来的是另一种成本。 停顿。 内存峰值。 以及“到底什么时候回收”的不确定。
C++ 走的路线更像。
我不替你做决定。 但我给你一套默认安全的工具。 你把意图写清楚。 剩下交给编译器。
最后一眼:它们不是魔法
智能指针听起来像魔法。
其实它们做的事很老。
就是把“别忘了释放”这句口头禅。
从你的脑子里搬到类型系统里。
你可以把资源当成一种“债”。
智能指针做的事,是把“谁背这笔债”写在类型上。
这就是 C++11 最实在的进步。
你写小项目也能用。
但你写大项目更离不开。 因为项目一大,“记得 delete”这种事迟早会漏。 漏了就不是扣几分。 是线上报警。
你会少背几个锅。
然后你就能把精力留给真正难的事。 比如你到底想让程序表达什么。