数组与双重指针的“亲密关系”:从零讲透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'

这个问题的关键就在于:你是否真正理解 ap 的关系

我们先不急着填空,而是从头开始,搞清楚“数组”和“双重指针”到底是什么。


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));

我们来一步步拆解:

  1. p[2] 等价于 *(p + 2)

    • p 指向 a[0]

    • p + 2 指向 a[2]

    • *(p + 2) 就是 a[2],即字符串 "hij" 的地址

  2. p[2] + 1 → 在 "hij" 的地址上 +1,指向 'i'

  3. *(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"};

这行代码做了两件事:

  1. 在只读数据段创建三个字符串

    • "abc" 存在某个地址,比如 0x1000

    • "def" 存在另一个地址,比如 0x1004

    • "hij" 存在另一个地址,比如 0x1008

  2. 在栈上创建一个数组 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:认为 ap 完全一样

  • sizeof(a) 返回整个数组的大小(如 3 * 8 = 24 字节)

  • sizeof(p) 返回指针的大小(通常是 8 字节)

  • 所以 ap 不是同一个东西!

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++ 内存模型的第一步。不要死记硬背,而是要从内存布局类型系统的角度去思考。

记住一句话:

数组名不是指针,但在大多数表达式中,它表现得像一个指向首元素的指针。

当你能画出内存图,解释每一步的类型和地址,你就真正掌握了它。

更多推荐