「裸指针还能不能用?」网上一边倒的答案是「别用,全换成std::unique_ptr」。这话对,但只对了一半。C++ Core Guidelines 的真正立场更精细:裸指针(aT*)本身没有错,错的是用它去表达「所有权」。指针可以放心地当「观察者」用,只要你约定它不负责释放。这篇把「不拥有」和「拥有」的边界划清楚,并给你能直接套用的签名写法。
官方文档:R.3: A raw pointer (a
T*) is non-owning (Core Guidelines)
问题不在指针,在「所有权」
看这两行:
Widget*create();// 返回的指针谁负责 delete?voidf(Widget*w);// w 是借来的,还是要我释放?第一个签名让人头疼:调用方拿到Widget*后,到底该不该delete?编译器没法告诉你,文档也没写,于是 leaks 和 double-free 就来了。根因是「所有权」被藏进了裸指针里。规约去建立:T*只表示「我指着一个对象,但我不拥有它」;谁拥有,谁就用智能指针或容器说清楚。
所有权表达方式一览(ASCII 图)
把「不拥有」和「拥有」两套表达摆在一张图里,一眼看出该用什么:
表达「不拥有」(borrow,不负责释放) T& 指向必存在的对象,不可为空、不可重绑 T* 指向对象,可为 nullptr(可选观察者) 表达「拥有」(own,负责释放/生命周期) std::unique_ptr<T> 独占所有权,离开作用域自动释放 std::shared_ptr<T> 共享所有权,引用计数归零释放 std::vector<T> 拥有连续的一批元素 T 局部变量 栈上自动拥有,作用域结束即析构 ────────────────────────────────────────────── 红线:不要用裸指针 / 裸引用去「传递所有权」核心约定:裸指针和裸引用只出现在「借」的位置(函数参数、返回值指向别人拥有的对象);「 ownership 的转移与持有」一律交给std::unique_ptr/std::shared_ptr/ 容器。这样从类型上就能读出一段代码要不要负责释放。
什么时候裸指针仍是正确选择
即使全程用智能指针,下面四类场景裸指针依然是对的:
- 非拥有的观察者参数:函数只读或临时借用一个对象,用
const T*或T*,明确「我不负责它的生死」(Core Guidelines F.7)。 - 可选输出参数:需要「调用方可能不关心结果」时,传
T* out,nullptr表示「别写」。返回值做不到「可选输出」。 - 与 C API 交互:C 接口只认裸指针,桥接处必然出现
T*,这时它就是个不拥有的桥。 - 指向栈上对象:局部变量取地址传给别人看,生命周期由栈管,指针只是借道。
官方文档:F.7: For general use, take
T*orT&to pass a maybe-modified object (Core Guidelines)
T* 与 T& 表达「不拥有」的区别
两者都「不拥有」,但语义强度不同,别乱换:
| 维度 | T&不拥有 | T*不拥有 |
|---|---|---|
| 可空(表达「可能没有」) | 不能,必存在 | 能,nullptr表示无 |
| 必须初始化 | 是 | 否 |
| 可重新指向别处 | 不能 | 能 |
| 适用 | 参数一定存在、不可选 | 可选观察者 / 可选输出 |
经验法则:参数一定存在就给T&(强契约),可能不存在才给T*。返回「找到的元素」时用const T*(可空 = 没找到),而不是const T&(引用没法说「没找到」)。
函数签名设计:好 / 坏对比表
下面这些对比直接来自 Core Guidelines,照着改能消掉一大类所有权歧义 bug:
| 坏签名 | 问题 | 好签名 |
|---|---|---|
void f(Widget* w)而w永远非空 | 不该为「必存在」引入可空性,调用方困惑要不要判空 | void f(Widget& w) |
Widget* create()返回裸指针 | 所有权不明:谁delete?易泄漏 / 重复释放 | std::unique_ptr<Widget> create() |
int* find(Key)返回裸指针且隐含拥有 | 混淆「不拥有」与「拥有」 | 不拥有用const Widget*;拥有用std::unique_ptr<Widget> |
void read(std::shared_ptr<Widget> p) | 强行要求共享所有权,调用方被迫make_shared | void read(const Widget& w)或void read(const Widget* w) |
官方文档:I.11: Never transfer ownership by a raw pointer or reference (Core Guidelines)
要点:要「借」就用裸指针/引用,要「转移所有权」就用std::unique_ptr按值返回或按值传参。类型本身就写明了生命周期责任,不需要注释兜底。
不拥有用指针,拥有用 unique_ptr
一个程序同时演示上面所有正确姿势:非拥有观察者const int*、可选输出参数int*、从容器不拥有地返回const int*、以及用std::unique_ptr明确转移所有权。全程没有裸new/delete。
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<memory>#include<vector>// 不拥有观察者:只读,不负责释放(F.7)voiddescribe(constint*p){if(p)std::cout<<"值: "<<*p<<'\n';elsestd::cout<<"空指针,无对象\n";}// 可选输出参数:传 nullptr 表示「不关心结果」voidmaybe_double(intin,int*out){if(out)*out=in*2;}// 不拥有地返回找到的元素(指针可空 = 没找到)constint*find_first_even(conststd::vector<int>&v){for(constint&x:v)if(x%2==0)return&x;returnnullptr;}// 转移所有权:用 unique_ptr 明确「调用方负责释放」std::unique_ptr<int>make_counter(){returnstd::make_unique<int>(0);// 无裸 new}intmain(){inta=7;describe(&a);// 非拥有观察者describe(nullptr);// 可空intresult=0;maybe_double(5,&result);std::cout<<"5*2 = "<<result<<'\n';maybe_double(5,nullptr);// 不关心结果,啥也不做std::vector<int>nums{1,3,4,7};constint*e=find_first_even(nums);if(e)std::cout<<"第一个偶数: "<<*e<<'\n';autoc=make_counter();// 拥有std::cout<<"counter 初值: "<<*c<<'\n';return0;}值: 7 空指针,无对象 5*2 = 10 第一个偶数: 4 counter 初值: 0describe/find_first_even用的裸指针全部「不拥有」,它们只借看、不会去释放;make_counter用std::unique_ptr把所有权显式交还给调用方,c离开作用域时自动析构,无泄漏。对比第 5 节那张坏表,create()若返回裸指针,这段所有权就含糊了。
实证对比:同一个场景,三种所有权写法各写一遍
前面讲的都是「该用什么」的规约。这一节把同一个场景用三种写法各实现一遍,让它们的行为差异直接打在屏幕上。
场景很简单:创建两个对象、求它们的值之和、然后释放。三种写法算出来的和完全一样,但「谁负责释放、什么时候释放」是三个不同的答案。
// ownership_demo.cpp — 编译: g++ -std=c++17 -Wall -O2 ownership_demo.cpp -o own#include<iostream>#include<memory>structNode{intvalue;explicitNode(intv):value(v){std::cout<<" + 创建 Node("<<v<<")\n";}~Node(){std::cout<<" - 析构 Node("<<value<<")\n";}};// 写法 1:用裸指针表达所有权 —— 反例,不要这么写// 谁 new 谁 delete;漏掉任何一条路径(包括抛异常的路径)就是泄漏intsum_with_raw(){std::cout<<"[1] 裸指针拥有(反例,不要这么写)\n";Node*a=newNode(1);Node*b=newNode(2);constints=a->value+b->value;deletea;// 这两个 delete 必须手动写对deleteb;returns;}// 写法 2:unique_ptr 独占所有权 —— 推荐intsum_with_unique(){std::cout<<"[2] std::unique_ptr 拥有\n";constautoa=std::make_unique<Node>(3);constautob=std::make_unique<Node>(4);returna->value+b->value;// 没有 delete:离开作用域自动释放}// 写法 3:shared_ptr 共享所有权 —— 代价是引用计数intsum_with_shared(){std::cout<<"[3] std::shared_ptr 拥有\n";constautoa=std::make_shared<Node>(5);constautob=std::make_shared<Node>(6);conststd::shared_ptr<Node>alias=a;// 共享同一个对象,计数 +1std::cout<<" a 的引用计数 = "<<a.use_count()<<'\n';returnalias->value+b->value;}intmain(){constintr1=sum_with_raw();constintr2=sum_with_unique();constintr3=sum_with_shared();std::cout<<"\n三种写法的和: "<<r1<<" / "<<r2<<" / "<<r3<<'\n';}[1] 裸指针拥有(反例,不要这么写) + 创建 Node(1) + 创建 Node(2) - 析构 Node(1) - 析构 Node(2) [2] std::unique_ptr 拥有 + 创建 Node(3) + 创建 Node(4) - 析构 Node(4) - 析构 Node(3) [3] std::shared_ptr 拥有 + 创建 Node(5) + 创建 Node(6) a 的引用计数 = 2 - 析构 Node(6) - 析构 Node(5) 三种写法的和: 3 / 7 / 11三种写法算出的和一致,差别全在析构日志的顺序和来源上:
- 写法 1(裸指针):
delete a; delete b;是手写的,日志里析构 Node(1)、析构 Node(2)出现在函数返回之前。这两行的顺序、位置、有无,全部取决于人手 —— 漏掉一条分支(更别说抛异常提前返回的那条)就是泄漏。这段代码能跑对,是因为它太短了;真实的类有十几个成员、几十条分支时,「每条路径都记得 delete」是人力难以保证的。 - 写法 2(
unique_ptr):日志里没有一行delete,但析构 Node(4)、析构 Node(3)照样出现,顺序是声明的逆序(先析构后声明的b)。这就是 RAII:释放时机由对象生命周期决定,而不是由你记不记得写决定。 - 写法 3(
shared_ptr):多了一行a 的引用计数 = 2。alias和a指向同一个对象,所以最后一个持有者析构时才真正释放(日志里Node(5)最后才被析构)。这行计数就是共享所有权的运行时代价。
所以「到底能不能用裸指针」的答案落在所有权上:用来「借」完全可以(比如下面第 8 节那些const Widget*参数),用来「拥有」就该换成智能指针(写法 2 或 3)。同一份业务逻辑,写法 2 比写法 1 更短、更安全,而且性能上没有损失。
「不拥有」在真实 API 里的形态:引用还是指针
上面是「谁拥有」的对比,这一节回到最日常的场景:函数参数。参数是「不拥有」指针出现频率最高的地方,而同一个操作往往可以有两种签名:一种承诺「对象一定存在」,另一种允许「这次没有」。
// borrow_api.cpp — 编译: g++ -std=c++17 -Wall -O2 borrow_api.cpp -o borrow#include<iostream>#include<string>#include<vector>structWidget{std::string name;intwidth;};// 签名 A:引用 —— 承诺「对象一定存在」,函数体内不需要判空voidrender(Widget&w){std::cout<<"渲染 <"<<w.name<<"> 宽度="<<w.width<<'\n';}// 签名 B:指针 —— 允许「这次没有」,nullptr 是有意义的取值而不是错误voidrender_optional(constWidget*w){if(!w){std::cout<<"本次没有 widget,跳过\n";return;}std::cout<<"渲染 <"<<w->name<<"> 宽度="<<w->width<<'\n';}// 签名 C:可选输出参数 —— 传 nullptr 就是「别写,我不关心结果」voidmeasure(constWidget&w,int*out_width){if(out_width)*out_width=w.width;}intmain(){Widget panel{"panel",320};render(panel);// 对象必存在 -> 引用(强契约)render_optional(&panel);// 对象存在,但接口允许为空render_optional(nullptr);// 语义明确的「没有」intwidth=-1;measure(panel,&width);// 关心结果std::cout<<"取到的宽度 = "<<width<<'\n';measure(panel,nullptr);// 不关心结果,什么都不写std::cout<<"宽度保持不变 = "<<width<<'\n';std::vector<Widget>widgets{panel};constWidget*borrowed=&widgets.front();// 只是借看,不拥有render_optional(borrowed);}渲染 <panel> 宽度=320 渲染 <panel> 宽度=320 本次没有 widget,跳过 取到的宽度 = 320 宽度保持不变 = 320 渲染 <panel> 宽度=320三个签名都不涉及所有权,谁都不负责释放Widget。区别只在契约强度:render(Widget&)用引用把「一定存在」写进类型,因此函数体里一个判空都没有;render_optional(const Widget*)用指针把「可能没有」显式暴露给调用方,nullptr是合法输入;measure的int* out_width则是「可选输出参数」,调用方传nullptr就等于声明「我不关心这个结果」。
反过来看反面写法:如果写成void render(std::shared_ptr<Widget> w),就等于要求调用方必须用共享所有权持有这个对象。可是panel明明是栈上对象,为了调用这个函数,调用方不得不std::make_shared<Widget>(panel)复制一份到堆上,多一次分配、多一个控制块、多一次原子计数,纯粹是被接口逼出来的开销。
易错点与常见误解
关于裸指针的讨论里,下面几条误解流传最广:
- 「裸指针就是内存泄漏的根源」 —— 不是。泄漏的根源是所有权没人认领。
const Widget* borrowed = &widgets.front();这样的非拥有指针不负责释放,它永远不会泄漏,也不会 double free。Core Guidelines 的观点正是「T*默认就是不拥有的」,问题出在有人拿它当拥有的用。 - 「参数统一用
shared_ptr传最安全」 —— 恰恰相反。按值收std::shared_ptr<T>会强制要求共享所有权,逼调用方把栈对象搬上堆,还多一次原子增减。Core Guidelines F.7 的建议是:只读就const T&,要改就T&,可能为空才const T*/T*。 - 「返回值用引用比用指针更现代」 —— 只在「一定能返回一个对象」时成立。查找类函数必须有「没找到」这个取值,这时
const T*配nullptr才是诚实的设计;为了用引用而返回一个静态哨兵对象,调用方根本没法分辨「找到的正好是哨兵」和「没找到」。 - 「
unique_ptr有运行时代价,热路径上还是得裸指针」 —— 用默认删除器时std::unique_ptr<T>与T*同尺寸、同性能(见下一节的表),它比裸指针只多了一条编译期规则「不能复制」。真需要把指针传给别人看时,get()借出去就行,不必放弃所有权表达。 - 「只要是非拥有指针就绝对安全」 —— 不拥有不等于不会悬垂。非拥有指针必须保证「被观察对象的生命周期不短于观察者」。函数参数场景天然安全(借用只发生在调用期间);一旦把非拥有指针存进成员变量或容器,就得自己担保生命周期 —— 这正是
std::weak_ptr存在的理由。
性能与内存视角:三种表达方式的成本
规约之外,还有必要把开销算清楚,因为「智能指针有开销」是很多人在热路径上退回裸指针的理由。实际情况是:独占所有权零开销,共享所有权才有成本。
| 表达方式 | 大小(x86-64) | 释放动作 | 能否复制 | 适合 |
|---|---|---|---|---|
T* | 8 字节 | 无(它不负责) | 能 | 不拥有的借用 |
std::unique_ptr<T> | 8 字节 | 1 次delete | 不能,只能移动 | 独占所有权 |
std::shared_ptr<T> | 16 字节 | 原子递减引用计数 | 能 | 真的需要共享所有权 |
unique_ptr与裸指针同尺寸、零运行时开销。默认删除器std::default_delete<T>是个空类,被空基类优化吸收,不占任何空间;析构时的delete调用本来就是你手写版也要做的。它省下的是「忘记 delete」「重复 delete」「异常路径漏 delete」三类 bug,代价只是「不能复制」这条编译期约束。shared_ptr是两个指针:一个指向对象,一个指向控制块;控制块里放着强/弱引用计数。std::make_shared能把对象和控制块的分配合并成一次,比shared_ptr<T>(new T)少一次堆分配,也更利于缓存局部性。但计数增减是原子操作,在多线程高并发路径上会形成 cache line 争抢 —— 这也是 Core Guidelines 反对「随手按值传shared_ptr」的性能理由。- 非拥有裸指针本身没有任何成本。它是观察者,不参与生命周期管理,不需要引用计数,也不产生任何额外指令。所以在只读遍历、可选输出这类「借」的场景里,用
const T*是既正确又最快的选择 —— 这两件事在这里并不冲突。
官方文档:R.30: Take smart pointers as parameters only to explicitly express lifetime semantics (C++ Core Guidelines) · std::make_shared (cppreference)
延伸阅读
- R.3 / I.11 / F.7 / F.18 (C++ Core Guidelines):裸指针只做不拥有、所有权靠智能指针的完整规约。
- std::unique_ptr (cppreference):表达独占所有权的标准工具,无额外开销。
- std::shared_ptr (cppreference):共享所有权场景,注意引用计数的代价。
收个尾
裸指针没错,错在拿它表达所有权。把它和引用严格限定在「不拥有」的借用位置(T&必存在、T*可空可选),所有权一律交给std::unique_ptr/std::shared_ptr/ 容器。签名里写清谁负责释放,泄漏和 double-free 就从类型层面消失了。这条边界一旦立住,「要不要用裸指针」就不再是信仰问题,而是看这个位置到底负不负责释放。