资讯动态

指针算术与数组退化:数组名到底是不是指针

发布时间:2026/10/4 16:36:09 来源:尧图企业网站定制
「数组名就是指针」是 C/C 初学者最早听到的半句真理。说它是半句是因为前半句在多数场合成立后半句就会咬人数组名在大多数表达式里会退化decay成指向首元素的指针但数组类型本身和指针类型不是一回事。sizeof(arr)拿到的是整块数组的字节数arr的类型是「指向含 N 个 int 的数组的指针」。下面用编译器和static_assert把这件事钉死顺便解释为什么函数参数里int arr[10]其实等价于int*。一个数组两种 sizeof#includeiostreamintmain(){std::coutstd::boolalpha;intarr[5]{1,2,3,4,5};std::coutsizeof(arr) sizeof(arr)\n;// 整个数组std::coutsizeof(arr[0]) sizeof(arr[0])\n;// 一个指针std::coutsizeof(int) sizeof(int)\n;std::cout*arr *arr\n;std::coutarr arr[0] (arrarr[0])\n;}sizeof(arr) 20 sizeof(arr[0]) 8 sizeof(int) 4 *arr 1 arr arr[0] truesizeof(arr)是 205 个int× 4 字节而sizeof(arr[0])是 864 位平台的一个指针。如果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 1arr先退化成指针再做指针加法不退化的例外这也是「数组不是指针」的实证sizeof(arr)—— 拿到整个数组大小arr—— 取的是「指向整个数组」的指针类型是T(*)[N]decltype(arr)—— 推导出的类型是T()[N]绑定到数组引用int (r)[5] arr;—— 引用直接绑数组不退化#includeiostream#includetype_traitsintmain(){intarr[5]{1,2,3,4,5};static_assert(std::is_samedecltype(arr),int[5]::value,arr 是数组类型 int[5]不是指针);static_assert(std::is_samedecltype((arr)),int()[5]::value,作为左值表达式时 arr 是 int()[5]数组引用);static_assert(std::is_samedecltype(arr),int(*)[5]::value,arr 是指向整个数组的指针 int(*)[5]);std::coutsizeof(arr) sizeof(arr)\n;std::coutsizeof(decltype(arr)) sizeof(decltype(arr))\n;std::coutsizeof(arr) sizeof(arr)\n;}sizeof(arr) 20 sizeof(decltype(arr)) 20 sizeof(arr) 8decltype(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 只跨一个int4 字节。类型决定指针步长这是最直观的一个例子。#includeiostreamintmain(){intarr[5]{1,2,3,4,5};constint*p_firstarr;// arr 退化成 arr[0]constint*p_nextarr1;// 跨 1 个 int4 字节constint(*p_whole)[5]arr;// arr 指向整个数组constint(*p_after)[5]arr1;// 跨整个数组20 字节std::coutp_next - p_first (p_next-p_first) 个 int\n;std::coutp_after - p_whole (p_after-p_whole) 个数组\n;std::cout字节差 (p_after - p_whole) (reinterpret_castconstchar*(p_after)-reinterpret_castconstchar*(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只是给人看的装饰编译器直接忽略。#includeiostream#includetype_traitsvoidf(intarr[10]){// 形参类型其实是 int*长度 10 被丢弃static_assert(std::is_samedecltype(arr),int*::value,int arr[10] 形参的真正类型是 int*);std::coutf 内部 sizeof(参数类型) sizeof(decltype(arr)) 字节指针\n;}intmain(){intarr[10]{};std::coutmain 里 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::arrayint,5)数组引用锁在类型里固定长度首选void f(std::spanconst int)视图自带.size()C20最灵活指针算术的边界哪一步合法哪一步是 UB既然arr[i]就是*(arr i)那么下标越界和指针算术越界其实是同一件事。C 对指针算术的容忍度比很多人想的窄指针只能在同一个数组的[0, N]区间内游走同一块分配也算。arr N是合法的 one-past-the-end但只能拿来比较和相减不能解引用。再往前一步哪怕不解引用也是未定义行为。运算是否合法说明arr 0…arr N合法含 one-past-the-end但*(arr N)解引用是 UBarr N 1UB越过末尾一步以上就 UB即使不解引用p - q同一数组 / 同一块分配合法结果是元素个数可正可负p - q指向不同数组UB相减的两个指针必须同源p q指向不同数组结果未指定比较只在同一数组内有意义需要全序时用std::lessnullptr 0、nullptr - nullptr合法空指针可以做「加 0」和相减nullptr 1是 UBarr[i]i N或i 0UB它等价于*(arr i)越界就是越界这些规则听着抽象实操上的正确姿势其实就一个用首指针和尾后指针这一对来表示区间。长度用end - begin现算从不手动写死数字// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includecstddef#includecstdio#includeiteratorintmain(){intarr[5]{1,2,3,4,5};constint*beginarr;// 指向首元素constint*endarr5;// 合法的 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*lastend-1;// 回退一步仍指向元素合法std::printf(*last %d\n,*last);}end - begin 5 std::size(arr) 5 std::distance 5 *last 5end这个尾后指针不指向任何元素不能解引用但它合法地参与了三件事减出长度end - begin、和别的指针比较、以及减 1 退回末尾元素。这套半开区间的约定正是标准库迭代器的基础。循环条件写p ! end而不是p end 1边界就没有含糊余地。还有一个看着人畜无害、实则最容易算错的写法在函数里用sizeof(arr) / sizeof(arr[0])求长度。数组一旦退化成形参这个式子会静默算错// 反例不要这么写形参 arr 已退化成 int*sizeof 拿到的是指针大小#includecstddefstd::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])是 4int算出来恒等于 2跟数组实际多长一点关系没有。正确做法是让长度留在类型里或者交给std::size// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includecstddef#includecstdio#includeiterator// 数组引用不退化长度锁在类型里templatestd::size_t Nstd::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) 5std::sizeC17走的正是数组引用这条路所以在数组上它对在已经退化的指针上它编译不过错误提前到编译期比运行时算出一个错误的 2 好太多。同样的道理std::array::at()会做边界检查并在越界时抛std::out_of_rangeoperator[]则和裸数组下标一样不做检查性能敏感又确定不越界时才用[]。现代替代std::array 与 std::spanstd::arrayC11把数组包进一个「大小编码在类型里」的对象退化不了长度永远在#includearray#includeiostreamvoidg(conststd::arrayint,5a){std::coutstd::array 内嵌数据sizeof sizeof(a)\n;}intmain(){conststd::arrayint,5a{1,2,3,4,5};g(a);std::couta[0] a[0]\n;}std::array 内嵌数据sizeof 20 a[0] 1std::arrayint,5的sizeof仍是 20。它没有指针开销元素内嵌在对象里同时还能享受.size()、迭代器、范围 for 这些标准库能力也不会退化成裸指针。std::spanC20是更进一步的视图裸数组、std::array、std::vector它都收而且自带长度不丢信息// verify: stdc20#includeiostream#includespan#includevectorvoidh(std::spanconstints){// 一套接口吃遍所有连续容器std::coutspan 元素个数 s.size()\n;}intmain(){intarr[4]{10,20,30,40};h(arr);conststd::vectorintv{1,2,3};h(v);}span 元素个数 4 span 元素个数 3官方文档std::array (C11) std::span (C20)Core Guidelines 怎么看I.13别把数组当单个指针传。改成std::span或数组引用。F.24用 span 表示半开区间。长度内建边界清晰。ES.26 / Per.12能不退化就不退化保持类型携带的信息量最大。三条指向同一个结论写新代码时固定长度用std::arrayT, N要传一段连续数据用std::spanT裸数组T[]只在跟 C 接口或老代码打交道时被迫出现。完整示例C17可直接编译// demo.cpp — 编译: g -stdc17 -Wall -O2 demo.cpp -o demo#includearray#includeiostream// 退化写法只剩指针长度必须手动带老接口常见voidby_pointer(int*p,std::size_t n){for(std::size_t i0;in;i)std::coutp[i] ;std::cout\n;}// 现代写法长度锁在类型里不退化、不丢信息voidby_array(conststd::arrayint,4a){for(constintx:a)std::coutx ;std::cout\n;}intmain(){intarr[4]{1,2,3,4};by_pointer(arr,4);// arr 退化成 int*手动补长度by_array(std::arrayint,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 (C20)替代「指针 长度」的视图。Core Guidelines — I.13 / F.24不要退化传数组。收个尾数组会在表达式里退化成首元素指针但sizeof(arr)、arr、decltype(arr)这三个位置还看得到它真实的数组类型。函数参数里的int arr[10]等价于int*长度就是在这儿丢的。新代码固定长度用std::array传一段连续数据用std::span让长度留在类型里。退化这件事理解一次就够了之后该做的是别再给自己制造需要理解它的场合。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑