数组名不是指针:一个流传最广的 C 语言误解 先看一段代码。不运行先猜输出#includestdio.hintmain(void){inta[5]{10,25,-3,42,7};int*pa;printf(%zu\n,sizeof(a));/* ? */printf(%zu\n,sizeof(p));/* ? */return0;}如果数组名就是指针这句话成立a和p同为int *两行输出应当相同。但在 64 位机器上实际输出是 20 和 8。这不是编译器的怪癖。数组名确实不是指针它只是在大多数表达式里表现得像指针。这个表现得像养出了 C 语言里流传最广的误解也埋下了不少真实的线上 bug。这篇文章把这件事一次讲清楚。一、三个铁证铁证一sizeof 的答案不同。20 是 5 个int的总大小8 是一个指针的大小32 位机器上是 4。如果a真是指针它自己只占 8 字节sizeof不可能知道它背后还挂着 20 字节的数组。铁证二数组名不能自增。inta[5];int*pa;p;/* OKp 是变量可以改 */a;/* 编译错误 */gcc 的报错很直白error: lvalue required as increment operand。标准层面说得更直白C11 §6.3.2.1 在定义可修改左值时明确排除了数组类型。自增和整体赋值要求的都是一个能被写入的位置而数组名给不出这个位置。这也解释了a b;为什么非法C 里能整体赋值的只有结构体和联合体数组不行它的内存是整体分配的连续空间却没给整体写入留语义。铁证三a与a数值相同类型不同。inta[5];int*pa1a;/* a 退化为 int* */int(*pa2)[5]a;/* a 是 int(*)[5] */printf(%td\n,(char*)(pa11)-(char*)pa1);/* 4 */printf(%td\n,(char*)(pa21)-(char*)pa2);/* 20 */a与a打出来数值相同但上面这组步长实验已经把它们彻底分开步长差了 5 倍pa1 1跳过一个intpa2 1跳过整个数组。类型决定步长——数组名是指针这句话混淆的正是值和类型这两件事。还有个更细的问题为什么a与a数值必然相等因为数组对象的首地址按定义就是它第一个字节的地址而首元素恰好从那里开始。相等是内存布局的必然不是巧合。相等的是数值不相等的是类型与步长。动图同样写1int *一步步走int(*)[5]一步跳过整个数组。二、数组名的两重身份C 标准C11 §6.3.2.1把规则写得很清楚除作为sizeof、_Alignof和取地址的操作数以及用于初始化数组的字符串字面量之外“数组类型的表达式会被转换为指向首元素的指针”。翻译一下数组名有两重身份。身份一在大多数表达式中数组名会退化decay为指向首元素的指针值。注意是值不是变量退化产物是个右值没有存储空间所以不能被赋值、不能自增。身份二在sizeof、、字符串字面量初始化这三种常用场合数组名保持数组类型的本来面目。sizeof能给出整个数组的大小、a的类型是int(*)[5]都靠这一条。这里还藏着一个细节sizeof是编译期运算符不是函数。编译器在编译期就掌握着int a[5]的完整类型信息直接把常数 20 写进指令。数组名在sizeof里不退化的底气正来自这份类型信息。另一个条款§6.5.3.4还规定sizeof的操作数不求值sizeof(i)不会真的执行i。唯一的例外是 C99 引入的变长数组VLAC11 起改为可选特性int vla[n]的长度只有运行时才知道它的sizeof只能推迟到运行时求值。两种身份的差异可以压成一张表int a[5]int *p a自身存储就是那 20 字节元素本身额外占 8 字节存地址sizeof20整个数组8指针能否、被赋值不能能x 1的步长退化后按int4 字节按int4 字节x的类型int(*)[5]步长 20int **步长 8“自身存储一行是本质其余都是它的推论指针变量是一个实实在在的对象有自己的地址、自己的 8 字节数组名只是一个名字它的存储就是数组元素本身。说数组名就是指针”类似于说门牌号就是那栋房子——门牌号只是个记号房子才是实体。图a、a、a1落在内存布局上的真实含义。地址相同类型与步长不同。顺带把下标访问也统一掉无论x是数组名还是指针x[i]都严格等价于*(x i)。两者的差别不在x[i]怎么算而在x的步长类型从哪来数组名来自元素类型指针来自声明类型。数组与指针经常穿着相似的衣服都能下标访问但衣服底下是两种东西。这也是为什么上面铁证三里pa1与pa2能用同样的语法做加减步长却完全不同语法相同类型不同。三、误解从哪来函数参数误解如此流行不是没有原因的。把数组传给函数你会看到数组名就是指针的行为证据voidfunc(inta[])/* 实际按 void func(int *a) 处理 */{printf(%zu\n,sizeof(a));/* 8不是 20 */}intmain(void){inta[5];func(a);return0;}这不是数组名进了函数就变成指针而是 C 标准的另一条规定C11 §6.7.6.3参数声明中的数组类型一律被调整为指向元素的指针。int a[]写在参数位置从进入函数那一刻起就是int *a跟数组名的本性无关。这条规则是误解最大的温床在函数里数组名的行为确实和指针完全一致。但它只说明传参退化不能反推数组名是指针。由此带来一个经典的隐蔽 bug在函数里求数组长度。#defineARRAY_LEN(a)(sizeof(a)/sizeof((a)[0]))voidfunc(inta[]){for(size_ti0;iARRAY_LEN(a);i)/* 循环 2 次不是 5 次 */process(a[i]);}64 位下sizeof(a) / sizeof(a[0])等于 8 / 4 2编译器一声不吭循环次数悄悄减半。可靠的做法只有一个把长度作为参数显式传入即void func(int *a, size_t n)。有人试图修补这个宏既然传进来的是指针那就改成sizeof(p) / sizeof(p[0])64 位下 8 / 4 还是 2。只要手里握着的是指针这个套路就给不出长度。这个坑严重到 Linux 内核直接在宏里设了编译期防线。内核的ARRAY_SIZE长这样#defineARRAY_SIZE(arr)\(sizeof(arr)/sizeof((arr)[0])__must_be_array(arr))#define__must_be_array(a)\BUILD_BUG_ON_ZERO(__same_type((a),(a)[0]))关键在__same_type((a), (a)[0])若a是真数组int[5]它与(a)[0]类型int *类型不同加数为 0一切照常一旦有人把指针传进来两个表达式类型相同BUILD_BUG_ON_ZERO就会构造出一个负位域宽度编译当场失败。误用撑不到线上编译期就爆掉。这道防线依赖 GNU 扩展typeof的一个特性对数组表达式求类型时不退化typeof(a)得到的就是int[5]本身。C23 已把typeof收编为标准关键字判断手里到底是数组还是指针从此有了标准的写法。这个等价关系本身也有历史来源。Ritchie 在《The Development of the C Language》里记载C 的前身 B 语言为了实现简单数组在表达式中一律按地址处理C 保留了这个等价但补上了sizeof与两个例外让数组保住了实体的身份。教科书把退化简化成数组名就是首地址丢掉的就是这两个例外——而工程上的坑几乎全藏在这两个例外里。顺着这段历史还能回答一个常见追问为什么结构体可以整体赋值数组不行结构体的整体赋值语义为逐成员拷贝从 C89 起就是语言特性而数组的演进走的是表达式中按地址处理这条路整体赋值的语义从未补上。这是历史设计不是技术必然。理解了这一层就不会再对数组不能赋值、必须 memcpy感到别扭也更不会写出a b;还纳闷编译器为什么报错。写在最后把这篇的结论压成五条可以当检查表用只要a在当前位置仍是数组类型而不是指针sizeof(a)/sizeof(a[0])就能求出长度数组一旦传参退化为指针这个套路立刻失效长度必须单独传。要防呆照抄内核ARRAY_SIZE的__must_be_array写法让误传指针在编译期报错。数组名不是可修改的左值不要写a、a b。需要整体赋值语义用结构体或memcpy。a与a数值相同、类型不同。不要为了统一类型把a强转回int *步长信息一强转就丢。记住三种常用的不退化场合sizeof、取地址、字符串字面量初始化标准里还有个_Alignof工程中极少见。另外char a[] hi得到的是可修改的数组副本char *s hi得到的是指向字面量的指针。这两行代码天差地别值得单独一篇。二维数组退化只降第一维int a[3][4]传参后是int (*a)[4]列数决定步长不能写成int **。下一篇我们顺着int (*pa)[5]这个类型往下走为什么有人看得懂int *p[10]却看不懂int (*p)[10]我会给出一套拆解任意复杂声明的方法连signal的原型也能三步读完。同系列文章会陆续发布关注后即可收到更新。