指针算术:C++里最优雅的“一步之遥”
先抛一个观点:指针之所以让人又爱又恨,根本原因不在于它“指向”什么,而在于它“走”多远。
很多语言都有引用、有对象句柄、有迭代器,但只有C和C++的指针把“地址算术”这件事摆到了台面上。你写 p + 1,编译器不会傻乎乎地把地址值加1,它会根据 p 指向的类型自动缩放步长。这个设计看起来不起眼,却直接决定了数组遍历、内存布局理解、甚至性能优化的底层逻辑。
今天我们就从这个“缩放步长”切入,把指针与数组的关系彻底捋清楚。
数组名到底是什么?
先看一行代码:
int scores[5] = {80, 90, 75, 88, 92};
scores 是什么?初学者会说“这是一个数组”。没错,但在C++的表达式中,数组名会退化(decay)为首元素的地址。也就是说,scores 在大多数语境下等价于 &scores[0]。
这意味着你可以直接写:
int *ptr = scores; // ptr 指向第一个元素
不需要加 &。因为 scores 本身就是一个地址值。
但这里有个微妙之处——数组名不是指针变量。它是一个常量地址,你不能对它做自增:
scores++; // 编译错误:数组名不可修改
而指针变量可以:
int *ptr = scores;
ptr++; // 合法,ptr 现在指向第二个元素
这个区别看似细小,却是理解后续所有指针算术的基础。
指针加1,到底加了几个字节?
这是指针算术最核心的问题。
假设 int 占4个字节,ptr 指向地址 0x1000。执行 ptr++ 后,ptr 的值不是 0x1001,而是 0x1004。
为什么?因为编译器知道 ptr 是 int* 类型,它会把“1”翻译成“1个 int 的大小”。这就是指针算术的自动缩放。
用生活比喻来说:数组是一排连续的房间,每个房间占4平方米。你手里的门牌号是 0x1000。当你“走到下一个房间”时,你跨过的是4平方米的距离,而不是1平方米。指针算术帮你自动计算了这个跨度——你只需要说“下一个”,不需要说“加4”。
这也解释了为什么不同类型的指针不能随意混用。char* 加1只跳1个字节,double* 加1跳8个字节。类型决定了步长。
一道题看清指针算术的本质
来看这道题:
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;
p += 2;
// *p 的值是多少?
答案不是10,不是20,是 30。
逐步拆解:
p = arr→p指向arr[0],地址假设为0x1000p += 2→ 编译器翻译为p = p + 2 * sizeof(int)→p = 0x1000 + 8 = 0x10080x1008正是arr[2]的地址*p取出arr[2]的值 → 30
这里的关键认知是:p += 2 修改的是指针本身,而不是它指向的值。移动完成后,*p 才去解引用取值。很多初学者会把“移动”和“取值”混为一谈,写出 *p + 2 这种表达式——那是把 arr[0] 的值10加上2得到12,跟地址移动完全是两码事。
地址运算的两种“面孔”
再看一道题,这道题暴露了一个更隐蔽的坑:
int a[4] = {1, 2, 3, 4};
// 下列哪个表达式与 &a[2] 相等?
// A. a + 2 B. *a + 2 C. a[2] D. &a + 2
正确答案是 A。
逐个分析为什么:
- A.
a + 2→a退化为&a[0],加2跳过两个int,正好是&a[2]。✅ - B.
*a + 2→*a是a[0]的值,即1。1 + 2 = 3,这是个整数,不是地址。❌ - C.
a[2]→ 这是元素的值3,不是地址。❌ - D.
&a + 2→ 这里&a的类型是int(*)[4],即“指向长度为4的int数组的指针”。加2意味着跳过两个完整的数组,偏移量是2 × 4 × sizeof(int) = 32字节,远远超过a[2]的位置。❌
D选项是这道题真正的陷阱。它揭示了一个事实:a 和 &a 的值相同,但类型不同,运算步长也完全不同。
a退化为int*,步长 =sizeof(int)= 4字节&a是int(*)[4],步长 =sizeof(int[4])= 16字节
同样是加2,前者偏移8字节,后者偏移32字节。值相同,类型不同,行为天差地别。这就是C++类型系统的威力——它让地址运算有了“语义”。
指针遍历数组:为什么 ptr++ 比 i++ 更本质
回到遍历数组的场景:
int *ptr = scores;
for (int i = 0; i < 5; i++) {
cout << *ptr << endl;
ptr++;
}
这段代码用 ptr++ 遍历,和用 scores[i] 遍历,最终效果一样。但理解层面不同:
scores[i]是下标抽象——你告诉编译器“我要第i个元素”,编译器帮你算地址ptr++是地址抽象——你直接操作地址,每次跨过一个元素
后者更接近机器的真实行为。在底层,scores[i] 本质上就是 *(scores + i)。下标运算符 [] 不过是指针算术的语法糖。
理解了这一点,你就能明白为什么C风格字符串可以用指针遍历,为什么函数传数组实际上传的是首地址,为什么动态内存分配返回的是指针——它们全都建立在同一套地址算术模型上。
越界:指针算术的暗面
指针算术给了你极大的自由,也给了你极大的犯错空间。
int scores[3] = {10, 20, 30};
int *ptr = scores;
for (int i = 0; i <= 3; i++) { // i<=3 越界!
cout << *ptr << endl;
ptr++;
}
当 i=3 时,ptr 已经指向了数组末尾之后的内存。那块内存可能属于其他变量,可能是未映射的页,可能恰好能读——但无论如何,这是未定义行为。C++标准不对越界访问做任何保证,编译器也不会帮你检查。
指针算术不会做边界检查,这是它快的原因,也是它危险的原因。你享受了性能,就要承担正确性的责任。
进阶方向
指针与数组的配合是C++内存模型的入口。掌握它之后,你可以自然地过渡到:
- 指针与字符串:C风格字符串就是字符数组,
char*遍历每个字符 - 指针作为函数参数:传数组名就是传首地址,函数内修改会影响原数组
- 动态内存:
new返回指针,delete释放指针指向的内存 - 指针与结构体:用
->运算符通过指针访问成员,构建链表、树等数据结构
指针算术不是孤立的语法点,它是理解C++内存布局的一把钥匙。你越早习惯用“地址+步长”的思维看代码,就越能写出高效、可控的程序。
关于作者
我是赵老师,持有 NOI 信息学奥赛教练证书,拥有 15 年以上的软件开发经验,从事信息学少儿编程教学已有 8 年时间。
这些年累计帮助 多名 学生通过编程特长升入自己心仪的目标学校。
如果你在编程学习上有任何疑问,欢迎联系我:18620372957(微信同号)