C++指针与引用入门:用门牌号和永久小名理解内存地址与传参 说实话写这篇之前我犹豫了一下。网上讲指针和引用区别的文章少说也有几百篇可多数还是逃不出“指针存地址、引用是别名”这种车轱辘话。看完之后该翻的代码还是会翻该犯的错照样犯。所以我尝试换个角度用“门牌号纸条”和“永久小名”这两个场景把指针和引用彻底拆开讲。你如果愿意跟着走一遍五分钟之后回头再看那些带星号的代码应该会有完全不一样的感觉。这个话题适合三类人刚学 C/C 被指针折磨得想放弃的同学面试前背了几遍“区别”但心里没底的人以及从 Java、Python 等语言转过来想弄明白“为什么 C 非要搞出两种间接引用方式”的开发者。下面的代码我全部用 C 写但核心思路放到 C、C 甚至底层调试里都是通用的。1. 指针你手里拿的是一张门牌号纸条不是房间本身1.1 把“地址”翻译成人话一栋楼、几个房间、一张纸条想象内存是一栋宿舍楼楼里每一间房都有固定门牌号。一个int a 42;就可以理解成42号房间里住着数值42。门牌号在编程里叫内存地址门前挂的“42”这个数字叫值两者经常被人搞混其实完全不是一回事。当我们要找一个变量时有两个信息可用一个是它的门牌号a一个是它的值a。指针就是负责记录门牌号的一张纸条。int a 42; int* p a; // p 这张纸条上写的是 a 的门牌号这里有一个特别容易误解的点p不是一个房间它只是一张写着门牌号的纸条。你把它揣在兜里并不会影响 42 号房间里住着谁。所以读int* p的时候别念成“int星星p”要念成“指向 int 的指针p”意思是p 指向的地方会住着一个 int 类型的值。很多人一上来就被*号吓懵其实把声明拆开看就好。int* p里的*是“指向”的意思它表示 p 不是 int而是指向 int。右边的 p 是变量名左边是整个变量的类型。这里多讲一句为了美观也有人写成int *p甚至int*p三种写法编译器都认但初学者最好固定用int* p这样容易记住“p 的类型是 int*”而不是“p 是 int”。还有一个细节容易被忽略a取出来的地址是 42 号房间的地址不是“纸条”的地址。纸条本身放在哪里是另一回事。如果你再看一层int** pp p;那就是在笔记里再记一笔“p 这张纸条放在 8 号房间”。这就是二级指针的来源后面我会专门讲它到底有什么用。1.2 解引用拿着纸条去开门指针存了地址是为了以后能按图索骥。*p这个操作意思是“拿起纸条走到门牌号对应的房间推门进去”。它不像那样只是问路而是真正进到房间里能看到、能修改里面的东西。#include iostream int main() { int a 42; int* p a; *p 100; // 顺着 p 找到 a 的房间把里面的 42 改成 100 std::cout a std::endl; // 输出 100 return 0; }这里*p 100等价于a 100。因为 p 指向的就是 a不是 a 的复制品。这个特性非常关键函数传参的时候能改到外部变量靠的也是这一层“间接”。理解指针你只需要建立两个箭头第一个箭头从 p 到 a第二个箭头从 a 到 a 的值。指针变量 p 本身是实实在在存在的东西它有大小、有地址但它存的不是“数据本尊”而是“数据在哪儿”的线索。为什么 C 语言当年非要设计这么麻烦的一层因为函数调用时默认是值传递值传递等于把房间里的东西全部复印一份交给别人复印成本高不说你在复印件上涂改原件也不会变。但如果你把门牌号告诉别人别人按门牌号上门处理原件的状态才会被真正改变。大型结构体、数组、对象全复制一遍的成本往往高到不可接受所以指针成了 C 系语言绕不开的基础设施。1.3 空指针和野指针废纸条最容易惹祸指针给你自由也给你两种把自己坑死的方式。第一种是空指针。int* p nullptr;相当于一张空白纸条上面没有写任何门牌号。这时候千万不能做*p因为你拿着白条去开门压根不知道要开哪扇门程序大概率直接崩溃。这个在 C 里会触发未定义行为常见表现就是段错误。第二种更阴险叫野指针。它是一张写了门牌号的纸条但那个门牌号对应的房间可能已经退了租、改了结构甚至变成公共厕所。你拿着它去开门运气好程序崩溃让你知道自己错了运气不好你改了另一个完全不相关的变量的内存程序表面还能跑但数据已经悄悄烂掉了。我见过太多疑难 bug最后查出来都是这种“看似没崩其实一直写错地方”的野指针。所以我在代码里给自己立了几条规矩指针声明时就初始化要么指向合法对象要么给nullptr绝不悬空。一个指针用完置空前先想清楚它指向的对象是否还活着。看到别人的代码里出现裸指针第一反应是查它的生命周期而不是直接解引用。这里补充一个很多人忽略的常识空指针至少还能被if (p nullptr)抓出来野指针却长着一张“正常地址”的脸调试器里打印出来也挺像回事排查成本远高于空指针。平时写代码宁可多写一个判空也不要在不确定指针是否有效的时候直接*p。2. 引用给同一个人起个永久小名2.1 为什么有了指针还不够指针很强但它的表达方式太啰嗦了。你想修改一个外部变量得先a取地址再在函数里*p解引用一个星号一个与符号满天飞。而且指针的“可改签”能力是一把双刃剑——它既可以指向这里中途又可以指向那里这让阅读代码的人很难确认它到底想干嘛。引用解决的就是这个问题。它像给同一个人起了一个永久小名。我们说“老王”和“住在302的王大爷”是同一个对象只是叫法不同。引用就是这种关系int a 42; int r a; // r 是 a 的另一个名字从此刻起r 和 a 就是同一间房间的两种称呼。你喊 r房间里的人会应你喊 a应的还是同一个人。修改 r 就是修改 a读取 a 就等于读取 r。有人问那这和int b a;有什么区别区别非常大。int b a;是重新开了一间房把 a 房间里的东西复制一份搬进去以后 b 和 a 毫无关系而int r a;从头到尾只有一间房r 和 a 都指向同一个门牌号。用调试器打印一下地址r和a是完全相同的但b和a不同。2.2 引用是别名不是复制在实际代码里引用最常见的场景是函数传参。写一个交换函数指针版本和引用版本对比一下void swap_ptr(int* x, int* y) { int t *x; *x *y; *y t; } void swap_ref(int x, int y) { int t x; x y; y t; }调用的时候指针版本要写swap_ptr(a, b);引用版本直接写swap_ref(a, b);。效果完全一样但引用版本读起来少了一层“取地址、解引用”的脑内翻译。函数内部用引用时感觉就跟操作本地变量一样唯一的区别是它操作的其实是调用者传进来的那个变量本体。这也是很多人初学时的误区以为int x是把变量复制一份进函数。不是的。int x是给调用者传进来的变量起了一个局部小名函数体里的 x 就是外面的那个实参。所以你在函数里给 x 赋值外面的变量就会跟着变。引用还解决了“拷贝昂贵”的问题。比如传一个很大的std::vector如果按值传整个容器所有元素都要复制一遍如果传引用等于只告诉函数“数据在那儿”直接看原件不花一分钱拷贝费。后面讲遍历的时候我还会提这一点。2.3 三条铁律必须初始化、绑定后不改、不为空引用比指针安全靠的是三条硬规则。但凡违反其中一条代码根本编译不过。铁律一引用必须初始化。指针可以先声明int* p;以后再用引用不行。你写int r;就报错因为“给一个人起的小名”必须对应到某个确定的人。没有初始化的引用编译器直接拒绝这个设计堵住了很多悬空引用的源头。铁律二一旦绑定终身不能换人。猜猜下面这段代码输出什么int a 10, b 20; int r a; r b;很多人以为 r 改成绑定到 b 了其实不是。r b;干的事情是“把 b 的值赋给 a”所以执行完之后 a 变成 20b 还是 20r 依旧是 a 的小名。这大概是面试题里最容易翻车的点。记住引用不是指针不能用赋值操作来“改绑”赋值永远是在“改值”。铁律三引用不为空。因为必须初始化且不能指向空地址正常代码里几乎不会出现“空引用”。函数形参是引用时你不需要判空因为它必然绑定到某个对象。相比之下指针形参还要考虑nullptr所以很多现代 C 的代码风格约定能传引用就不用指针。这三条规则合在一起相当于给“间接访问”这件事上了保险。你少了一些指针的灵活性但换来了代码的确定性和阅读时的安全感。3. 最容易绕晕的几个对比点一张表加几句人话3.1 先看对比表再逐个拆为什么初学者背“指针和引用区别”最容易死记但考试和实战里最容易考的几个维度其实靠一张表就能说清对比项指针引用初始化可先声明后赋值定义时必须绑定对象重新绑定可以改指向其他对象终身绑定不能改绑空值可等于nullptr不允许为空自增地址向后移一个单位给原对象的值加 1sizeof指针变量本身大小语义上是原对象大小取地址p是指针变量的地址r永远是原对象的地址使用方式需用*解引用直接当原变量用这张表背后有一个本质区别指针是“先找地址再访问对象”中间隔了两层引用是“直接就是对象的另一个名字”只有一层。后续所有的细节差异几乎都可以从这一句话推出来。先解释最容易出错的第 4 到第 7 行。p之所以是“地址移一位”是因为指针本身是存地址的变量对它执行自增语义就是走到下一个同类型元素的地址这在遍历数组时非常常用。而r绑定的不是地址是对象本身所以它等价于a把值加一。如果你忘了这点调试时会看到完全意想不到的结果。3.2 const 到底锁谁看它待在星号的哪一边const一出现很多人就开始绕口令。其实判断规则只有一个const 修饰的是它右边最近的那个成分。在指针声明里关键是看 const 在*的左边还是右边。int a 1, b 2; const int* p1 a; // const 离 int 近锁的是“值”不能通过 p1 修改 *p1 // *p1 3; // 编译错误 p1 b; // 但 p1 可以改指向 int* const p2 a; // const 离 p2 近锁的是“指针本身”不能再改指向 *p2 3; // 可以修改指向房间里的值 // p2 b; // 编译错误 const int* const p3 a; // 值和指向都锁死可以记成一句口诀const 在星号左边锁指向的目标const 在星号右边锁指针自己。放到“门牌号纸条”的场景里const int* p是“你拿到了纸条但不能通过纸条改房间里的人”可纸条本身你可以换一张int* const p是“你手里的纸条不能换成别的”但房间里的人随你改。这两个场景完全不等价理解了场景就不可能忘。引用的 const 就简单多了。const int r a;表示通过 r 这个视角只能读取 a不能通过 r 修改 a。这里没有“const 引用不能改绑”的纠结因为引用本来就不能改绑。它还有一个妙用用const T接收临时对象时会延长临时对象的生命周期到引用离开作用域为止这是很多初学者不知道的“白送”特性。3.3 自增和 sizeof一个往前走地址一个往前走数值上面表格里自增行为已经讲了一半。现在补充一个实战里经常碰到的迷惑点数组名和指针的关系。int arr[5] {1, 2, 3, 4, 5}; int* p arr; // arr 在这里退化成首元素地址 p; // 现在 p 指向 arr[1]数组名在大多数表达式里会“退化”成指向首元素的指针所以p arr合法p之后就能访问下一个元素。但数组名本身不是指针变量你不能arr。这是因为数组名是一个“固定门牌号”不是一个可以改签的纸条。sizeof这一段特别坑sizeof(arr)返回整个数组的字节数比如int arr[5]在常见平台上返回 20但sizeof(p)返回指针变量本身的大小64 位系统下通常是 8。很多人在函数参数里写了void f(int arr[])以为拿到的是数组其实编译器把形参当int*处理sizeof(arr)结果就变成了指针大小于是一批传数组长度的经典 bug 诞生。引用版的sizeof(r)就单纯得多它语义上返回被引用对象的类型大小比如int r a;的sizeof(r)是sizeof(int)而不是“引用变量有多大”。这也再次提醒我们引用不是一个独立对象它没有自己的“包装盒”。4. 实战到底怎么选传参、返回值、遍历的决策原则4.1 函数形参先想拷贝成本再想能不能为空我会给函数据参定一个非常具体的顺序。第一步先问“我需要修改外部变量吗”。如果不需要再看拷贝成本。大对象用const T小对象直接用T按值传就好。int、double、char这种几个字节的类型没必要用引用按值传反而让调用方更放心——它知道你绝不会偷偷改它。第二步如果需要修改外部变量优先用T。引用语法干净绑定必合法代码里少一堆判空逻辑。交换函数、累加器、填充函数都应该用引用而不是裸指针。第三步再考虑“这个参数是不是可能不存在”。比如一个查找函数可能需要表达“没找到”。这时T*就比T合适了因为你可以传nullptr函数内部先判断再解引用。引用不允许“不存在”硬要用引用就只能靠抛异常或额外返回值来表达“空”反而麻烦。一句话总结引用用于“一定存在的对象”指针用于“可能不存在或需要改指向的对象”。现代 C 代码风格的通行倾向是普通函数接口优先const T和T只有语义上确实需要“可空”时才用裸指针。4.2 返回值能返回引用就别返回指针新建对象请交给智能指针返回值选引用还是指针取决于返回的东西归谁所有。如果返回的是容器里的元素、类里的成员、字符串里的某个字符这种“已经存在于某处”的对象直接用引用。比如标准库operator[]就返回T所以你能写出v[0] 10;这样的赋值。如果返回的是“新建出来的对象”事情就变了。它原本不存在是你创建出来的怎么办可以按值返回靠移动语义交付也可以返回智能指针std::unique_ptrT来明确所有权。绝大多数情况下不要返回裸指针更不要返回引用。还有个红牌规则永远不要返回局部变量的引用或指针。局部变量在函数返回时就销毁了你等于给外面的人一张“已经拆掉的房间”的地址条。这在调试时非常痛苦因为表面看起来函数算出了正确的值但调用方拿到的可能是一块已经被覆写的内存错误行为取决于编译器何时回收时好时坏。我在实际写码的时候给自己定了一个简单标准函数返回引用之前先在脑子里问一句“这个对象到底是谁的”如果回答不上来立刻改成值返回或智能指针。所有权模糊是 C 最难修的 bug 来源之一宁可多写几个移动构造也不要在生命周期上赌。4.3 容器遍历和数据结构引用省拷贝指针造结构遍历容器时引用带来的收益是体验式的。for (auto x : vec) { x 1; } // 不生效改的是拷贝 for (auto x : vec) { x 1; } // 直接修改容器里的元素 for (const auto x : vec) { sum x; } // 只读且零拷贝第一次写auto x的人十个有九个会困惑“为什么修改没生效”。原因就是值传递复制了一份。把auto换成auto每次迭代拿到的就不是副本而是容器元素的别名修改立刻写回原容器。如果只读用const auto既避免了拷贝又保证了不会误改。那为什么链表、树这些数据结构里的指针地位还是那么高因为节点之间的“连接关系”需要指向性、可空性、可重新指向。一个二叉树节点左孩子可能为空插入时要重定向parent-left删除时要移动节点——这些操作本质上都在“修改指针本身”引用在这个场合反而施展不开。你很少看到TreeNode left这种成员因为它无法表达“没有左孩子”的状态。所以实战选择并不是“引用一定比指针好”而是在接口层尽量用引用在数据结构内部合理使用指针。层和层之间的边界划清楚代码就会清爽很多。5. 容易翻车的三个底层细节5.1 引用在底层到底占不占内存这个问题问十个人可能给你三种答案。真相是C 标准只定义了引用的语义没规定它必须占内存。从语义上讲引用不是一个独立对象对引用取地址得到的是原对象的地址所以“引用自己没有地址”这句话在标准层面成立。但从实现层面看很多编译器的做法是用一个指针来表达引用本质是一个“自动解引用的常量指针”。更妙的是因为编译器通常在编译期就能确定引用绑定到谁它会做大量优化直接把引用替换成原变量本身。这时候引用既不占栈空间也不存在“真正的引用变量”一切只是代码层面的一种“别名标注”。实际操作中你感受到的差异是一个类里如果有一个引用成员这个类通常确实会因为这个引用变“大”因为编译器和 ABI 需要为这个引用预留一个指针大小的存储空间但栈上的局部引用变量绝大多数情况下折腾不出额外空间。所以不要再背“引用一定占 8 字节”或“引用一定不占空间”这种结论标准没保证编译器也在变。5.2 悬空引用为什么比悬空指针更阴险指针用不好会有悬空指针引用同样会有悬空引用。典型的例子int getLocal() { int x 42; return x; // 返回局部变量的引用悬空警告 }一旦函数结束x 的生命周期就结束了给外面留下的引用成了一个“指向已拆房间”的永久小名。更坑的是引用用起来太顺手了你根本不会像指针那样先判空再使用你对悬空引用的一切访问都是直接往一块已经释放的内存上读写出错的时候连个缓冲都没有。我排查这类问题最有效的手段首先是打开编译器的警告很多编译器对返回局部变量引用会直接报 warning其次是用 AddressSanitizer 之类的工具跑一遍逻辑它能在你访问到非法内存的第一时间告诉你哪个地址出问题最后是写代码时盯住一句话引用和指针的生命周期必须短于或等于它们所指向对象的生命周期。这句叮嘱值得写在手边。5.3 二级指针 vs 指针引用想改指针本身怎么办一级指针能修改“指针指向的值”那如果我想修改“指针本身”呢比如我想写个函数把外部指针 p 改成指向另一个对象或者改成nullptr。话题终于回到了 1.1 里埋的那个伏笔你需要知道 p 这张纸条放在哪里于是就有了二级指针。void reset_ptr(int** pp) { if (pp) *pp nullptr; // 把外部指针本身置空 } void reset_ref(int* p) { p nullptr; // 同样把外部指针置空但语法干净得多 }调用时分别是reset_ptr(p);和reset_ref(p);。效果一样但引用版本对读者友好太多因为它直接表达了“这个函数要操作的是指针 p 本身”。理解了这条很多链表面试题的思路就通了一个口子链表头节点的增删往往需要改“头指针本身”所以常用二级指针或指针引用来接收头节点。如果你在函数里只传一级指针而且试图改头节点指向改完外面毫无变化因为这个一级指针只是外部头指针的拷贝。顺便说一句现代 C 里很多场景可以用std::unique_ptr替代二级指针职责更清晰。但无论用哪种形式内核都是一样的通过“引用的引用”或“指针的指针”再往深一层拿到修改目标。6. 从“背过”到“会用”三道自测题和一套练法6.1 三道题先别急着看答案自测比看一百篇教程管用。这三道题覆盖了本轮最核心的易错点你先写出答案再看解析。Question 1int a 10, b 20; int* p a; p b; *p 30;问现在a和b分别是多少答案a 是 10b 是 30。p先把纸条从 a 的门牌号改成 b 的门牌号再通过解引用把 b 写成 30。a 自始至终没被动过。Question 2int a 10, b 20; int r a; r b;问现在a和b分别是多少答案a 是 20b 还是 20。这题和上一道看着像本质完全不同。r b不是让 r 改绑到 b而是把 b 的值赋给 r 绑定的对象 a。所以 a 被覆盖为 20。如果你中了“引用也能重新绑定”的圈套说明你还是没把“引用是别名”当回事。Question 3struct Node { int data; Node* next; }; void removeFirst(Node* head) { head head-next; // 能成功吗 }问这个函数能不能真的删掉链表第一个节点答案不能。传入的head是外部头指针的拷贝函数内部改了拷贝外部的头指针纹丝不动。要想真的更新头节点需要Node* head或Node** head。这就是 5.3 讲到的“改指针本身”的场景。三道题答完如果你每一道都能把原因说清楚而不是只记得结果那说明你已经过了“背概念”的阶段。6.2 我的实操体会和练习顺序最后说点我自己的经验。我见过太多人把“指针和引用的区别”背得滚瓜烂熟一写链表插入还是崩一写容器遍历还是忘记加。原因很简单概念是能被背下来的但“间接层”的直觉只能靠调试器喂出来。我的建议是找一台电脑跟着做三个练习。第一写一个Swap函数分别用指针版和引用版实现并用调试器观察两个版本的形参地址和实参地址你会有一次“原来是同一间房”的顿悟。第二写一个单链表插入函数故意用一级指针传头节点体会外部指针改不动是什么感受然后改成指针引用或二级指针对比差异。第三用for (auto x : vec)改一遍数组里的每个值再用for (auto x : vec)试一遍看看为什么后者“改了等于没改”。这三个练习做完再看那些带星号和与符号的声明你就不再需要逐字翻译了。你会自动在脑子里画出门牌号纸条的地图谁在找地址谁在开门谁的地址对应哪间房。指针能不能用得好值不值钱最后全看这张地图清不清楚。有一句话我一直很认指针和引用本质上都是一种“间接层”只是一个人拿着可改签的门牌号纸条一个人拿着终身绑定的永久小名。会用它们的人从来不是背下了多少规则而是心里始终有那栋宿舍楼的俯视图。