「数组名就是指针」是 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-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
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
延伸阅读
- 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,让长度留在类型里。退化这件事理解一次就够了,之后该做的是别再给自己制造需要理解它的场合。