「传参到底用指针还是引用?」这个问题被问了一万遍,多数回答却走向一个教条:用引用,指针是 C 的残余。这话只对了一半。引用有它不可替代的场合,但指针在好几类需求面前是唯一正确选项。下面务实地说清边界:什么时候该用引用,什么时候必须用指针,以及为什么两者在传参开销上其实一样。
官方文档:References (cppreference)
一个引用表达不了的需求
假设你写一个「在数组里找第一个偶数」的函数。找到了要返回那个元素的地址,没找到呢?引用不能为空、不能重绑,它根本没有「没找到」这个状态可以表达。这时候硬用引用,只会写出别扭的接口,或者偷偷塞一个哨兵值进去。指针天生能取nullptr,恰好把「可选」语义说清楚。下面从两者的硬性区别说起。
引用与指针的硬性区别
| 能力 | 引用T& | 指针T* |
|---|---|---|
| 必须初始化 | 是,且只能绑一次 | 否,可先声明后赋值 |
| 能否重新绑定到别的对象 | 不能(终身绑定) | 能(p = &other) |
| 能否为空(不指向任何对象) | 不能(理论上) | 能(nullptr) |
| 指针算术 / 遍历数组 | 不能 | 能(p++、p + i) |
| 表示数组或连续内存 | 不能(单对象) | 能(天然指向首元素) |
| 底层传递开销(大对象) | 传地址,约 8 字节 | 传地址,约 8 字节 |
结论先放这:引用胜在契约强,它向读代码的人承诺这里一定有个有效对象;指针胜在灵活,可空、可重定向、可算术。选哪个,取决于你要的是强契约还是灵活性。
什么时候必须用指针
下面四类需求,引用做不到或做得很丑,必须用指针(或它的现代替身std::optional,稍后说边界):
- 表达「可能没有」:函数可能找不到东西、可能失败,用
nullptr表示「无」比抛异常或哨兵值更轻量。 - 在容器里存「可重置的指向」:比如
std::vector<Node*>,某个槽位可以随时设成nullptr或换指另一个节点;引用既不能重绑也不能为空,做不到。 - 遍历数组 / 指针算术:要
p++挨个扫 buffer、用*(base + i)随机访问,只有指针能写。 - 多态对象:基类指针
Base*指向派生类实例,是实现运行时多态的经典手段。引用也能多态,但它不能数组化,也不能在「可能没有」时退成空。
官方文档: Polymorphism via pointers (cppreference)
传参开销对比:引用底层也是指针
有人觉得「引用比指针快」,这是误解。在常见平台(x86-64)上,引用在绝大多数实现里就是底层传了一个地址,和指针一模一样,没有解引用次数的差异,也没有拷贝整个大对象的开销。用 Compiler Explorer 看void f(const Big&)和void f(const Big*),生成的汇编对参数的存取指令基本一致。
传参开销(以大对象 Big 为例,64 位平台) 栈/寄存器里传的内容 是否拷贝整个对象 引用 T& 8 字节地址 否 指针 T* 8 字节地址 否 值 T 整个对象(可能上百字节) 是 <- 大对象才该用引用/指针避免所以引用和指针怎么选是语义问题,不是性能问题。大对象别按值传,至于引用和指针,开销相同。真正影响性能的是按值传大对象触发的整份拷贝,那才是要避的坑。
把两种「指向」在内存里画出来会看得更清楚。编译器给它们生成的机器表示几乎一样,差别全在类型系统加的那些约束上:
同一个对象 obj(栈地址 0x7ffd10),两种「指向」方式 int obj = 42; 0x7ffd10 ┌──────┐ │ 42 │ └──────┘ int& r = obj; r 不是一块独立存储,它就是 obj 的别名: r = 7 → 0x7ffd10 变成 7 &r → 永远是 0x7ffd10,绑不到别处 没有「空引用」这种状态 int* p = &obj; p 自己占 8 字节(假设放在 0x7ffd20): 0x7ffd20 ┌──────────┐ │ 0x7ffd10 │──► 0x7ffd10: 42 └──────────┘ p = nullptr → 指向「无」 (引用没有这个取值) p = &other → 改指别处 (引用做不到) p + 1 → 下一个 int (引用做不到) 传给函数时:f(const int&) 与 f(const int*) 传的都是一个地址,寄存器里的指令一致 结论:选引用还是指针 = 选「要哪些类型层约束」,与运行时开销无关完整示例 A:多态基类指针 + 可空查找
下面这个程序同时用到了「指针实现多态」和「返回nullptr表达没找到」两个必须用语指针的场景:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<vector>structShape{virtual~Shape()=default;// 基类析构必须 virtualvirtualvoiddraw()const=0;};structCircle:Shape{voiddraw()constoverride{std::cout<<"画圆\n";}};structSquare:Shape{voiddraw()constoverride{std::cout<<"画方\n";}};// 返回指向找到元素的指针;越界则返回 nullptr(表达「可能没有」)constShape*find_by_index(conststd::vector<Shape*>&shapes,intidx){if(idx<0||idx>=static_cast<int>(shapes.size()))returnnullptr;returnshapes[idx];}intmain(){Circle c;Square s;std::vector<Shape*>shapes{&c,&s};// 多态:基类指针容器for(constShape*p:shapes)p->draw();// 指针实现多态constShape*found=find_by_index(shapes,1);if(found)found->draw();elsestd::cout<<"没找到\n";constShape*miss=find_by_index(shapes,9);if(miss)miss->draw();elsestd::cout<<"没找到\n";return0;}画圆 画方 画方 没找到shapes是Shape*容器,循环里通过基类指针调用draw(),实际派发到Circle和Square。find_by_index越界时返回nullptr,这个「可能没有」的状态引用给不了。
完整示例 B:指针算术遍历数组
引用做不到「p++扫 buffer」,只有指针能写。这个例子用指针求和并随机访问:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>intmain(){intbuf[]={10,20,30,40};int*p=buf;// 指向首元素int*end=buf+4;// 尾后哨兵intsum=0;for(;p!=end;++p){// 指针遍历数组sum+=*p;}std::cout<<"和: "<<sum<<'\n';std::cout<<"第二个元素: "<<*(buf+1)<<'\n';// 指针算术随机访问return0;}和: 100 第二个元素: 20++p、*(buf + 1)这类指针算术是引用的禁区。标准库的很多底层遍历以及 C API 离不开指针,原因就在这儿。
完整示例 C:容器里存「可重置的指向」
前面那张区别表里「引用不能重新绑定」一行看着抽象,放到容器里就很具体了:std::vector<T*>里每个槽位都可以随时换成别的对象,或者重置成nullptr;而std::vector<T&>连写都写不出来,标准容器根本不存引用。
下面是一个观察者注册表:广播时遍历槽位,注销时把某个槽位置nullptr。
// observer_registry.cpp — 编译: g++ -std=c++17 -Wall -O2 observer_registry.cpp -o ob#include<iostream>#include<vector>structObserver{constchar*name;voidnotify()const{std::cout<<name<<" 收到通知\n";}};intmain(){Observer alpha{"alpha"};Observer beta{"beta"};Observer gamma{"gamma"};// 引用不能重绑、不能为空,所以进不了容器;指针可以std::vector<Observer*>registry{&alpha,&beta,&gamma};std::cout<<"--- 第一轮广播 ---\n";for(constObserver*o:registry)o->notify();registry[1]=nullptr;// 注销 beta:把槽位重置为空 —— 引用做不到std::cout<<"--- 第二轮广播 ---\n";for(constObserver*o:registry){if(o)o->notify();elsestd::cout<<"(空槽位,已注销)\n";}std::cout<<"注册表长度 = "<<registry.size()<<'\n';}--- 第一轮广播 --- alpha 收到通知 beta 收到通知 gamma 收到通知 --- 第二轮广播 --- alpha 收到通知 (空槽位,已注销) gamma 收到通知 注册表长度 = 3这里的registry只是借看alpha/beta/gamma三个栈对象,并不拥有它们,所以对象必须在registry之前声明、在它之后销毁。这正是「不拥有」型指针的固有代价:换来可重置、可为空的灵活性,代价是生命周期得由你自己担保。顺序写反了,第二轮广播就会读到一个已经没了的对象。
易错点与常见误解
「引用还是指针」这个话题里,下面几条误解传播得最广:
- 「引用一定不会悬垂,指针才会」。引用只是排除了「空」这一种失效状态。
const T& f() { T local; return local; }返回的是已经销毁的局部对象的引用;引用绑到一个delete过的对象同样会炸。引用不比指针更安全,它只是把「可能没有」这一种情况从类型里删掉了。 - 「C++17 里可以用
std::optional<T&>表达『不拥有且可能没有』」。不行。std::optional要求T是对象类型,std::optional<T&>不合法(标准禁止optional的模板参数为引用)。要表达「空」只有两条路:T*,或者用std::reference_wrapper<T>包一层,但它本身也不能为空。所以「不拥有 + 可能没有」的正确答案仍然是裸指针。 - 「返回
const T&比返回const T*更现代」。只在「一定能返回一个对象」时成立。查找类函数必须有「没找到」这个取值,硬用引用就只能返回一个静态哨兵对象,调用方无从分辨「找到的正好是哨兵」和「没找到」。Core Guidelines 的建议是可能失败就用指针,nullptr本身就是最清晰的失败信号。 - 「用引用就代表参数一定有效」。引用保证的是调用那一刻绑定到了一个对象,不保证这个对象之后一直活着。真正要防「对象被销毁后仍被使用」,得靠所有权设计,比如把非拥有指针换成
std::weak_ptr,而不是靠换签名。 - 「指针参数是 C 时代的遗留写法」。Core Guidelines F.7 恰恰推荐用
T*/T&传递可能被修改的对象。被劝退的从来不是裸指针本身,而是用裸指针传递所有权,那条是 I.11,属于另一个话题。 - 「
T* const和const T*差不多」。差得多:const T* p是内容不能改,T* const p是指针自己不能再指向别处。引用天然接近后者,绑定后就不能改指向,这也是T&与T*在可变性上一个微妙差异。用引用就不必再纠结顶层 const。
选型决策表
把前面所有场景收进一张表,照着选:
| 你的需求 | 用 |
|---|---|
| 参数一定存在、函数内不改指向、也不要空 | T&(强契约,首选) |
| 参数需要被修改且一定存在 | T&(非常量) |
| 表达「可能没有」——且不拥有它 | T*(配合nullptr判空) |
| 拥有且「可能没有」 | std::optional<T>(值语义,不裸指针) |
| 需要重新指向不同对象 | T* |
| 遍历数组 / 指针算术 / 缓冲区 | T*(或std::span) |
| 多态(基类接口、可数组化) | T*(引用也能多态但不能数组/重绑) |
务实提醒:表示「拥有且可能没有」时优先std::optional<T>而不是指针——它把「空」和「析构」一起管好了;表示「不拥有且可能没有」才用裸指针。所有权的边界是下一篇的主题。
延伸阅读
- Reference vs Pointer (cppreference):引用语义的权威定义。
- std::optional (cppreference):表达「拥有且可能没有」的现代替代。
- Compiler Explorer (godbolt.org):贴
f(const Big&)与f(const Big*)看汇编,验证两者传参开销一致。
收个尾
能用引用就用引用,需要「可能没有」、需要重新指向、需要算术,再换指针。传参开销两者一样,别把它当选型理由,那是想多了。
真正容易卡住的是所有权:谁负责delete这件事,引用和指针的写法都不写在脸上。这个得靠类型名和约定说清楚,下一篇专门讲。