C++ 数组下标语法

C++ 数组下标语法

C++ 的数组下标运算看起来简单,但背后其实牵涉到 数组、指针、指针算术、表达式求值、运算符重载。你前面看到的 2[1[a]],正是这些规则叠加产生的结果。

1. 最基本的数组下标语法

最常见的是:

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

a[0]   // 10
a[1]   // 20
a[4]   // 50

语法形式:

表达式1[表达式2]

对于内置数组和指针,C++ 中它的核心含义可以理解为:

a[i]

等价于:

*(a + i)

所以:

a[2]

相当于:

*(a + 2)

假设:

int a[] = {10, 20, 30};

内存可以概念上理解成:

a
↓
+------+------+------+
|  10  |  20  |  30  |
+------+------+------+
   0      1      2

a + 2 指向第三个元素,然后:

*(a + 2)

取出这个位置的值,也就是:

30

2. 为什么 1[a] 居然合法?

这是最有意思的地方。

因为:

a[i]

等价于:

*(a + i)

而指针加整数时:

a + i

和:

i + a

都是合法的。

所以:

*(a + i)

也可以写成:

*(i + a)

因此:

a[i]

和:

i[a]

对于内置数组/指针来说效果相同。

例如:

#include <iostream>

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

    std::cout << a[1] << '\n';
    std::cout << 1[a] << '\n';
}

输出:

20
20

也就是说:

a[1] == 1[a]

这不是编译器的特殊彩蛋,而是下标运算本身的定义导致的。

3. 2[1[a]] 到底发生了什么?

假设:

int a[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

正常写法:

a[1][2]

得到:

7

现在看:

2[1[a]]

先算:

1[a]

因为:

1[a] == a[1]

所以:

2[1[a]]

变成:

2[a[1]]

再次利用:

x[y] == y[x]

得到:

a[1][2]

所以:

2[1[a]]

等价于:

a[1][2]

最终结果:

7

完整展开:

2[1[a]]

== 2[a[1]]

== a[1][2]

== *(*(a + 1) + 2)

这最后一个形式很重要:

*(*(a + 1) + 2)

它展示了二维数组下标背后的真实逻辑。

4. 一维数组的下标实际上是指针运算

看下面:

int a[] = {10, 20, 30, 40};

int x = a[2];

可以理解成:

int x = *(a + 2);

这里:

a

在很多表达式中会发生所谓的 array-to-pointer conversion(数组到指针转换)。

也就是:

a

通常会转换为:

&a[0]

类型大致从:

int[4]

变成:

int*

因此:

a + 2

表示:

&a[2]

于是:

*(a + 2)

就是:

a[2]

5. 指针也可以直接使用 []

因为下标运算本身就是建立在指针运算之上的,所以指针也能用:

int a[] = {10, 20, 30};

int* p = a;

std::cout << p[0] << '\n';
std::cout << p[1] << '\n';
std::cout << p[2] << '\n';

输出:

10
20
30

并且:

p[2]

就是:

*(p + 2)

所以:

p[2]
a[2]
*(p + 2)
*(a + 2)

在这里都是同一个元素。

6. 数组下标不一定从“数组变量”开始

例如:

int a[] = {10, 20, 30, 40};

int* p = &a[1];

那么:

p[0]   // 20
p[1]   // 30
p[2]   // 40

因为:

p

本身指向:

a[1]

因此:

p[1]

其实是:

*(p + 1)

也就是:

a[2]

这说明:

[] 的本质并不是“访问某个数组变量”,而是“基于一个指针进行偏移后解引用”。

7. 为什么 a + 1 不是地址加 1 字节?

这是指针算术特别重要的规则。

例如:

int a[3];

假设:

sizeof(int) == 4

如果:

a

地址是:

1000

那么:

a + 1

不是:

1001

而是:

1004

因为指针算术会自动按元素大小移动。

所以:

a + i

实际地址大致是:

起始地址 + i * sizeof(int)

因此:

a[i]

可以方便地访问第 i 个元素。

8. 二维数组为什么可以写 a[i][j]?

例如:

int a[3][4];

它不是一个特殊的“二维数组对象”。

从类型角度,它是:

一个长度为 3 的数组
每个元素又是一个长度为 4 的 int 数组

也就是:

int [3][4]

可以想象成:

a
│
├── a[0] -> int[4]
├── a[1] -> int[4]
└── a[2] -> int[4]

所以:

a[1][2]

是分两步:

第一步:

a[1]

得到第二行:

int[4]

第二步:

a[1][2]

取第二行中的第三个元素。

展开:

a[1]

就是:

*(a + 1)

因此:

a[1][2]

就是:

(*(a + 1))[2]

继续展开:

*(*(a + 1) + 2)

因此:

a[i][j]

本质上就是:

*(*(a + i) + j)

10. 下标可以不是常量

当然不需要写死:

a[0]
a[1]
a[2]

可以写:

int i = 2;

std::cout << a[i];

也可以:

a[i + 1]

甚至:

a[f()]

只要表达式最终产生一个合适的整数索引即可。

例如:

int i = 1;
int j = 2;

a[i + j]

等价于:

a[3]

11. 下标可以是负数吗?

语法上可以。

比如:

p[-1]

等价于:

*(p - 1)

这有时候是合法的。

例如:

int a[] = {10, 20, 30};

int* p = &a[1];

std::cout << p[-1];

这里:

p

指向:

a[1]

于是:

p[-1]

就是:

a[0]

输出:

10

所以负下标本身并不是非法语法。

关键问题不是:

下标是不是负数?

而是:

运算后得到的地址是否仍然属于允许访问的数组范围?

12. 越界是未定义行为

这是 C++ 中非常重要的一点。

int a[3] = {10, 20, 30};

std::cout << a[100];

C++ 通常不会自动检查越界。

这属于:

Undefined Behavior

也就是未定义行为。

可能发生:

输出垃圾值
程序崩溃
看起来正常
修改其他内存
优化器做出意想不到的优化

都可能。

同样:

a[-1]

通常也是越界,因此也是未定义行为。

13. [] 本身不会做边界检查

比如:

std::vector<int> v = {10, 20, 30};

v[100]

std::vector::operator[] 也通常不进行边界检查。

如果希望检查:

v.at(100)

则会抛出:

std::out_of_range

所以:

v[i]

优点是速度直接,缺点是无边界检查。

而:

v.at(i)

会进行检查。

14. 内置数组的 [] 和 std::vector 的 [] 不完全是一回事

这里有一个非常重要的区别。

对于:

int a[10];

这里:

a[i]

使用的是语言内置的下标运算。

本质上:

*(a + i)

但是:

std::vector<int> v;

这里:

v[i]

实际上调用的是成员函数:

v.operator[](i)

概念上类似:

v.operator[](i)

因此对于类类型来说,[] 可以被重载。

15. 为什么 1[a] 对数组成立,但 1[v] 对 vector 不成立?

非常关键。

数组:

int a[3];

可以:

1[a]

因为使用的是内置规则:

*(1 + a)

但:

std::vector<int> v = {1, 2, 3};

你不能写:

1[v]

因为 std::vector 的下标访问依赖:

v.operator[](1)

operator[] 是 vector 的成员函数。

1 显然没有:

operator[](v)

因此:

v[1]   // OK
1[v]   // 错

所以以前说:

x[y] == y[x]

必须加限定:

对内置数组/指针下标运算来说成立,不是所有 C++ 类型都成立。

17. 为什么 a[i] 可以放在赋值左边?

例如:

int a[3];

a[1] = 100;

为什么可以?

因为:

a[1]

相当于:

*(a + 1)

解引用得到的是那个真实元素本身,而不仅仅是它的一个临时副本。

所以:

a[1] = 100;

修改的是数组里的元素。

同样:

++a[1];

也是可以的。

以及:

int& r = a[1];

也可以。

18. &a[i] 和 a + i

因为:

a[i] == *(a + i)

所以取地址:

&a[i]

基本就是:

a + i

例如:

int a[] = {1, 2, 3};

int* p1 = &a[2];
int* p2 = a + 2;

那么:

p1 == p2

为真。

这也是一个非常实用的关系:

&a[i] == a + i

19. a、&a[0] 和 &a 不是完全相同的东西

这个地方是初学 C++ 时非常容易混淆的。

假设:

int a[5];

通常表达式中:

a

会转换成:

int*

即:

&a[0]

但是:

&a

类型是:

int (*)[5]

也就是:

指向“整个长度为 5 的数组”的指针。

所以:

a

和:

&a

数值地址往往看起来相同,但类型和指针算术完全不同。

例如:

a + 1

跳过一个 int。

而:

&a + 1

跳过整个:

int[5]

也就是 5 个 int。

21. 数组名不是“永远等于指针”

这是另一个常见误解。

很多教程会说:

数组名就是指针。

严格来说不对。

应该说:

数组表达式在很多情况下会自动转换为指向首元素的指针。

例如:

int a[10];

a 的真实类型是:

int[10]

并不是:

int*

证明之一:

sizeof(a)

得到整个数组大小:

10 * sizeof(int)

如果 a 真的是 int*,那么:

sizeof(a)

就只会得到指针大小。

比如 64 位平台通常是 8。

22. 哪些情况下数组不会退化成指针?

几个典型情况:

sizeof(a)

不会退化。

还有:

&a

也不会。

另外模板和引用中,也能保留真正的数组类型:

template <std::size_t N>
void f(int (&a)[N]) {
}

这里可以直接知道数组长度。

23. 字符串字面量也可以下标

例如:

"hello"[1]

结果是:

'e'

因为字符串字面量本质上是字符数组:

const char[6]

内容是:

h e l l o \0

所以:

"hello"[0]   // 'h'
"hello"[1]   // 'e'

甚至按照前面的内置下标规则:

1["hello"]

也是:

'e'

虽然正常代码绝对不建议这么写。

25. 指针加法不是普通整数加法

下面不合法:

int* p;
int* q;

p + q;   // 错

两个指针不能直接相加。

但:

p + 3
3 + p

都合法。

所以:

p[3]
3[p]

也都合法。

这就是对称性的来源。

26. 两个指针可以相减

例如:

int a[10];

int* p = &a[2];
int* q = &a[7];

auto d = q - p;

那么:

d == 5

它表示两个指针之间相差多少个元素,而不是多少字节。

这和数组下标的设计是统一的。

27. 数组下标的类型通常是整数

常见:

int i;
unsigned i;
long i;
std::size_t i;

都可以用作下标。

实际标准库中经常使用:

std::size_t

例如:

for (std::size_t i = 0; i < v.size(); ++i) {
    std::cout << v[i];
}

原因是容器大小通常使用:

std::size_t

表示。

28. 但要小心无符号整数

例如:

std::size_t i = 0;

--i;

它不会变成:

-1

而是发生无符号回绕,变成一个非常大的数字。

因此:

a[i]

很可能严重越界。

这也是 C++ 下标相关 bug 的常见来源之一。

29. [] 的优先级很高

例如:

*p[i]

解析成:

*(p[i])

而不是:

(*p)[i]

因为 [] 的优先级比一元 * 高。

如果你真的想:

(*p)[i]

必须写括号。

类似地:

a[i].x

先做:

a[i]

再访问:

.x

30. 多维数组的内存是连续的

例如:

int a[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

通常可以理解为连续排列:

1 2 3 4 5 6

但类型结构仍然是:

a
├── int[3]
└── int[3]

因此:

a + 1

不是跳过一个 int,而是跳过一整行:

int[3]

所以:

a + 1

指向第二行。

这也解释了:

a[1]

为什么可以直接得到第二行。

31. int** 和二维数组不是同一种东西

这个问题和下标语法关系很大。

例如:

int a[3][4];

a 在表达式中退化为:

int (*)[4]

即:

指向 int[4] 的指针

而不是:

int**

所以:

int** p = a;

是不合法的。

因为:

int (*)[4]

和:

int**

完全是不同的类型。

虽然它们都可以写:

x[i][j]

但内部结构不一样。

32. p[i][j] 为什么对 int** 也能工作?

假设:

int** p;

那么:

p[i][j]

仍然按两个步骤进行:

p[i]

得到:

int*

然后:

p[i][j]

得到:

int

也就是:

*(*(p + i) + j)

语法一样,但底层内存结构不一定像真正二维数组那样连续。

33. C++23 对多参数 operator[] 有扩展

现代 C++ 里,用户定义类型的 operator[] 能力比传统写法更灵活。

传统上通常是:

obj[i][j]

通过两次 operator[] 实现。

较新的 C++ 允许类定义带多个参数的下标运算符,例如概念上:

matrix[i, j]

对于自定义类型可以有特殊意义。

不过这属于现代 C++ 的运算符重载特性,和内置数组:

a[i][j]

的机制不是一回事。

posted @ 2026-09-29 18:00  光風霽月  阅读(4)  评论(0)    收藏  举报