在 C 语言中,数组和指针是两种不同的东西,但大多数时候表现得一样。正是这个“大多数时候”让这个话题变得棘手:把数组当成指针用的代码通常能正常运行,直到某天它碰上区别真正起作用的那两个地方,结果就悄悄出错了。
本页把这条界线划清楚。
数组退化:一切背后的那条规则
在几乎所有表达式中,数组名都会自动转换为指向其首元素的指针。这叫作退化(decay)。arr 变成 &arr[0],类型是 int *。
数组名和首元素的地址打印出来完全一样。这就是退化在起作用,也是你不用 & 就能把数组赋给指针的原因。
只有三个地方不会发生退化:作为 sizeof 的操作数、作为 & 的操作数,以及用字符串字面量初始化字符数组时。其他地方一律按退化处理。
arr[i] 就是 *(arr + i)
C 标准用指针运算来定义下标运算符:a[b] 的意思是 *(a + b)。下标不是一个独立的功能,只是一种简写。
由此得出两个结论。第一,指针也可以用下标:p[2] 就是 *(p + 2),所以接收指针的函数仍然可以使用熟悉的 arr[i] 写法。第二,2[arr] 能编译:*(2 + arr) 和 *(arr + 2) 是同一个地址。除了出题之外没人这么写,但它回答了下标是不是“真的”就是指针运算这个问题。
把数组传给函数
由于退化,函数永远收不到数组,它收到的是一个指针。
这个例子有三点值得单独说明。
int arr[100] 里的长度只是说明,仅此而已。 编译器会把参数改写成 int *arr,从不检查你是否真的传入了 100 个元素。
长度必须单独传递。 函数只拿到一个地址,并不知道数据延伸到哪里。这就是为什么几乎所有接收数组的 C 函数都要带一个数量:memcpy(dst, src, n)、fread(ptr, size, count, f)、qsort(base, nmemb, size, cmp)。字符串之所以是例外,只是因为它自带结束标记 '\0'。
传指针开销很小,数据是共享的。 数组不会被复制,所以函数可以修改调用者的元素,这往往正是你想要的:
像 print_all 那样把只读参数标记为 const int *。这能表明意图,也能让编译器发现意外的写入。
sizeof 陷阱
这个 bug 每个 C 程序员都会踩一次。sizeof 是不发生退化的两个地方之一,所以在声明数组的地方它给出数组的真实大小,而在数组已经退化的任何地方,它给出的是一个指针的大小。
在 main 中,sizeof data 是 40 字节,算出的长度是 10。在 inspect 中,sizeof arr 是一个指针的大小,在 64 位机器上是 8,于是“长度”算出来是 2。代码看起来一模一样,却悄悄出错了。
由此得出的规则:只在声明数组的作用域内计算数组长度,然后从那里把它传下去。
对数组取 & 得到的类型不同
另一个不发生退化的地方是 & 运算符。&arr 是指向整个数组的指针,而不是指向首元素的指针。两者保存的地址相同,区别在于“加 1”是什么意思。
起始地址相同,步长不同。arr 的类型是 int *,每次前进一个元素;&arr 的类型是 int (*)[5],每次前进整个数组。正是这个机制让传递二维数组的一行成为可能,详见多维数组。
数组指针与指针数组
两个看起来很像、意思却相反的声明:
int *p[5]; // array of 5 pointers to int
int (*q)[5]; // pointer to an array of 5 ints
[] 的优先级高于 *,所以 int *p[5] 解析为“p 是一个数组,元素是指针”。int (*q)[5] 中的括号强制 * 先结合:“q 是一个指针,指向一个数组”。
int *p[5] int (*q)[5]
p[0] -> [ int ] q -> [ int | int | int | int | int ]
p[1] -> [ int ] one contiguous block of 5
p[2] -> [ int ]
p[3] -> [ int ]
p[4] -> [ int ]
five separate addresses
指针数组更常见,而且你已经用过它了:char *argv[] 就是一个字符串指针数组,每个命令行参数对应一个。它也是保存一组长度不同的字符串而不浪费空间的方法。
注意 (*q)[0]:先解引用指针得到数组,再对它取下标。同样出于优先级的原因,这里的括号也必不可少。
数组和指针真正不同的地方
记住下面这张小表,困惑就会消失:
数组 int arr[10] | 指针 int *p | |
|---|---|---|
| 它是什么 | 内存中排列的 10 个 int | 保存一个地址的单个变量 |
sizeof | 40,整块内存 | 8,只是指针本身 |
| 能否赋值 | 不能:arr = x; 会报错 | 能:p = x; 没问题 |
& 得到 | int (*)[10] | int ** |
| 内存从哪里来 | 随数组一起声明 | 你让它指向哪里就是哪里 |
数组名并不是保存地址的变量,它就是那块存储空间本身,地址是在需要时才计算出来的。这就是你不能给它赋值的原因。
常见问题
C 语言中数组和指针是一回事吗?
不是,但两者很容易混淆,因为数组名在几乎所有表达式中都会退化为指向首元素的指针。数组本身是一块大小已知的元素;指针是保存一个地址的单个变量。sizeof 和 & 是能看出区别的两个地方。
为什么在函数里 sizeof(arr) 得到的结果不对?
因为这个参数不是数组。void f(int arr[]) 会被悄悄改写成 void f(int *arr),所以 sizeof(arr) 量的是一个指针(大多数机器上是 8 字节),而不是原来的数组。请把长度作为单独的参数传进去。
int *p[5] 和 int (*p)[5] 有什么区别?
int *p[5] 是一个包含 5 个 int 指针的数组。int (*p)[5] 是一个指向“5 个 int 组成的数组”的指针。括号让 * 先和名字结合;没有括号时 [] 优先,因为它的优先级更高。
arr[i] 真的和 *(arr + i) 一样吗?
一样:C 标准把 a[b] 定义为 *(a + b)。这也是看起来很奇怪的 i[arr] 能编译并正常工作的原因:加法满足交换律,所以 *(i + arr) 是同一个元素。真实代码中千万别这么写,但它证明了这条规则。