ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

指针还是引用:什么时候必须用指针

指针还是引用:什么时候必须用指针 「传参到底用指针还是引用」这个问题被问了一万遍多数回答却走向一个教条用引用指针是 C 的残余。这话只对了一半。引用有它不可替代的场合但指针在好几类需求面前是唯一正确选项。下面务实地说清边界什么时候该用引用什么时候必须用指针以及为什么两者在传参开销上其实一样。官方文档References (cppreference)一个引用表达不了的需求假设你写一个「在数组里找第一个偶数」的函数。找到了要返回那个元素的地址没找到呢引用不能为空、不能重绑它根本没有「没找到」这个状态可以表达。这时候硬用引用只会写出别扭的接口或者偷偷塞一个哨兵值进去。指针天生能取nullptr恰好把「可选」语义说清楚。下面从两者的硬性区别说起。引用与指针的硬性区别能力引用T指针T*必须初始化是且只能绑一次否可先声明后赋值能否重新绑定到别的对象不能终身绑定能p other能否为空不指向任何对象不能理论上能nullptr指针算术 / 遍历数组不能能p、p i表示数组或连续内存不能单对象能天然指向首元素底层传递开销大对象传地址约 8 字节传地址约 8 字节结论先放这引用胜在契约强它向读代码的人承诺这里一定有个有效对象指针胜在灵活可空、可重定向、可算术。选哪个取决于你要的是强契约还是灵活性。什么时候必须用指针下面四类需求引用做不到或做得很丑必须用指针或它的现代替身std::optional稍后说边界表达「可能没有」函数可能找不到东西、可能失败用nullptr表示「无」比抛异常或哨兵值更轻量。在容器里存「可重置的指向」比如std::vectorNode*某个槽位可以随时设成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 -stdc17 -Wall -O2 demo.cpp -o demo#includeiostream#includevectorstructShape{virtual~Shape()default;// 基类析构必须 virtualvirtualvoiddraw()const0;};structCircle:Shape{voiddraw()constoverride{std::cout画圆\n;}};structSquare:Shape{voiddraw()constoverride{std::cout画方\n;}};// 返回指向找到元素的指针越界则返回 nullptr表达「可能没有」constShape*find_by_index(conststd::vectorShape*shapes,intidx){if(idx0||idxstatic_castint(shapes.size()))returnnullptr;returnshapes[idx];}intmain(){Circle c;Square s;std::vectorShape*shapes{c,s};// 多态基类指针容器for(constShape*p:shapes)p-draw();// 指针实现多态constShape*foundfind_by_index(shapes,1);if(found)found-draw();elsestd::cout没找到\n;constShape*missfind_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 -stdc17 -Wall -O2 demo.cpp -o demo#includeiostreamintmain(){intbuf[]{10,20,30,40};int*pbuf;// 指向首元素int*endbuf4;// 尾后哨兵intsum0;for(;p!end;p){// 指针遍历数组sum*p;}std::cout和: sum\n;std::cout第二个元素: *(buf1)\n;// 指针算术随机访问return0;}和: 100 第二个元素: 20p、*(buf 1)这类指针算术是引用的禁区。标准库的很多底层遍历以及 C API 离不开指针原因就在这儿。完整示例 C容器里存「可重置的指向」前面那张区别表里「引用不能重新绑定」一行看着抽象放到容器里就很具体了std::vectorT*里每个槽位都可以随时换成别的对象或者重置成nullptr而std::vectorT连写都写不出来标准容器根本不存引用。下面是一个观察者注册表广播时遍历槽位注销时把某个槽位置nullptr。// observer_registry.cpp — 编译: g -stdc17 -Wall -O2 observer_registry.cpp -o ob#includeiostream#includevectorstructObserver{constchar*name;voidnotify()const{std::coutname 收到通知\n;}};intmain(){Observer alpha{alpha};Observer beta{beta};Observer gamma{gamma};// 引用不能重绑、不能为空所以进不了容器指针可以std::vectorObserver*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过的对象同样会炸。引用不比指针更安全它只是把「可能没有」这一种情况从类型里删掉了。「C17 里可以用std::optionalT表达『不拥有且可能没有』」。不行。std::optional要求T是对象类型std::optionalT不合法标准禁止optional的模板参数为引用。要表达「空」只有两条路T*或者用std::reference_wrapperT包一层但它本身也不能为空。所以「不拥有 可能没有」的正确答案仍然是裸指针。「返回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::optionalT值语义不裸指针需要重新指向不同对象T*遍历数组 / 指针算术 / 缓冲区T*或std::span多态基类接口、可数组化T*引用也能多态但不能数组/重绑务实提醒表示「拥有且可能没有」时优先std::optionalT而不是指针——它把「空」和「析构」一起管好了表示「不拥有且可能没有」才用裸指针。所有权的边界是下一篇的主题。延伸阅读Reference vs Pointer (cppreference)引用语义的权威定义。std::optional (cppreference)表达「拥有且可能没有」的现代替代。Compiler Explorer (godbolt.org)贴f(const Big)与f(const Big*)看汇编验证两者传参开销一致。收个尾能用引用就用引用需要「可能没有」、需要重新指向、需要算术再换指针。传参开销两者一样别把它当选型理由那是想多了。真正容易卡住的是所有权谁负责delete这件事引用和指针的写法都不写在脸上。这个得靠类型名和约定说清楚下一篇专门讲。
返回列表