数组与双重指针的“亲密关系”:从零讲透C/C++中的经典难题
数组与双重指针的“亲密关系”:从零讲透C/C++中的经典难题
你有没有遇到过这样的代码:
char *a[] = {"abc", "def", "hij"};
char **p = a;
然后一脸懵:数组怎么可以直接赋给双重指针?它们是同一个东西吗?能互相转换吗?
这道题不仅出现在面试中,也常出现在大学期末考试里。今天,我们就用最通俗的语言,彻底讲清楚:
-
数组和双重指针到底是什么关系?
-
它们能不能互相转换?
-
如何用两种方式从一个字符串数组中取出某个字符?
我们还会用一个经典题目作为例子,带你一步步理解背后的原理。
0. 引子:一道“神秘”的题目
假设我们有这样一个函数:
void func() {
char *a[] = {"abc", "def", "hij"}; // 字符串数组
char **p = a; // 双重指针指向它
printf("%c\n", /* 请填空:如何打印出 'i' ? */);
}
目标是:打印出第三个字符串 "hij" 的第二个字符 'i'。
这个问题的关键就在于:你是否真正理解 a 和 p 的关系。
我们先不急着填空,而是从头开始,搞清楚“数组”和“双重指针”到底是什么。
1. 基础回顾:什么是数组?什么是指针?
1.1 一维数组 vs 指针
我们先从简单的开始。
int arr[3] = {1, 2, 3};
int *p = arr;
-
arr是一个数组,包含3个整数。 -
p是一个指针,指向arr的第一个元素。
在大多数情况下,arr 会“退化”成一个指向首元素的指针(即 &arr[0])。所以 p = arr 是合法的。
✅ 结论:数组名在表达式中通常等价于指向第一个元素的指针。
2. 进阶:字符串数组与双重指针
现在回到我们的题目:
char *a[] = {"abc", "def", "hij"};
我们来拆解这句话的含义:
| 表达式 | 含义 |
|---|---|
a |
整个数组的名字 |
a[0] |
第一个字符串 "abc" 的地址(即指向 'a' 的指针) |
a[1] |
第二个字符串 "def" 的地址 |
a[2] |
第三个字符串 "hij" 的地址 |
所以,a 是一个数组,它的每个元素都是 char* 类型(即字符串指针)。
换句话说:a 的类型是 char* [3] —— 一个包含3个 char* 的数组。
2.1 那么 char **p = a; 合法吗?
我们定义了一个双重指针:
char **p = a;
p 的类型是 char**,也就是“指向 char* 的指针”。
而 a 是一个 char* 数组。当我们把 a 赋值给 p 时,发生了什么?
👉 数组名 a 自动退化成指向第一个元素的指针,也就是 &a[0]。
-
a[0]的类型是char* -
所以
&a[0]的类型是char**
完美匹配!所以 p = a 是完全合法的。
✅ 结论:数组名在赋值给指针时会退化成指向首元素的指针,因此
char** p = a;是正确的。
3. 数组和双重指针能互相转换吗?
答案是:在某些上下文中可以,但它们本质不同
| 对比项 | 数组(char* a[]) |
双重指针(char** p) |
|---|---|---|
| 本质 | 一段连续的内存空间,存储多个指针 | 一个变量,存储一个地址(该地址指向另一个指针) |
| 大小 | 固定(编译时确定) | 不固定,可指向任意位置 |
| 能否重新赋值 | ❌ 数组名不能赋值 | ✅ 指针可以重新指向 |
| 内存布局 | 连续存储的指针列表 | 只是一个指针变量 |
📌 关键区别:
-
数组是“数据结构”,指针是“变量”。
-
数组名不是变量,不能被赋值(如
a = p;是错的)。 -
指针是变量,可以改变指向。
✅ 所以:数组可以“退化”为指针使用,但指针不能变回数组。
4. 回到题目:如何打印出 'i'?
我们有两种方法:
4.1 方法一:用数组方式(直观易懂)
printf("%c", a[2][1]);
-
a[2]→ 第三个字符串"hij"的起始地址 -
a[2][1]→ 从"hij"开始,偏移1个字符 →'i'
✅ 这是最自然、最清晰的写法。
4.2 方法二:用双重指针方式(体现指针运算)
printf("%c", *(p[2] + 1));
我们来一步步拆解:
-
p[2]等价于*(p + 2)-
p指向a[0] -
p + 2指向a[2] -
*(p + 2)就是a[2],即字符串"hij"的地址
-
-
p[2] + 1→ 在"hij"的地址上 +1,指向'i' -
*(p[2] + 1)→ 解引用,得到字符'i'
✅ 所以 *(p[2] + 1) 就是 'i'
4.3 其他等价写法(供拓展)
你还可以写成:
printf("%c", *(*(p + 2) + 1)); // 完全用指针运算
printf("%c", (p[2])[1]); // p[2] 是字符串,再取 [1]
这些都和 a[2][1] 是等价的。
5. 字符串存储在哪里?只读数据段(.rodata)
首先,我们要明确一个关键点:字符串字面量(如 "abc")存储在程序的只读数据段(.rodata)中。
这意味着:
-
字符串内容不能被修改(尝试修改会引发段错误)
-
所有相同的字符串字面量可能被合并(称为“字符串池”)
-
它们在整个程序生命周期中都存在
char *a[] = {"abc", "def", "hij"};
这行代码做了两件事:
-
在只读数据段创建三个字符串:
-
"abc"存在某个地址,比如0x1000 -
"def"存在另一个地址,比如0x1004 -
"hij"存在另一个地址,比如0x1008
-
-
在栈上创建一个数组
a,存放三个指针:-
a[0] = 0x1000(指向"abc") -
a[1] = 0x1004(指向"def") -
a[2] = 0x1008(指向"hij")
-
所以,内存布局大致如下:
只读数据段 (.rodata) 栈 (stack) ┌──────────────┐ ┌──────────────┐ │ "abc" │ │ a[0] ────┐ │ │ 地址: 0x1000 │ │ a[1] ────┼─┐ │ │ "def" │ │ a[2] ────┼─┼─┤ │ 地址: 0x1004 │ └──────────────┘ │ "hij" │ ↑ │ 地址: 0x1008 │ a, &a[0] └──────────────┘
6. 深入解析:a、&a[0] 与 &a 的区别
这是最容易混淆的地方,我们来逐个分析。
6.1 a —— 数组名,退化为指针
-
值:
&a[0](第一个元素的地址) -
类型:
char**(指向char*的指针) -
用途:用于访问数组元素,如
a[1]
6.2 &a[0] —— 明确取第一个元素的地址
-
值:
&a[0](与a相同) -
类型:
char** -
用途:与
a等价,但语义更明确
✅ 所以:a == &a[0],在大多数情况下可以互换。
6.3 &a —— 指向整个数组的指针
-
值:仍然是
&a[0](地址相同!) -
类型:
char* (*)[3](指向包含3个char*的数组的指针) -
用途:高级场景,如多维数组传参
📌 关键区别在于类型不同,导致指针运算不同:
a + 1; // 指向 a[1],偏移 8 字节(sizeof(char*)) &a[0] + 1; // 同上 &a + 1; // 指向整个数组之后,偏移 24 字节(3 * 8)
7. 形象比喻:图书馆里的书架 vs 快递柜
我们可以用两个比喻来理解:
7.1 图书馆里的书架
-
把
a看作一个书架,上面有3本书:"abc"、"def"、"hij" -
每本书是一个字符串,放在图书馆的某个角落(堆或只读段)
-
书架上的每个格子存的是书的索引卡(即指针),告诉你书在哪里
-
p就是一个管理员,他拿着第一张索引卡(a[0]),可以顺着找到所有书
当你用 a[2][1],就像直接走到第3个格子,翻开那本书的第2页。
当你用 *(p[2] + 1),就像管理员先走到第3张索引卡,找到书,再翻到第2页。
7.2 快递柜比喻
-
a和&a[0]:指向第一个柜子的指针-
你可以从这里开始,一个一个柜子找下去
-
-
&a:指向整个快递站的指针-
它代表的是“整排柜子”这个整体
-
如果你做
&a + 1,就跳到了“下一个快递站”,而不是下一个柜子
-
8. 常见误区与陷阱
8.1 误区1:认为 a 和 p 完全一样
-
sizeof(a)返回整个数组的大小(如 3 * 8 = 24 字节) -
sizeof(p)返回指针的大小(通常是 8 字节) -
所以
a和p不是同一个东西!
8.2 误区2:认为 char** p = a; 是“类型不匹配”
-
虽然
a是数组,但在赋值时退化为指针 -
&a[0]的类型正好是char** -
所以是合法的
8.3 误区3:试图把双重指针当数组传参
void bad_func(char** p) {
// 不能保证 p 指向的是一个数组
// p[0], p[1] 可能越界
}
⚠️ 指针不携带长度信息,使用时要格外小心。
9. 总结:一张表搞懂关系
| 特性 | 数组 char* a[] |
双重指针 char** p |
|---|---|---|
| 类型 | char* [N] |
char** |
| 是否可赋值 | ❌ 数组名不可赋值 | ✅ 可重新指向 |
sizeof |
数组总大小 | 指针大小(8字节) |
| 能否退化为指针 | ✅ 是 | ❌ 本身就是指针 |
能否访问 a[2][1] |
✅ 可以 | ✅ 通过 p[2][1] 或 *(p[2]+1) |
| 安全性 | 高(长度固定) | 低(需手动管理) |
10. 最终答案
回到原题:
void func() {
char *a[] = {"abc", "def", "hij"};
char **p = a;
printf("%c\n", a[2][1]); // 方法一:数组方式
printf("%c\n", *(p[2] + 1)); // 方法二:双重指针方式
}
两种方式都能正确输出 'i'。
11. 写给读者
理解数组和指针的关系,是掌握 C/C++ 内存模型的第一步。不要死记硬背,而是要从内存布局和类型系统的角度去思考。
记住一句话:
数组名不是指针,但在大多数表达式中,它表现得像一个指向首元素的指针。
当你能画出内存图,解释每一步的类型和地址,你就真正掌握了它。
更多推荐



所有评论(0)