☰
指针还是引用:什么时候必须用指针
2026/10/4 16:36:16 网站建设 项目流程

「传参到底用指针还是引用?」这个问题被问了一万遍,多数回答却走向一个教条:用引用,指针是 C 的残余。这话只对了一半。引用有它不可替代的场合,但指针在好几类需求面前是唯一正确选项。下面务实地说清边界:什么时候该用引用,什么时候必须用指针,以及为什么两者在传参开销上其实一样。

官方文档:References (cppreference)

一个引用表达不了的需求

假设你写一个「在数组里找第一个偶数」的函数。找到了要返回那个元素的地址,没找到呢?引用不能为空、不能重绑,它根本没有「没找到」这个状态可以表达。这时候硬用引用,只会写出别扭的接口,或者偷偷塞一个哨兵值进去。指针天生能取nullptr,恰好把「可选」语义说清楚。下面从两者的硬性区别说起。

引用与指针的硬性区别

能力引用T&指针T*
必须初始化是,且只能绑一次否,可先声明后赋值
能否重新绑定到别的对象不能(终身绑定)能(p = &other)
能否为空(不指向任何对象)不能(理论上)能(nullptr)
指针算术 / 遍历数组不能能(p++、p + i)
表示数组或连续内存不能(单对象)能(天然指向首元素)
底层传递开销(大对象)传地址,约 8 字节传地址,约 8 字节

结论先放这:引用胜在契约强,它向读代码的人承诺这里一定有个有效对象;指针胜在灵活,可空、可重定向、可算术。选哪个,取决于你要的是强契约还是灵活性。

什么时候必须用指针

下面四类需求,引用做不到或做得很丑,必须用指针(或它的现代替身std::optional,稍后说边界):

  1. 表达「可能没有」:函数可能找不到东西、可能失败,用nullptr表示「无」比抛异常或哨兵值更轻量。
  2. 在容器里存「可重置的指向」:比如std::vector<Node*>,某个槽位可以随时设成nullptr或换指另一个节点;引用既不能重绑也不能为空,做不到。
  3. 遍历数组 / 指针算术:要p++挨个扫 buffer、用*(base + i)随机访问,只有指针能写。
  4. 多态对象:基类指针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这件事,引用和指针的写法都不写在脸上。这个得靠类型名和约定说清楚,下一篇专门讲。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询