☰
详解C++数组和数组名问题(指针、解引用)
2026/10/21 2:48:13 网站建设 项目流程

前言


数组是 C++ 里最基础也最容易「似懂非懂」的类型。初学者常听到一句话——「数组名就是指向首元素的指针」,于是所有推导都建立在这个前提上。但这个说法只在「大多数表达式」里成立:数组名本身是一个数组类型的对象,只是在参与运算时会发生数组到指针的转换(array-to-pointer conversion),也就是常说的「退化」(decay)。


一旦把这层区分忘掉,就会出现几类典型困惑:为什么sizeof(arr)在主函数里是整个数组的大小,传进函数后却变成一个指针的大小?为什么&arr和arr打印出来地址相同,&arr + 1却跳过整个数组?为什么二维数组不能直接赋给int**?为什么*(arr + i)和arr[i]是一回事?


本文从类型系统出发,讲清数组名、首元素地址、指向整个数组的指针三者的区别,再说明多维数组的退化层次、函数传参时长度信息的丢失,最后给出保留长度的现代写法。


一、数组的类型与退化的例外


声明int a[5];时,a的类型是int[5],是一个包含 5 个int的数组对象。它在内存中连续存放 5 个int,sizeof(a)等于5 * sizeof(int),这是编译期就能确定的常量。


在大多数表达式里,a会被转换成int*,指向第一个元素。这个转换是隐式的、自动发生的,例如赋值给指针、传给参数类型为int*的函数、参与算术运算。转换之后,关于「数组长度」的信息就丢失了,指针只剩一个地址。


但标准明确列出了不发生退化的上下文:



  • 作为sizeof的操作数时,得到整个数组的字节数;

  • 作为一元&的操作数时,得到「指向整个数组的指针」,类型是int (*)[5];

  • 作为decltype的操作数时,得到数组类型本身;

  • 用于初始化「数组的引用」时,引用绑定到数组对象本身;

  • 字符串字面量用于初始化字符数组时,逐字符拷贝而不是变成指针。


此外,C++17 起auto的推导规则也值得注意:auto b = a;中a退化,b的类型是int*;而auto& c = a;中引用直接绑定数组,c的类型是int (&)[5],长度信息保留。




表达式类型(以int a[5]为例)含义



a在多数表达式中转换为int*指向首元素

sizeof(a)std::size_t,值为 5 个int的字节数不退化

&aint (*)[5]指向整个数组

&a[0]int*指向首元素

*aint&首元素本身

a + 1int*指向第二个元素

&a + 1int (*)[5]跳过整个数组后的位置

decltype(a)int[5]数组类型,不退化



&a与a(转换成int*后)的地址值相同,因为数组对象和它的首元素是「指针可互转」(pointer-interconvertible)的;但类型不同,指针算术的步长也就不同。a + 1前进sizeof(int)字节,&a + 1前进sizeof(int[5])字节。把&a + 1当成「首元素地址加一」是经典错误。


二、下标、解引用与指针算术的关系


标准对下标运算的定义就是解引用加指针算术:a[i]等价于*(a + i)。因此下面几种写法在语义上完全一致:



  • a[i]

  • *(a + i)

  • *(i + a)

  • i[a]


最后一种虽然合法,但可读性差,不该在正式代码里使用。理解这个等价关系后,很多「奇怪」的写法就能看懂了:*a是*(a + 0),即a[0];*(a + 1)是a[1];&a[2]是a + 2。


指针算术的单位是「所指元素的类型」。对int*加 1 前进一个int,对int (*)[5]加 1 前进一个「含 5 个 int 的数组」。标准只允许在同一个数组对象(或末尾后一位)内做指针算术:指向数组首元素之前的指针、超过末尾后一位的指针,其形成本身就是 UB,更不用说解引用。末尾后一位(one-past-the-end)指针可以形成、可以比较,但不能解引用。


还有一个容易忽略的细节:a在退化后是「指向首元素的指针」,它是右值;而&a[0]同样是指向首元素的指针。二者值相同、类型相同,可以互相比较。真正不同的是&a——它指向的是整个数组对象,不是首元素。


三、多维数组的退化是一层一层发生的


以int m[2][3];为例,它的类型是「含 2 个int[3]的数组」。理解多维数组的关键是把最外层之外的部分看成一个整体类型:



  • m的类型是int[2][3],退化后是int (*)[3],指向第一行(一行是 3 个 int);

  • m[0]的类型是int[3],退化后是int*,指向第一行第一个元素;

  • m[0][0]的类型是int;

  • &m的类型是int (*)[2][3],指向整个二维数组。


因此m + 1跳过一整行(3 个 int),m[0] + 1跳过一行内的一个元素。标准同样把m[i][j]定义为*(*(m + i) + j):先对m + i解引用得到第 i 行这个数组,它再退化成int*,加 j 后解引用得到元素。


这也解释了为什么二维数组不能赋给int**。int**的含义是「指向一个int*的指针」,它期待内存里真的存放着指针数组;而二维数组的内存是一整块连续的int,没有任何指针。int** p = m;无法通过编译(类型不匹配);即使强转成功,用p[i][j]访问也会把整数当成指针解引用,是 UB。


正确的做法是让指针指向一行:int (*row)[3] = m;,之后row[i][j]正常工作。需要传递二维数组时,除最外层外的维度必须写进参数类型里,因为编译器要靠它计算步长。


四、函数传参:长度信息为什么会丢失


当数组作为函数参数时,参数类型中的数组形式会被「调整」(adjust)为指针:void f(int a[5])、void f(int a[])、void f(int* a)三者声明的是同一个函数,其中的5被完全忽略。于是函数内部sizeof(a)得到的是指针大小,不是数组大小;函数也无法知道自己收到多长。


这是 C 语言遗留的设计,C++ 为了兼容保留了下来,但提供了几种更好的写法:



  • 模板引用:template <std::size_t N> void f(const int (&a)[N]),N 由实参推导,长度信息保留,且只能接受真正的数组,不能接受指针;

  • std::array<T, N>:按值或按引用传递,长度是类型的一部分;

  • std::span<T>(需要 C++20 及以上):运行期的「指针 + 长度」视图,可以统一接收数组、std::vector、std::array;

  • 显式传长度或传std::vector。


模板引用还有一个好处:传nullptr或int*会直接编译失败,而不是在运行时越界。但它也会为每个不同的 N 生成一份实例化,代码体积会随长度种类增加,通常可以接受。


完整示例:观察数组名在各处的类型


下面的例子用模板引用保留长度,打印数组;主函数里对比a、&a[0]、&a的地址值与步长,并演示二维数组的正确传递方式。


// 适用于 C++17
#include <cstddef>
#include <iostream>

template <std::size_t N>
void print_array(const int (&a)[N]) {
for (std::size_t i = 0; i < N; ++i) {
std::cout << a[i] << (i + 1 == N ? '\n' : ' ');
}
}

template <std::size_t R, std::size_t C>
void print_matrix(const int (&m)[R][C]) {
for (std::size_t i = 0; i < R; ++i) {
for (std::size_t j = 0; j < C; ++j) {
std::cout << m[i][j] << (j + 1 == C ? '\n' : ' ');
}
}
}

void takes_pointer(int a[5]) {
// 参数中的 5 会被忽略,a 的实际类型是 int*
std::cout << sizeof(a) << '\n';
}

int main() {
int a[5] = {10, 20, 30, 40, 50};

print_array(a);

std::cout << sizeof(a) << ' ' << sizeof(a[0]) << ' '
<< sizeof(a) / sizeof(a[0]) << '\n';

int* p = a; // 数组退化为指向首元素的指针
std::cout << (p == &a[0]) << '\n';
std::cout << *p << ' ' << *(p + 2) << ' ' << p[3] << '\n';

int (*whole)[5] = &a; // 指向整个数组,类型为 int (*)[5]
std::cout << (reinterpret_cast<const void*>(whole)
== reinterpret_cast<const void*>(a)) << '\n';
std::cout << (whole + 1) - whole << '\n'; // 步长以整个数组为单位

int m[2][3] = {{1, 2, 3}, {4, 5, 6}};
print_matrix(m);

int (*row)[3] = m; // 正确:指向一行,而不是 int**
std::cout << row[1][2] << '\n';

takes_pointer(a);
}

注意int (*whole)[5] = &a;的括号不能省:int* whole[5]是「含 5 个 int 指针的数组」,与「指向含 5 个 int 的数组的指针」完全不同。row[1][2]按标准语义先取第二行再取第三个元素,结果为 6。


常见坑点



  1. ❌ 「数组名就是指针」,于是用sizeof(a)判断指针大小;✅ 记住数组名是数组类型,只在多数表达式中退化,sizeof、&、decltype处不退化。

  2. ❌void f(int a[5])里用sizeof(a) / sizeof(a[0])求长度;✅ 参数已退化为int*,应改传长度、传std::vector,或用模板引用const int (&)[N]。

  3. ❌ 以为&a + 1是「第二个元素」;✅&a是int (*)[5],加 1 跳过整个数组,a + 1才指向第二个元素。

  4. ❌int m[2][3]; int** p = m;;✅int (*p)[3] = m;,或改用std::array<std::array<int,3>,2>。

  5. ❌ 函数返回局部数组的地址:int* f() { int a[3]; return a; };✅ 返回std::array/std::vector,或由调用方传入缓冲区。

  6. ❌char* s = "hello";并试图修改;✅ C++11 起字符串字面量类型是const char[N],应写const char* s = "hello";,要可修改就用char s[] = "hello";。

  7. ❌ 对末尾后一位指针解引用,或让指针移动到数组范围之外;✅ 末尾后一位只能比较,不能解引用,算术必须留在同一数组内。

  8. ❌ 混淆*p++与(*p)++;✅ 前者是*(p++)(先取当前值再移动指针),后者是对所指元素自增。


总结




写法类型(int a[5])关键点



a退化后int*只在多数表达式中退化

sizeof(a)整个数组字节数与sizeof(int*)不同

&aint (*)[5]步长是整个数组

a + 1int*步长是一个元素

a[i]int&等价于*(a + i)

m(int m[2][3])int (*)[3]指向一行,不是int**

m[i]退化后int*行内首元素地址



数组名的「指针错觉」来自退化规则的普遍性,而真正需要记住的是那些不退化的例外。写代码时,能用std::array、std::vector或模板引用保留长度,就不要把裸数组和长度分开传递;必须用指针时,把「指向元素」和「指向整个数组」的类型区分清楚,才能避免步长和边界上的错误。




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

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

立即咨询