「裸指针还能不能用?」网上一边倒的答案是「别用,全换成 std::unique_ptr」。这话对,但只对了一半。C++ Core Guidelines 的真正立场更精细:裸指针(a T*)本身没有错,错的是用它去表达「所有权」。指针可以放心地当「观察者」用,只要你约定它不负责释放。这篇把「不拥有」和「拥有」的边界划清楚,并给你能直接套用的签名写法。
官方文档:R.3: A raw pointer (a
T*) is non-owning (Core Guidelines)
问题不在指针,在「所有权」
看这两行:
cpp
Widget* create(); // 返回的指针谁负责 delete?
void f(Widget* w); // w 是借来的,还是要我释放?
第一个签名让人头疼:调用方拿到 Widget* 后,到底该不该 delete?编译器没法告诉你,文档也没写,于是 leaks 和 double-free 就来了。根因是「所有权」被藏进了裸指针里。规约去建立:T* 只表示「我指着一个对象,但我不拥有它」;谁拥有,谁就用智能指针或容器说清楚。
所有权表达方式一览(ASCII 图)
把「不拥有」和「拥有」两套表达摆在一张图里,一眼看出该用什么:
text
表达「不拥有」(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。
cpp
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <memory>
#include <vector>
// 不拥有观察者:只读,不负责释放(F.7)
void describe(const int* p) {
if (p) std::cout << "值: " << *p << '\n';
else std::cout << "空指针,无对象\n";
}
// 可选输出参数:传 nullptr 表示「不关心结果」
void maybe_double(int in, int* out) {
if (out) *out = in * 2;
}
// 不拥有地返回找到的元素(指针可空 = 没找到)
const int* find_first_even(const std::vector<int>& v) {
for (const int& x : v) if (x % 2 == 0) return &x;
return nullptr;
}
// 转移所有权:用 unique_ptr 明确「调用方负责释放」
std::unique_ptr<int> make_counter() {
return std::make_unique<int>(0); // 无裸 new
}
int main() {
int a = 7;
describe(&a); // 非拥有观察者
describe(nullptr); // 可空
int result = 0;
maybe_double(5, &result);
std::cout << "5*2 = " << result << '\n';
maybe_double(5, nullptr); // 不关心结果,啥也不做
std::vector<int> nums{1, 3, 4, 7};
const int* e = find_first_even(nums);
if (e) std::cout << "第一个偶数: " << *e << '\n';
auto c = make_counter(); // 拥有
std::cout << "counter 初值: " << *c << '\n';
return 0;
}
text
值: 7
空指针,无对象
5*2 = 10
第一个偶数: 4
counter 初值: 0
describe / find_first_even 用的裸指针全部「不拥有」,它们只借看、不会去释放;make_counter 用 std::unique_ptr 把所有权显式交还给调用方,c 离开作用域时自动析构,无泄漏。对比第 5 节那张坏表,create() 若返回裸指针,这段所有权就含糊了。
实证对比:同一个场景,三种所有权写法各写一遍
前面讲的都是「该用什么」的规约。这一节把同一个场景用三种写法各实现一遍,让它们的行为差异直接打在屏幕上。
场景很简单:创建两个对象、求它们的值之和、然后释放。三种写法算出来的和完全一样,但「谁负责释放、什么时候释放」是三个不同的答案。
cpp
// ownership_demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 ownership_demo.cpp -o own
#include <iostream>
#include <memory>
struct Node {
int value;
explicit Node(int v) : value(v) { std::cout << " + 创建 Node(" << v << ")\n"; }
~Node() { std::cout << " - 析构 Node(" << value << ")\n"; }
};
// 写法 1:用裸指针表达所有权 ------ 反例,不要这么写
// 谁 new 谁 delete;漏掉任何一条路径(包括抛异常的路径)就是泄漏
int sum_with_raw() {
std::cout << "[1] 裸指针拥有(反例,不要这么写)\n";
Node* a = new Node(1);
Node* b = new Node(2);
const int s = a->value + b->value;
delete a; // 这两个 delete 必须手动写对
delete b;
return s;
}
// 写法 2:unique_ptr 独占所有权 ------ 推荐
int sum_with_unique() {
std::cout << "[2] std::unique_ptr 拥有\n";
const auto a = std::make_unique<Node>(3);
const auto b = std::make_unique<Node>(4);
return a->value + b->value; // 没有 delete:离开作用域自动释放
}
// 写法 3:shared_ptr 共享所有权 ------ 代价是引用计数
int sum_with_shared() {
std::cout << "[3] std::shared_ptr 拥有\n";
const auto a = std::make_shared<Node>(5);
const auto b = std::make_shared<Node>(6);
const std::shared_ptr<Node> alias = a; // 共享同一个对象,计数 +1
std::cout << " a 的引用计数 = " << a.use_count() << '\n';
return alias->value + b->value;
}
int main() {
const int r1 = sum_with_raw();
const int r2 = sum_with_unique();
const int r3 = sum_with_shared();
std::cout << "\n三种写法的和: " << r1 << " / " << r2 << " / " << r3 << '\n';
}
text
[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 里的形态:引用还是指针
上面是「谁拥有」的对比,这一节回到最日常的场景:函数参数。参数是「不拥有」指针出现频率最高的地方,而同一个操作往往可以有两种签名:一种承诺「对象一定存在」,另一种允许「这次没有」。
cpp
// borrow_api.cpp --- 编译: g++ -std=c++17 -Wall -O2 borrow_api.cpp -o borrow
#include <iostream>
#include <string>
#include <vector>
struct Widget {
std::string name;
int width;
};
// 签名 A:引用 ------ 承诺「对象一定存在」,函数体内不需要判空
void render(Widget& w) {
std::cout << "渲染 <" << w.name << "> 宽度=" << w.width << '\n';
}
// 签名 B:指针 ------ 允许「这次没有」,nullptr 是有意义的取值而不是错误
void render_optional(const Widget* w) {
if (!w) {
std::cout << "本次没有 widget,跳过\n";
return;
}
std::cout << "渲染 <" << w->name << "> 宽度=" << w->width << '\n';
}
// 签名 C:可选输出参数 ------ 传 nullptr 就是「别写,我不关心结果」
void measure(const Widget& w, int* out_width) {
if (out_width) *out_width = w.width;
}
int main() {
Widget panel{"panel", 320};
render(panel); // 对象必存在 -> 引用(强契约)
render_optional(&panel); // 对象存在,但接口允许为空
render_optional(nullptr); // 语义明确的「没有」
int width = -1;
measure(panel, &width); // 关心结果
std::cout << "取到的宽度 = " << width << '\n';
measure(panel, nullptr); // 不关心结果,什么都不写
std::cout << "宽度保持不变 = " << width << '\n';
std::vector<Widget> widgets{panel};
const Widget* borrowed = &widgets.front(); // 只是借看,不拥有
render_optional(borrowed);
}
text
渲染 <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 就从类型层面消失了。这条边界一旦立住,「要不要用裸指针」就不再是信仰问题,而是看这个位置到底负不负责释放。