指针保存的是地址,而地址就是数字,所以期待能对它们做算术运算是合理的。确实可以,但和普通的整数运算不完全一样。C 会把每一次指针运算都按所指对象的大小进行缩放,于是“加一”就变成了用处大得多的“移动到下一个元素”。
正是这一个设计决定,让在 C 中用指针遍历数组和用下标遍历一样自然。
加 1 移动的是一个对象,而不是一个字节
取一个指针,加上 1,然后打印两个地址:
int * 跳了 4 个字节,double * 跳了 8 个字节,char * 跳了 1 个字节,每一个都恰好落在下一个同类型对象开始的位置。规则很简单:
ptr + n == (address in ptr) + n * sizeof(*ptr)
这正是指针需要类型的原因。一个没有类型的裸地址,根本不知道“下一个”有多远。
用指针遍历数组
现在来看回报。数组的元素位于连续的内存中,所以逐步移动指针就能按顺序访问它们:
两个循环打印出同样的五个数字。在第二个循环中,scores 退化为指向第一个元素的指针,p++ 前进一个 int,*p 读取那个位置上的元素。在内存中的布局如下:
scores[0] scores[1] scores[2] scores[3] scores[4]
+---------+---------+---------+---------+---------+
| 88 | 92 | 75 | 60 | 100 |
+---------+---------+---------+---------+---------+
^ ^ ^
p p+1 scores+5
(start) (4 bytes later) (one past the end)
两种写法之间的关系是精确的,值得记住:scores[i] 的定义就是 *(scores + i)。更多内容请看“指针与数组”一节。
自增、自减和 *p++ 陷阱
++ 和 -- 用在指针上时,同样会按类型大小缩放。
让所有人都困惑的表达式是 *p++。后缀 ++ 比 * 结合得更紧,所以它的意思是“取当前的 p,让 p 前进,然后解引用旧的值”:
三个表达式,三种不同的含义:
| 表达式 | 之后的指针 | 产生的值 |
|---|---|---|
*p++ | 已前进 | 移动之前的元素 |
*++p | 已前进 | 移动之后的元素 |
(*p)++ | 不变 | 旧的值,同时该元素加一 |
*p++ 是地道的 C 写法,字符串和缓冲区的复制就是这样写的;但当你想表达其他意思时,一定要写上括号。
两个指针相减
一个指针减去另一个指针,得到的是它们之间的元素个数,而不是字节数:
结果的类型是 ptrdiff_t,这是 <stddef.h> 中的一种有符号整数类型,用 %td 打印。对指向两个不同数组的指针做减法,结果是未定义的:标准只定义了同一个对象内部的这种运算。
你也可以用指针减去一个整数(p - 3 会后退三个元素),但不能把两个指针相加。“两个地址之和”没有任何意义。
比较指针
关系运算符可以用于指向同一个数组的指针,它们的含义和内存布局所暗示的完全一致:当 p 指向更靠前的元素时,p < q 为真。
front < back 就是这个循环终止的全部条件,它之所以正确,是因为两个指针都始终位于同一个数组之内。== 和 != 也是判断循环是否到达结束标记的自然方式,这就引出了让这一切合法的那条规则。
尾后一位规则
C 明确允许你构造一个指向数组最后一个元素之后一位的指针。你可以计算它、保存它、拿它做比较,但不能解引用它。
int arr[4];
arr[0] arr[1] arr[2] arr[3] (no element)
+------+ +------+ +------+ +------+ +- - - -+
| | | | | | | | | |
+------+ +------+ +------+ +------+ +- - - -+
^ ^
arr arr + 4
legal to use legal to FORM and COMPARE
never legal to dereference
正是这条规则让标准的循环写法成立:
for (int *p = arr; p != arr + n; p++) {
/* ... */
}
在最后一次迭代中,p 变成 arr + n,比较失败,循环退出,自始至终都没有读取那个位置。
即便如此,仍有两种情况是未定义的。构造一个超过末尾两位的指针,或者指向开头之前一位的指针,即使从不解引用,也是未定义行为:
int *bad1 = arr + n + 1; // undefined - too far past
int *bad2 = arr - 1; // undefined - before the beginning
第二种情况在实际中很重要:写成 for (int *p = arr + n - 1; p >= arr; p--) 的反向循环,在最后一次自减时会计算出 arr - 1。它在所有常见编译器上都能工作,但在形式上仍然是未定义的。干净的反向循环可以避免这个问题:
void 指针,以及不能做的运算
void * 保存的是一个没有类型的地址,malloc 和 qsort 正是靠它保持通用。由于没有元素大小,标准不允许对 void * 做指针运算:要先转换为具体的类型(如果要按字节操作,就转换为 char *)。
void *v = buffer;
// v + 1; // not standard C - no size to scale by
char *b = v;
b + 1; // fine - one byte forward
GCC 和 Clang 把 void * 运算作为一种扩展来支持,把它当作 char * 处理,所以依赖它的代码可以编译,直到有一天在更严格的环境中构建。如果你希望编译器提醒你,就用 -std=c17 -pedantic 编译。
它能为你带来什么
指针运算不是偶尔才用的优化技巧,它是数组下标、字符串处理以及标准库中每一次缓冲区遍历背后的机制。strlen 就是把指针推进到结尾的 '\0',再减去起始指针。memcpy 就是两个指针同步前进。
接下来,请看“指针与数组”一节,了解数组和指针的等价关系到底是怎样运作的,包括第一次把数组传给函数时几乎人人都会掉进去的 sizeof 陷阱。
常见问题
C 语言中 ptr + 1 做了什么?
它让指针向前移动一个对象,而不是一个字节。对于典型机器上的 int *,这是 4 个字节;对于 double *,是 8 个字节。编译器会替你乘以 sizeof(*ptr),所以指针的类型很重要。
C 语言中两个指针可以相减吗?
可以,前提是两个指针都指向同一个数组。end - start 给出它们之间的元素个数,类型是 ptrdiff_t(用 %td 打印)。对指向不相关对象的指针做减法是未定义行为。
让指针指向数组末尾之后的一个位置合法吗?
合法。C 明确允许构造一个指向最后一个元素之后那个位置的指针,这样 for (int *p = arr; p != arr + n; p++) 这样的循环才能工作。你可以计算和比较这个指针,但解引用它是未定义行为。
为什么 *p++ 和 (*p)++ 不一样?
*p++ 会让指针自增,并解引用旧的值:它读取当前元素,然后前进。(*p)++ 不动指针,而是让它指向的值加一。后缀 ++ 比 * 结合得更紧,所以是括号改变了含义。