指针加减按「元素个数」缩放、不是按字节——这是 C 指针独有的能力,也是 bug 之源。
int a[5] = {10, 20, 30, 40, 50};
int *p = &a[0]; // 或 int *p = a;
p + 1; // 不是地址 +1,而是地址 + sizeof(int) = +4 字节
p + 2; // +8 字节
p - 1; // -4 字节(前提:结果仍在同一数组/对象内)
p + n 的语义:指向「从 p 开始往后数第 n 个元素」的那个位置。编译器自动乘上 sizeof(*p):
p + n ≡ (int*)((char*)p + n * sizeof(*p))
所以 char* 加 1 前进 1 字节,double* 加 1 前进 8 字节——缩放因子就是指向类型的大小。
p[i] 就是 *(p+i)C 里下标运算符本质是指针运算的语法糖,二者完全等价:
p[i] ≡ *(p + i) ≡ *(i + p) ≡ i[p] // 连 i[p] 都合法!
正因如此,数组下标从 0 开始是必然的:a[0] 就是 *(a + 0),即首元素本身,没有额外偏移。
int a[5] = {10, 20, 30, 40, 50};
int *p = a;
p[2] = 99; // 等价 *(p+2) = 99,也即 a[2] = 99
两个指向同一数组的指针相减,结果是它们之间相隔的元素个数(类型 ptrdiff_t,有符号):
int a[5] = {10, 20, 30, 40, 50};
int *p = &a[1], *q = &a[4];
printf("%td\n", q - p); // 3
注意:指针相减只在同一数组(或同一对象)内才有定义。不同数组的指针相减是未定义行为,别拿它算「两块内存差多远」。
<、>、<=、>= 同样只对同一数组内的指针有定义。==/!= 则任意两个指针都能比(判断是否指向同一位置)。
int a[5];
int *p = &a[0], *q = &a[3];
if (p < q) { /* 合法,p 在 q 前面 */ }
遍历数组的两种等价写法:
for (int *p = a; p < a + 5; p++) { // 指针推进
printf("%d ", *p);
}
// 完全等价于
for (int i = 0; i < 5; i++) {
printf("%d ", a[i]);
}
指针运算最容易踩的坑:算出一个越界指针本身(哪怕不解引用)在大多数情况下就是未定义行为。
int a[5];
int *p = a + 5; // 允许:指向「末尾之后一个」的位置(one-past-the-end)
int *q = a + 6; // UB:越过 one-past-the-end
int *r = a - 1; // UB:越过首元素之前
a + 5),可用来作比较/循环终点,但不能解引用。void* 与算术标准 C 中 void* 不能做算术(编译器不知道 +1 该前进几字节)。GCC/Clang 作为扩展允许 void* 按 1 字节前进,但可移植代码别这么写:
void *p = malloc(8);
// p + 1; // 标准 C 报错
char *c = p; // 先转成 char*,按字节走
c + 1; // 前进 1 字节,合法
p + i 就是 lea rax, [p + i*sizeof]——地址计算是一条指令,这就是为什么 C 数组访问极快、接近裸机。p+1;数组越界抛 ArrayIndexOutOfBoundsException,是运行时检查,不是 UB。安全,但有每次访问的边界检查开销。std::vector 迭代器也支持 it + n、it1 - it2,且带越界诊断(调试模式)。a[i:j]、list[i] 表面像下标,背后是解释器对对象引用数组的封装,没有「指针算术」这种裸内存概念。步长 = 指向类型大小;下标是语法糖;越界(除 one-past-the-end)即 UB(第 14 章展开)。