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

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

一个数组,两种 sizeof

cpp 复制代码
#include <iostream>

int main() {
    std::cout << std::boolalpha;
    int arr[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';
}
text 复制代码
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; ------ 引用直接绑数组,不退化
cpp 复制代码
#include <iostream>
#include <type_traits>

int main() {
    int arr[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';
}
text 复制代码
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* 的隐式转换。

表达式 类型 退化了 含义 / 步长
arr int[5] 否(sizeof / decltype 上下文) 数组类型本身,20 字节
arr(在普通表达式里) int* 是 等价于 &arr[0]
&arr int(*)[5] 否 指向整个数组,+1 跨 20 字节
arr + 1 int* 是 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 这种看着什么都没干的写法也一样会退化。

退化前后的内存与类型图

text 复制代码
内存(栈上)
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 字节。类型决定指针步长,这是最直观的一个例子。

cpp 复制代码
#include <iostream>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};

    const int* p_first = arr;          // arr 退化成 &arr[0]
    const int* p_next  = arr + 1;      // 跨 1 个 int(4 字节)
    const int (*p_whole)[5] = &arr;    // &arr 指向整个数组
    const int (*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<const char*>(p_after) -
                  reinterpret_cast<const char*>(p_whole)) << " 字节\n";
}
text 复制代码
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 只是给人看的装饰,编译器直接忽略。

cpp 复制代码
#include <iostream>
#include <type_traits>

void f(int arr[10]) {   // 形参类型其实是 int*,长度 10 被丢弃
    static_assert(std::is_same<decltype(arr), int*>::value,
                  "int arr[10] 形参的真正类型是 int*");
    std::cout << "f 内部 sizeof(参数类型) = " << sizeof(decltype(arr)) << " 字节(指针)\n";
}

int main() {
    int arr[10] = {};
    std::cout << "main 里 sizeof(arr) = " << sizeof(arr) << '\n';
    f(arr);              // arr 退化成 int*,长度信息一起没了
}
text 复制代码
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 + 1 UB 越过末尾一步以上就 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 现算,从不手动写死数字:

cpp 复制代码
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <cstddef>
#include <cstdio>
#include <iterator>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};

    const int* begin = arr;              // 指向首元素
    const int* 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));

    const int* last = end - 1;           // 回退一步仍指向元素,合法
    std::printf("*last = %d\n", *last);
}
text 复制代码
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]) 求长度。数组一旦退化成形参,这个式子会静默算错:

cpp 复制代码
// 反例,不要这么写:形参 arr 已退化成 int*,sizeof 拿到的是指针大小
#include <cstddef>

std::size_t bad_len(int arr[]) { return sizeof(arr) / sizeof(arr[0]); }

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

text 复制代码
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:

cpp 复制代码
// 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_t good_len(const int (&arr)[N]) { return N; }

int main() {
    int arr[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));
}
text 复制代码
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)把数组包进一个「大小编码在类型里」的对象,退化不了,长度永远在:

cpp 复制代码
#include <array>
#include <iostream>

void g(const std::array<int, 5>& a) {
    std::cout << "std::array 内嵌数据,sizeof = " << sizeof(a) << '\n';
}

int main() {
    const std::array<int, 5> a{1, 2, 3, 4, 5};
    g(a);
    std::cout << "a[0] = " << a[0] << '\n';
}
text 复制代码
std::array 内嵌数据,sizeof = 20
a[0] = 1

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

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

cpp 复制代码
// verify: std=c++20
#include <iostream>
#include <span>
#include <vector>

void h(std::span<const int> s) {   // 一套接口吃遍所有连续容器
    std::cout << "span 元素个数 = " << s.size() << '\n';
}

int main() {
    int arr[4] = {10, 20, 30, 40};
    h(arr);

    const std::vector<int> v{1, 2, 3};
    h(v);
}
text 复制代码
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,可直接编译)

cpp 复制代码
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <array>
#include <iostream>

// 退化写法:只剩指针,长度必须手动带(老接口常见)
void by_pointer(int* p, std::size_t n) {
    for (std::size_t i = 0; i < n; ++i) std::cout << p[i] << ' ';
    std::cout << '\n';
}

// 现代写法:长度锁在类型里,不退化、不丢信息
void by_array(const std::array<int, 4>& a) {
    for (const int x : a) std::cout << x << ' ';
    std::cout << '\n';
}

int main() {
    int arr[4] = {1, 2, 3, 4};
    by_pointer(arr, 4);                       // arr 退化成 int*,手动补长度
    by_array(std::array<int, 4>{1, 2, 3, 4}); // 不退化,类型自带长度
}
text 复制代码
1 2 3 4
1 2 3 4

延伸阅读

收个尾

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

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

相关推荐
汉克老师1 小时前
GESP2026年9月认证C++七级( 第一部分选择题(1~7题)精讲
c++·gesp·小学生·学c++编程
程序员老陆1 小时前
C++ 悬空指针:从原理到工程级解决方案
开发语言·c++·程序设计
漂流瓶jz1 小时前
UVA-1610 聚会游戏 题解答案代码 算法竞赛入门经典第二版
数据结构·c++·算法·题解·aoapc·算法竞赛入门经典·uva
随意起个昵称1 小时前
【线性DP】P10156 [LSOT-2] 胜者组
c++·动态规划
无名猿2 小时前
map / set 完全指南:红黑树与有序容器
数据结构·c++·性能优化·stl·标准库
朝朝辞暮i2 小时前
C++ 第 28 课:Lambda 表达式
开发语言·c++·算法
All for pursuit.2 小时前
【回溯-3】39.组合总和
数据结构·c++·算法·leetcode
见叶之秋2 小时前
【C++】一条双向链的诞生:手写 list 模拟实现
c++
沫璃染墨2 小时前
《从零入门Linux系统篇(五十六):线程篇·九——生产者消费者模型:从条件变量到BlockingQueue实现》
linux·服务器·c++·驱动开发·设计模式·架构·系统架构