第 4 章 · 指针与一维数组

「数组名在函数里 sizeof 怎么变成 8 了?」是我大一收到的求助里频率最高的问题,没有之一。答案藏在本章主角「数组退化(array decay)」里:C 的数组名在大多数场合会悄悄变成指针,数组本身的长度信息只活在编译器脑子里,运行时根本不存在。这一章讲清楚退化的规则、仅有的两个例外、以及工程上的防丢长度三板斧。原文小节编号错成 3.x,一并修正。

4.1 退化:数组名的默认形态

int a[5] = {1, 2, 3, 4, 5};
int *p = a;          // a 在这里退化成 &a[0]
p[2];                // 就是 a[2]

在几乎所有「值语境」下,数组名隐式转换成指向首元素的指针。这就是第 3 章能写 a + 1、*(a + i) 的原因:那些运算根本不是作用在「数组」上,而是作用在退化出来的指针上。数组名和首元素指针,值相同,类型都是 int*。

4.2 仅有的两个例外:sizeof 和 &

退化规则有三处例外中的两处在这里(第三处字符串字面量初始化数组,第 6 章讲):

int a[5];
sizeof(a);        // 20:整个数组的大小,数组身份还在
sizeof(&a[0]);    // 8:退化成指针了
&a;               // 类型 int (*)[5],指向整个数组,不是 int*!
&a[0];            // 类型 int*,指向首元素

&a 和 &a[0] 数值相同(都指着同一块地的门口),类型不同(&a 是「指向含 5 个 int 的数组的指针」)。类型不同意味着步长不同:&a + 1 一步跨过整个数组(20 字节),&a[0] + 1 只前进 4 字节。这个「指向整个数组的指针」看着别扭,却是第 5 章多维数组的敲门砖,这里先记住存在:

int (*pa)[5] = &a;
(*pa)[2];         // 先解引用得到整个数组,再下标,= a[2]

4.3 传参即退化:长度信息的死亡现场

数组一旦作为函数参数,必然退化,写法上三种声明完全等价:

void f(int a[5]);   // 编译器眼中的 int *a,方括号里的 5 被无视
void f(int a[]);    // 同上
void f(int *a);     // 三兄弟本是一个人

于是那个经典 bug 有了完整解释:

void print_size(int a[]) {
    printf("%zu\n", sizeof(a) / sizeof(a[0]));   // 8/4 = 2,不是 5!
}

函数收到的只是指针,sizeof(a) 是指针大小,长度信息在门口就丢了。这也是为什么 C 的数组参数约定俗成带一个 n:

int sum(const int *a, int n) {          // 长度手动同行
    int s = 0;
    for (int i = 0; i < n; i++) s += a[i];
    return s;
}

sizeof(a)/sizeof(a[0]) 这个「数组元素个数」惯用法,只在数组定义所在的作用域成立,进了函数就是错的。想编译期报错,可以加一行卫士(利用「指针不能对指针取模」的规则):static_assert(sizeof(a) != sizeof(int*), "数组已退化"),这在 review 里能救很多新人。

4.4 想带上长度?两条正路

路线一:指向数组的指针。利用 4.2 的 int (*)[5],把长度写进类型:

void fill(int (*a)[5]) {         // 长度 5 是类型的一部分,传错编译不过
    for (int i = 0; i < 5; i++) (*a)[i] = i;
}
int arr[5];
fill(&arr);                      // 必须传 &arr

代价是死长度(长度变化就得改类型签名),适合「固定尺寸缓冲区」场景(比如像素行、页帧)。

路线二:VLA 参数。C99 起允许参数声明引用前面的参数:

void fill(int n, int a[n]) {     // n 在前,a 顺势声明为 int[n]
    for (int i = 0; i < n; i++) a[i] = i;
}

它仍然退化(运行时还是指针),但签名表达了意图,读者一眼看懂 a 长度是 n。工程里更常见的第三选择是直接传结构体(数组 + 长度打包),或者用 C++ 的 std::span 这类「指针 + 长度」的官方封装,那是后话。

常见错误

  • 在函数里对参数做 sizeof。4.3 的头号 bug,永远记住「参数即指针」。
  • *char p = "abc" 和 char p[] = "abc" 混为一谈。前者是指针指向只读字面量(改它会段错误),后者是把字面量复制进栈上数组(随便改)。第 6 章专门展开。
  • 以为 int a[5] 参数里的 5 有编译器帮助。编译器连警告都不给(除非开 -Wsizeof-array-argument 这类冷门警告),长度永远自己管。

练习

  1. 动手:写程序验证 4.2 的表:打印 sizeof(a)、sizeof(&a[0])、(void*)&a 与 (void*)&a[0],以及 (char*)&a + 1 - (char*)&a 与 (char*)&a[0] + 1 - (char*)&a 两个差值,亲眼看「同地址不同类型不同步长」。
  2. 手算:void f(int a[10]) 内 sizeof(a) 是多少?想让它编译期等于 40,有几种办法(至少两条),各自代价是什么?
  3. 动手:实现 reverse(int *a, int n) 原地翻转数组,分别用下标版和纯指针版(只用 p、q 两个指针)各写一遍,验证等价。
  4. 思考:Java 数组自带 length、有边界检查;C 数组裸奔但零开销。结合第 2 章(内存布局)和计组第 7 章(Cache),说说「数组不带元数据」给 C 换来了什么具体的好处,以及你愿意为安全付出这 8 字节/数组的代价吗?