「裸指针还能不能用」网上一边倒的答案是「别用全换成std::unique_ptr」。这话对但只对了一半。C Core Guidelines 的真正立场更精细裸指针aT*本身没有错错的是用它去表达「所有权」。指针可以放心地当「观察者」用只要你约定它不负责释放。这篇把「不拥有」和「拥有」的边界划清楚并给你能直接套用的签名写法。官方文档R.3: A raw pointer (aT*) is non-owning (Core Guidelines)问题不在指针在「所有权」看这两行Widget*create();// 返回的指针谁负责 deletevoidf(Widget*w);// w 是借来的还是要我释放第一个签名让人头疼调用方拿到Widget*后到底该不该delete编译器没法告诉你文档也没写于是 leaks 和 double-free 就来了。根因是「所有权」被藏进了裸指针里。规约去建立T*只表示「我指着一个对象但我不拥有它」谁拥有谁就用智能指针或容器说清楚。所有权表达方式一览ASCII 图把「不拥有」和「拥有」两套表达摆在一张图里一眼看出该用什么表达「不拥有」(borrow不负责释放) T 指向必存在的对象不可为空、不可重绑 T* 指向对象可为 nullptr可选观察者 表达「拥有」(own负责释放/生命周期) std::unique_ptrT 独占所有权离开作用域自动释放 std::shared_ptrT 共享所有权引用计数归零释放 std::vectorT 拥有连续的一批元素 T 局部变量 栈上自动拥有作用域结束即析构 ────────────────────────────────────────────── 红线不要用裸指针 / 裸引用去「传递所有权」核心约定裸指针和裸引用只出现在「借」的位置函数参数、返回值指向别人拥有的对象「 ownership 的转移与持有」一律交给std::unique_ptr/std::shared_ptr/ 容器。这样从类型上就能读出一段代码要不要负责释放。什么时候裸指针仍是正确选择即使全程用智能指针下面四类场景裸指针依然是对的非拥有的观察者参数函数只读或临时借用一个对象用const T*或T*明确「我不负责它的生死」Core Guidelines F.7。可选输出参数需要「调用方可能不关心结果」时传T* outnullptr表示「别写」。返回值做不到「可选输出」。与 C API 交互C 接口只认裸指针桥接处必然出现T*这时它就是个不拥有的桥。指向栈上对象局部变量取地址传给别人看生命周期由栈管指针只是借道。官方文档F.7: For general use, takeT*orTto 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_ptrWidget create()int* find(Key)返回裸指针且隐含拥有混淆「不拥有」与「拥有」不拥有用const Widget*拥有用std::unique_ptrWidgetvoid read(std::shared_ptrWidget p)强行要求共享所有权调用方被迫make_sharedvoid 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 -stdc17 -Wall -O2 demo.cpp -o demo#includeiostream#includememory#includevector// 不拥有观察者只读不负责释放F.7voiddescribe(constint*p){if(p)std::cout值: *p\n;elsestd::cout空指针无对象\n;}// 可选输出参数传 nullptr 表示「不关心结果」voidmaybe_double(intin,int*out){if(out)*outin*2;}// 不拥有地返回找到的元素指针可空 没找到constint*find_first_even(conststd::vectorintv){for(constintx:v)if(x%20)returnx;returnnullptr;}// 转移所有权用 unique_ptr 明确「调用方负责释放」std::unique_ptrintmake_counter(){returnstd::make_uniqueint(0);// 无裸 new}intmain(){inta7;describe(a);// 非拥有观察者describe(nullptr);// 可空intresult0;maybe_double(5,result);std::cout5*2 result\n;maybe_double(5,nullptr);// 不关心结果啥也不做std::vectorintnums{1,3,4,7};constint*efind_first_even(nums);if(e)std::cout第一个偶数: *e\n;autocmake_counter();// 拥有std::coutcounter 初值: *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 -stdc17 -Wall -O2 ownership_demo.cpp -o own#includeiostream#includememorystructNode{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*anewNode(1);Node*bnewNode(2);constintsa-valueb-value;deletea;// 这两个 delete 必须手动写对deleteb;returns;}// 写法 2unique_ptr 独占所有权 —— 推荐intsum_with_unique(){std::cout[2] std::unique_ptr 拥有\n;constautoastd::make_uniqueNode(3);constautobstd::make_uniqueNode(4);returna-valueb-value;// 没有 delete离开作用域自动释放}// 写法 3shared_ptr 共享所有权 —— 代价是引用计数intsum_with_shared(){std::cout[3] std::shared_ptr 拥有\n;constautoastd::make_sharedNode(5);constautobstd::make_sharedNode(6);conststd::shared_ptrNodealiasa;// 共享同一个对象计数 1std::cout a 的引用计数 a.use_count()\n;returnalias-valueb-value;}intmain(){constintr1sum_with_raw();constintr2sum_with_unique();constintr3sum_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」是人力难以保证的。写法 2unique_ptr日志里没有一行delete但析构 Node(4)、析构 Node(3)照样出现顺序是声明的逆序先析构后声明的b。这就是 RAII释放时机由对象生命周期决定而不是由你记不记得写决定。写法 3shared_ptr多了一行a 的引用计数 2。alias和a指向同一个对象所以最后一个持有者析构时才真正释放日志里Node(5)最后才被析构。这行计数就是共享所有权的运行时代价。所以「到底能不能用裸指针」的答案落在所有权上用来「借」完全可以比如下面第 8 节那些const Widget*参数用来「拥有」就该换成智能指针写法 2 或 3。同一份业务逻辑写法 2 比写法 1 更短、更安全而且性能上没有损失。「不拥有」在真实 API 里的形态引用还是指针上面是「谁拥有」的对比这一节回到最日常的场景函数参数。参数是「不拥有」指针出现频率最高的地方而同一个操作往往可以有两种签名一种承诺「对象一定存在」另一种允许「这次没有」。// borrow_api.cpp — 编译: g -stdc17 -Wall -O2 borrow_api.cpp -o borrow#includeiostream#includestring#includevectorstructWidget{std::string name;intwidth;};// 签名 A引用 —— 承诺「对象一定存在」函数体内不需要判空voidrender(Widgetw){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(constWidgetw,int*out_width){if(out_width)*out_widthw.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::vectorWidgetwidgets{panel};constWidget*borrowedwidgets.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_ptrWidget w)就等于要求调用方必须用共享所有权持有这个对象。可是panel明明是栈上对象为了调用这个函数调用方不得不std::make_sharedWidget(panel)复制一份到堆上多一次分配、多一个控制块、多一次原子计数纯粹是被接口逼出来的开销。易错点与常见误解关于裸指针的讨论里下面几条误解流传最广「裸指针就是内存泄漏的根源」 —— 不是。泄漏的根源是所有权没人认领。const Widget* borrowed widgets.front();这样的非拥有指针不负责释放它永远不会泄漏也不会 double free。Core Guidelines 的观点正是「T*默认就是不拥有的」问题出在有人拿它当拥有的用。「参数统一用shared_ptr传最安全」 —— 恰恰相反。按值收std::shared_ptrT会强制要求共享所有权逼调用方把栈对象搬上堆还多一次原子增减。Core Guidelines F.7 的建议是只读就const T要改就T可能为空才const T*/T*。「返回值用引用比用指针更现代」 —— 只在「一定能返回一个对象」时成立。查找类函数必须有「没找到」这个取值这时const T*配nullptr才是诚实的设计为了用引用而返回一个静态哨兵对象调用方根本没法分辨「找到的正好是哨兵」和「没找到」。「unique_ptr有运行时代价热路径上还是得裸指针」 —— 用默认删除器时std::unique_ptrT与T*同尺寸、同性能见下一节的表它比裸指针只多了一条编译期规则「不能复制」。真需要把指针传给别人看时get()借出去就行不必放弃所有权表达。「只要是非拥有指针就绝对安全」 —— 不拥有不等于不会悬垂。非拥有指针必须保证「被观察对象的生命周期不短于观察者」。函数参数场景天然安全借用只发生在调用期间一旦把非拥有指针存进成员变量或容器就得自己担保生命周期 —— 这正是std::weak_ptr存在的理由。性能与内存视角三种表达方式的成本规约之外还有必要把开销算清楚因为「智能指针有开销」是很多人在热路径上退回裸指针的理由。实际情况是独占所有权零开销共享所有权才有成本。表达方式大小x86-64释放动作能否复制适合T*8 字节无它不负责能不拥有的借用std::unique_ptrT8 字节1 次delete不能只能移动独占所有权std::shared_ptrT16 字节原子递减引用计数能真的需要共享所有权unique_ptr与裸指针同尺寸、零运行时开销。默认删除器std::default_deleteT是个空类被空基类优化吸收不占任何空间析构时的delete调用本来就是你手写版也要做的。它省下的是「忘记 delete」「重复 delete」「异常路径漏 delete」三类 bug代价只是「不能复制」这条编译期约束。shared_ptr是两个指针一个指向对象一个指向控制块控制块里放着强/弱引用计数。std::make_shared能把对象和控制块的分配合并成一次比shared_ptrT(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 就从类型层面消失了。这条边界一旦立住「要不要用裸指针」就不再是信仰问题而是看这个位置到底负不负责释放。