Menu

C 语言指针运算:指针加减和遍历内存

给指针加 1 并不是加 1 个字节,而是移动到下一个同类型的对象。本页介绍 ptr+1、指针自增和自减、指针相减、指针比较、用指针遍历数组,以及“尾后一位”规则。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

指针保存的是地址,而地址就是数字,所以期待能对它们做算术运算是合理的。确实可以,但和普通的整数运算不完全一样。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)++ 不动指针,而是让它指向的值加一。后缀 ++ 比 * 结合得更紧,所以是括号改变了含义。

Coddy 编程语言插图

和 Coddy 一起学编程

开始