☰
指针算术与数组退化:数组名到底是不是指针
2026/10/4 16:35:59 网站建设 项目流程

「数组名就是指针」是 C/C++ 初学者最早听到的半句真理。说它是半句,是因为前半句在多数场合成立,后半句就会咬人:数组名在大多数表达式里会退化(decay)成指向首元素的指针,但数组类型本身和指针类型不是一回事。sizeof(arr)拿到的是整块数组的字节数,&arr的类型是「指向含 N 个 int 的数组的指针」。下面用编译器和static_assert把这件事钉死,顺便解释为什么函数参数里int arr[10]其实等价于int*。

一个数组,两种 sizeof

#include<iostream>intmain(){std::cout<<std::boolalpha;intarr[5]={1,2,3,4,5};std::cout<<"sizeof(arr) = "<<sizeof(arr)<<'\n';// 整个数组std::cout<<"sizeof(&arr[0]) = "<<sizeof(&arr[0])<<'\n';// 一个指针std::cout<<"sizeof(int) = "<<sizeof(int)<<'\n';std::cout<<"*arr = "<<*arr<<'\n';std::cout<<"arr == &arr[0] = "<<(arr==&arr[0])<<'\n';}
sizeof(arr) = 20 sizeof(&arr[0]) = 8 sizeof(int) = 4 *arr = 1 arr == &arr[0] = true

sizeof(arr)是 20(5 个int× 4 字节),而sizeof(&arr[0])是 8(64 位平台的一个指针)。如果arr本身就是int*,这两个数应该相等。它们不等,arr就不是指针。

官方文档:Array declaration — array-to-pointer conversion

核心:退化是什么,什么时候发生

退化(array-to-pointer conversion)指:在绝大多数表达式里,类型为T[N]的数组名会被隐式转换成T*,指向首元素&arr[0]。于是arr[i]等价于*(arr + i),数组下标本质上就是指针算术。

会退化的场景:

  • 在表达式里当右值用:int* p = arr;(arr→&arr[0])
  • 作为函数实参传递:f(arr);(形参拿到的是int*,长度信息丢失)
  • 数组算术:arr + 1(arr先退化成指针,再做指针加法)

不退化的例外(这也是「数组不是指针」的实证):

  • sizeof(arr)—— 拿到整个数组大小
  • &arr—— 取的是「指向整个数组」的指针,类型是T(*)[N]
  • decltype(arr)—— 推导出的类型是T(&)[N]
  • 绑定到数组引用:int (&r)[5] = arr;—— 引用直接绑数组,不退化
#include<iostream>#include<type_traits>intmain(){intarr[5]={1,2,3,4,5};static_assert(std::is_same<decltype(arr),int[5]>::value,"arr 是数组类型 int[5],不是指针");static_assert(std::is_same<decltype((arr)),int(&)[5]>::value,"作为左值表达式时 arr 是 int(&)[5](数组引用)");static_assert(std::is_same<decltype(&arr),int(*)[5]>::value,"&arr 是指向整个数组的指针 int(*)[5]");std::cout<<"sizeof(arr) = "<<sizeof(arr)<<'\n';std::cout<<"sizeof(decltype(arr)) = "<<sizeof(decltype(arr))<<'\n';std::cout<<"sizeof(&arr) = "<<sizeof(&arr)<<'\n';}
sizeof(arr) = 20 sizeof(decltype(arr)) = 20 sizeof(&arr) = 8

decltype(arr)直接得到的是int[5],也就是数组类型本身;把arr当作左值表达式括起来写成decltype((arr)),才是int(&)[5]这个数组引用。static_assert在编译期替我们钉死了这件事:数组就是数组,不是指针。

退化本质上是 C 语言留下的历史包袱。C 没有按值传递整个数组的能力,所以函数参数里的数组会悄悄变成指针,C++ 为了兼容把这一行为继承了过来。代价是长度信息在函数边界上丢了,这正是std::array/std::span要替我们补回来的东西。

把「数组名 vs 指针」的差别压成一张速查表,判断标准只有一条:这个位置有没有触发T[N]到T*的隐式转换。

表达式类型退化了含义 / 步长
arrint[5]否(sizeof/decltype上下文)数组类型本身,20 字节
arr(在普通表达式里)int*是等价于&arr[0]
&arrint(*)[5]否指向整个数组,+1跨 20 字节
arr + 1int*是arr已退化,+1跨 4 字节
sizeof(arr)std::size_t否20(整块数组)
sizeof(arr + 0)std::size_t是8(一个指针)
decltype(arr)int[5]否数组类型;写decltype((arr))才得到int(&)[5]
arr[i]int&是等价于*(arr + i),本质就是指针算术

只有四类位置保留数组类型:sizeof、&(取地址)、decltype、绑定到引用。其它位置一律退化,连arr + 0这种看着什么都没干的写法也一样会退化。

退化前后的内存与类型图

内存(栈上) int arr[5] = {1,2,3,4,5} ┌────┬────┬────┬────┬────┐ │ 1 │ 2 │ 3 │ 4 │ 5 │ 每个格子 4 字节,整块 20 字节 └────┴────┴────┴────┴────┘ ▲ │ arr 退化成 &arr[0](指向首个 int) │ 类型从 int[5] 变成 int* &arr 的类型是 int(*)[5],指向上面这「整块」 &arr + 1 ──跨过 20 字节──► (指向紧随其后的内存) arr + 1 ──跨过 4 字节──► 指向第 2 个 int 类型对照 arr : int[5] (数组,20 字节) &arr[0] : int* (指针,8 字节) &arr : int(*)[5] (指向数组的指针,8 字节)

注意&arr + 1。因为&arr的类型是「指向含 5 个 int 的数组」,指针加 1 跨的是整个数组,20 字节;而arr + 1里的arr已经退化成int*,加 1 只跨一个int,4 字节。类型决定指针步长,这是最直观的一个例子。

#include<iostream>intmain(){intarr[5]={1,2,3,4,5};constint*p_first=arr;// arr 退化成 &arr[0]constint*p_next=arr+1;// 跨 1 个 int(4 字节)constint(*p_whole)[5]=&arr;// &arr 指向整个数组constint(*p_after)[5]=&arr+1;// 跨整个数组(20 字节)std::cout<<"p_next - p_first = "<<(p_next-p_first)<<" 个 int\n";std::cout<<"p_after - p_whole = "<<(p_after-p_whole)<<" 个数组\n";std::cout<<"字节差 (p_after - p_whole) = "<<(reinterpret_cast<constchar*>(p_after)-reinterpret_cast<constchar*>(p_whole))<<" 字节\n";}
p_next - p_first = 1 个 int p_after - p_whole = 1 个数组 字节差 (p_after - p_whole) = 20 字节

函数参数里长度信息怎么丢的

这是退化最坑的地方。void f(int arr[10])在编译器眼里完全等同于void f(int* arr),方括号里那个10只是给人看的装饰,编译器直接忽略。

#include<iostream>#include<type_traits>voidf(intarr[10]){// 形参类型其实是 int*,长度 10 被丢弃static_assert(std::is_same<decltype(arr),int*>::value,"int arr[10] 形参的真正类型是 int*");std::cout<<"f 内部 sizeof(参数类型) = "<<sizeof(decltype(arr))<<" 字节(指针)\n";}intmain(){intarr[10]={};std::cout<<"main 里 sizeof(arr) = "<<sizeof(arr)<<'\n';f(arr);// arr 退化成 int*,长度信息一起没了}
main 里 sizeof(arr) = 40 f 内部 sizeof(参数类型) = 8 字节(指针)

所以数组一旦退化进函数,函数就不知道它有多长,只能额外再传一个std::size_t n。这也是缓冲区溢出的经典温床:f越界读写时没人拦得住,而调用方往往以为那个10还在起作用。

官方文档:Function parameter — array parameters are treated as pointers

对比三种「传数组」的方式:

写法形参类型长度信息评价
void f(int arr[10])int*丢失(10 是假的)反例,不要这么写
void f(int* p, std::size_t n)int*+ 长度手动带能用,但调用方易传错
void f(const std::array<int,5>&)数组引用锁在类型里固定长度首选
void f(std::span<const int>)视图自带.size()C++20,最灵活

指针算术的边界:哪一步合法,哪一步是 UB

既然arr[i]就是*(arr + i),那么下标越界和指针算术越界其实是同一件事。C++ 对指针算术的容忍度比很多人想的窄:指针只能在同一个数组的[0, N]区间内游走(同一块分配也算)。arr + N是合法的 one-past-the-end,但只能拿来比较和相减,不能解引用。再往前一步,哪怕不解引用也是未定义行为。

运算是否合法说明
arr + 0…arr + N合法含 one-past-the-end;但*(arr + N)解引用是 UB
arr + N + 1UB越过末尾一步以上就 UB,即使不解引用
p - q(同一数组 / 同一块分配)合法结果是元素个数,可正可负
p - q(指向不同数组)UB相减的两个指针必须同源
p < q(指向不同数组)结果未指定比较只在同一数组内有意义;需要全序时用std::less
nullptr + 0、nullptr - nullptr合法空指针可以做「加 0」和相减;nullptr + 1是 UB
arr[i](i >= N或i < 0)UB它等价于*(arr + i),越界就是越界

这些规则听着抽象,实操上的正确姿势其实就一个:用首指针和尾后指针这一对来表示区间。长度用end - begin现算,从不手动写死数字:

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<cstddef>#include<cstdio>#include<iterator>intmain(){intarr[5]={1,2,3,4,5};constint*begin=arr;// 指向首元素constint*end=arr+5;// 合法的 one-past-the-end,不可解引用std::printf("end - begin = %td\n",end-begin);std::printf("std::size(arr) = %zu\n",std::size(arr));std::printf("std::distance = %td\n",std::distance(begin,end));constint*last=end-1;// 回退一步仍指向元素,合法std::printf("*last = %d\n",*last);}
end - begin = 5 std::size(arr) = 5 std::distance = 5 *last = 5

end这个尾后指针不指向任何元素,不能解引用,但它合法地参与了三件事:减出长度(end - begin)、和别的指针比较、以及减 1 退回末尾元素。这套半开区间的约定正是标准库迭代器的基础。循环条件写p != end而不是p < end + 1,边界就没有含糊余地。

还有一个看着人畜无害、实则最容易算错的写法:在函数里用sizeof(arr) / sizeof(arr[0])求长度。数组一旦退化成形参,这个式子会静默算错:

// 反例,不要这么写:形参 arr 已退化成 int*,sizeof 拿到的是指针大小#include<cstddef>std::size_tbad_len(intarr[]){returnsizeof(arr)/sizeof(arr[0]);}

gcc 13 起对这种情况会直接给出警告(下面是-Wall下的真实输出,prog.cc是编译器收到的文件名):

prog.cc: In function 'std::size_t bad_len(int*)': prog.cc:6:48: warning: 'sizeof' on array function parameter 'arr' will return size of 'int*' [-Wsizeof-array-argument] 6 | std::size_t bad_len(int arr[]) { return sizeof(arr) / sizeof(arr[0]); } | ~^~~~ prog.cc:6:25: note: declared here 6 | std::size_t bad_len(int arr[]) { return sizeof(arr) / sizeof(arr[0]); } | ~~~~^~~~~

sizeof(arr)是 8(指针),sizeof(arr[0])是 4(int),算出来恒等于 2,跟数组实际多长一点关系没有。正确做法是让长度留在类型里,或者交给std::size:

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<cstddef>#include<cstdio>#include<iterator>// 数组引用不退化,长度锁在类型里template<std::size_t N>std::size_tgood_len(constint(&arr)[N]){returnN;}intmain(){intarr[5]={1,2,3,4,5};std::printf("std::size(arr) = %zu\n",std::size(arr));std::printf("good_len(arr) = %zu\n",good_len(arr));}
std::size(arr) = 5 good_len(arr) = 5

std::size(C++17)走的正是数组引用这条路,所以在数组上它对,在已经退化的指针上它编译不过,错误提前到编译期,比运行时算出一个错误的 2 好太多。同样的道理:std::array::at()会做边界检查并在越界时抛std::out_of_range,operator[]则和裸数组下标一样不做检查,性能敏感又确定不越界时才用[]。

现代替代:std::array 与 std::span

std::array(C++11)把数组包进一个「大小编码在类型里」的对象,退化不了,长度永远在:

#include<array>#include<iostream>voidg(conststd::array<int,5>&a){std::cout<<"std::array 内嵌数据,sizeof = "<<sizeof(a)<<'\n';}intmain(){conststd::array<int,5>a{1,2,3,4,5};g(a);std::cout<<"a[0] = "<<a[0]<<'\n';}
std::array 内嵌数据,sizeof = 20 a[0] = 1

std::array<int,5>的sizeof仍是 20。它没有指针开销,元素内嵌在对象里,同时还能享受.size()、迭代器、范围 for 这些标准库能力,也不会退化成裸指针。

std::span(C++20)是更进一步的视图:裸数组、std::array、std::vector它都收,而且自带长度,不丢信息:

// verify: std=c++20#include<iostream>#include<span>#include<vector>voidh(std::span<constint>s){// 一套接口吃遍所有连续容器std::cout<<"span 元素个数 = "<<s.size()<<'\n';}intmain(){intarr[4]={10,20,30,40};h(arr);conststd::vector<int>v{1,2,3};h(v);}
span 元素个数 = 4 span 元素个数 = 3

官方文档:std::array (C++11) | std::span (C++20)

Core Guidelines 怎么看

  • I.13:别把数组当单个指针传。改成std::span或数组引用。
  • F.24:用 span 表示半开区间。长度内建,边界清晰。
  • ES.26 / Per.12:能不退化就不退化,保持类型携带的信息量最大。

三条指向同一个结论:写新代码时固定长度用std::array<T, N>,要传一段连续数据用std::span<T>,裸数组T[]只在跟 C 接口或老代码打交道时被迫出现。

完整示例(C++17,可直接编译)

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<array>#include<iostream>// 退化写法:只剩指针,长度必须手动带(老接口常见)voidby_pointer(int*p,std::size_t n){for(std::size_t i=0;i<n;++i)std::cout<<p[i]<<' ';std::cout<<'\n';}// 现代写法:长度锁在类型里,不退化、不丢信息voidby_array(conststd::array<int,4>&a){for(constintx:a)std::cout<<x<<' ';std::cout<<'\n';}intmain(){intarr[4]={1,2,3,4};by_pointer(arr,4);// arr 退化成 int*,手动补长度by_array(std::array<int,4>{1,2,3,4});// 不退化,类型自带长度}
1 2 3 4 1 2 3 4

延伸阅读

  • cppreference — Array declaration / array-to-pointer conversion:退化规则的权威定义。
  • cppreference — std::array:把数组包进类型安全的容器。
  • cppreference — std::span (C++20):替代「指针 + 长度」的视图。
  • Core Guidelines — I.13 / F.24:不要退化传数组。

收个尾

数组会在表达式里退化成首元素指针,但sizeof(arr)、&arr、decltype(arr)这三个位置还看得到它真实的数组类型。函数参数里的int arr[10]等价于int*,长度就是在这儿丢的。

新代码固定长度用std::array,传一段连续数据用std::span,让长度留在类型里。退化这件事理解一次就够了,之后该做的是别再给自己制造需要理解它的场合。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询