一、从“纸条”到“藏宝图”,深入指针的高阶世界
在上一篇文章中,我们通过“房间与纸条”的比喻,理解了指针的基本概念:指针是一个装着地址的变量,就像一张写着房间编号的纸条。我们学会了如何用 & 获取地址,用 * 解引用访问数据,并明白了指针类型的重要性。
今天,我们将探索那些让C程序员又爱又怕的“高级纸条”:数组指针、函数指针、多级指针(指向指针的指针),以及万能的 void*。
二、数组指针 vs 指针数组:一字之差,天壤之别
这两个概念经常让初学者混淆。关键在于:括号的位置决定了“谁是指针”。
🎯 核心区别一句话:
-
数组指针:一个指针,指向一个数组整体。
👉int (*p)[5];—— p 是一个指针,指向一个包含5个int的数组。 -
指针数组:一个数组,每个元素都是一个指针。
👉int *p[5];—— p 是一个数组,有5个元素,每个元素都是指向int的指针。
1. 数组指针:指向一整排房间的线索
想象你有一排5个连续的房间,编号为 arr[0] 到 arr[4]。你想用一张纸条记录这整排房间的起始位置,这张纸条就是“数组指针”。
#include <stdio.h>
int main() {
int arr[5] = {10, 20, 30, 40, 50};
// 数组指针:p 指向整个数组 arr
int (*p)[5] = &arr; // 注意:是 &arr,不是 arr!
printf("arr 的地址: %p\n", arr);
printf("p 的值: %p\n", p);
printf("p 指向的数组首元素: %d\n", (*p)[0]); // 注意括号!
printf("p + 1 的地址: %p\n", p + 1); // 跳过整个数组(5个int)
return 0;
}
arr 的地址: 0x7ffd6b6a1a00
p 的值: 0x7ffd6b6a1a00
p 指向的数组首元素: 10
p + 1 的地址: 0x7ffd6b6a1a14
🔍 关键点解析:
int (*p)[5]:p是一个指针,指向一个有5个int的数组。&arr:取整个数组的地址,类型是int(*)[5],正好匹配。(*p)[i]:先解引用p得到数组,再用[i]访问元素。p + 1:加1不是跳1个int,而是跳整个数组的长度(5×4=20字节)。
🧠 比喻:这张纸条上写着“从301号房间开始,连续5个房间都属于arr”。p+1 就是“下一组5个房间”。
2. 指针数组:一叠指向不同房间的线索
现在你有5张独立的纸条,每张纸条都写着一个房间的编号。这5张纸条被整齐地放在一个文件夹里——这个文件夹就是“指针数组”。
#include <stdio.h>
int main() {
int a = 10, b = 20, c = 30, d = 40, e = 50;
// 指针数组:5个指针,每个指向一个int变量
int *p[5] = {&a, &b, &c, &d, &e};
for (int i = 0; i < 5; i++) {
printf("p[%d] 指向的值: %d\n", i, *p[i]);
}
return 0;
}
p[0] 指向的值: 10
p[1] 指向的值: 20
p[2] 指向的值: 30
p[3] 指向的值: 40
p[4] 指向的值: 50
🔍 关键点解析:
int *p[5]:p是一个数组,有5个元素,每个元素都是int*类型。p[i]是第i个指针,*p[i]是它指向的值。p + 1:加1跳过一个指针大小(64位系统的话是8字节,32位则是4字节)。
🧠 比喻:文件夹里有5张纸条,第0张写“301”,第1张写“305”,第2张写“310”……它们可以指向任意房间,不一定是连续的。
📊 内存布局对比图示
数组指针 (*p)[5] 指向 arr[5]:
+-----+-----+-----+-----+-----+
| 10 | 20 | 30 | 40 | 50 | ← arr 数组(连续)
+-----+-----+-----+-----+-----+
↑
p (指向整个数组)
指针数组 *p[5]:
+----+----+----+----+----+
| →a | →b | →c | →d | →e | ← p 数组(每个元素是个指针)
+----+----+----+----+----+
↑ ↑ ↑ ↑ ↑
p[0] p[1] p[2] p[3] p[4]
📌 补充:数组名 arr 的“双重身份”
在学习数组指针时,一个让人困惑的问题是:数组名 arr 到底代表什么?
数组名本质上有两种 “身份”:
- 代表整个数组:仅在两种特殊场景下生效。
- 代表首元素的地址:在大多数普通场景下(如赋值、传参、运算等),数组名会被隐式转换为数组首元素的地址(指针)。
#include <stdio.h>
int main() {
int arr[5] = {1, 2, 3, 4, 5};
// arr 是 int* 类型(首元素地址)
int *p1 = arr;
// &arr 是 int (*)[5] 类型(指向整个数组的指针)
int (*p2)[5] = &arr;
// &arr[0] 是 int* 类型(首元素地址)
int *p3 = &arr[0];
// 按照各自的指针类型输出(仍需转换为 void* 以符合 %p 要求)
printf("arr = %p\n", (void*)p1); // 首元素地址
printf("&arr = %p\n", (void*)p2); // 整个数组的地址
printf("&arr[0] = %p\n", (void*)p3); // 首元素地址
printf("sizeof(arr) = %zu\n", sizeof(arr)); // 整个数组的大小(20字节)
return 0;
}
arr = 0x7ffe6c083620
&arr = 0x7ffe6c083620
&arr[0] = 0x7ffe6c083620
sizeof(arr) = 20
📌 关于 (void*) 的说明:
在 printf 中使用 %p 输出指针地址时,C 语言标准要求对应的参数应为 void* 类型。因此我们使用 (void*) 进行强制类型转换,以确保兼容性和避免编译器警告。
💡
void*是一种“通用指针”类型,可以指向任何数据类型,我们将在后文“void 指针:通用指针”一节中详细讲解。
🔍 逐行解析:
-
arr:在大多数表达式中,数组名arr会自动转换为 指向首元素的指针,即&arr[0]。所以它输出的是首元素的地址。 -
&arr:取整个数组的地址。虽然值和arr相同,但它的类型不同:&arr的类型是int (*)[5](指向整个数组的指针),而arr的类型是int*(指向首元素的指针)。 -
&arr[0]:明确取首元素的地址,与arr等价。 -
sizeof(arr):sizeof是少数不会触发数组名退化的场景之一。这里arr代表整个数组,所以sizeof(arr)返回的是整个数组占用的字节数:5 * sizeof(int) = 20(假设 int 为 4 字节)。
🎯 关键总结:
| 表达式 | 含义 | 类型 | 是否退化 |
|---|---|---|---|
arr | 指向首元素的指针 | int* | 是 |
&arr | 整个数组的地址 | int (*)[5] | 否 |
sizeof(arr) | 整个数组的大小(字节) | size_t | 否 |
&arr[0] | 首元素的地址(等价于 arr) | int* | 是 |
🧠 为什么这很重要?
理解 arr 在不同上下文中的含义至关重要,尤其是在处理数组指针时:
// 声明一个指向包含5个整数的数组的指针
int (*p)[5] = &arr; // ✅ 正确:类型匹配
// int *q[5] = &arr; // ❌ 错误:不能把数组地址赋给指针数组
当你将数组名传递给函数时,它会被隐式转换为指向首元素的指针:
void printFirstElement(int *ptr) {
printf("第一个元素的值: %d\n", *ptr);
}
printFirstElement(arr); // arr 退化为 &arr[0]
因此,记住这个口诀:
“
sizeof和&面前,数组名不退化;其他场合,arr就是&arr[0]。”
理解这一点,你就掌握了数组与指针关系的核心钥匙 🔑。
三、函数指针 vs 指针函数:谁是指针?
在C语言中,函数也是有地址的——就像变量存储在内存中一样,函数的机器指令也存放在特定的内存区域。因此,我们可以用一个“线索”(指针)来记录这个地址,从而间接地调用函数。
但要注意两个名字非常相似的概念:
- 函数指针(Function Pointer)
- 指针函数(Pointer-Returning Function)
它们看起来很像,含义却完全不同。我们来一一解析。
1. 函数指针:指向“会动的机器”的线索
还记得我们之前的比喻吗?变量是房间,指针是写着房间号的纸条。那么函数就像是一个“会动的机器”,它执行特定任务(比如加法、打印等)。而函数指针就是一张写着“去哪个房间找这台机器”的纸条。
🎯 什么是函数指针?
函数指针是一个变量,它存储的是某个函数的入口地址。通过这个指针,我们可以像调用函数一样去执行它指向的函数。
🔧 声明语法:
// 返回类型 (*指针名)(参数类型列表);
int (*func)(int, int);
// func 是一个函数指针,
// 指向返回 int、接受两个 int 参数的函数
🧠 解读:
(*func):说明func是一个指针。int (...):说明它指向的函数返回int类型。(int, int):说明它指向的函数接受两个int参数。
假设我们有两个简单的函数,一个做加法,一个做减法:
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int sub(int a, int b) {
return a - b;
}
现在,我们想用一个“变量”来决定到底是做加法还是减法。这个“变量”就是函数指针。
int main() {
// 声明一个函数指针:它可以指向返回int、有两个int参数的函数
int (*func)(int, int);
// 让 func 指向 add 函数
func = add;
printf("func(5, 3) = %d\n", func(5, 3)); // 输出:8
// 让 func 指向 sub 函数
func = sub;
printf("func(5, 3) = %d\n", func(5, 3)); // 输出:2
return 0;
}
🎯 关键理解:
func是一个指针变量,它可以“指向”不同的函数。- 通过
func(5, 3),我们就能调用它当前指向的函数。 - 这就像你有一个遥控器(
func),可以切换不同的电视频道(add或sub)。
🧠 比喻:
函数指针就像“遥控器上的按钮”,你按下同一个按钮,但可以控制不同的机器(函数)。
2. 指针函数:能“生成线索”的机器
与函数指针相反,指针函数是一个函数,它的返回值是一个指针。
🎯 什么是指针函数?
它是一个函数,执行后返回一个指向某种类型的指针。
🔧 声明语法:
// 返回类型* 函数名(参数列表);
int* create_array(int size);
// 这是一个函数,返回 int* 类型指针
🧠 解读:
int*:表示这个函数返回一个指向int的指针。create_array(...):函数名。
我们来看一个简单的例子:
#include <stdio.h>
// 定义一个全局变量(放在“外面的房间”)
int global_num = 100;
// 指针函数:这个函数返回一个指向 int 的指针
int* get_address() {
return &global_num; // 返回 global_num 的地址
}
int main() {
int *p; // 声明一个指针变量
p = get_address(); // 调用函数,接收返回的地址
printf("*p = %d\n", *p); // 输出:100
printf("global_num = %d\n", global_num); // 输出:100
// 我们甚至可以通过 p 修改 global_num
*p = 200;
printf("修改后:global_num = %d\n", global_num); // 输出:200
return 0;
}
🎯 关键理解:
get_address()是一个函数,它的返回类型是int*(指向 int 的指针)。- 它返回的是
global_num的地址。 - 主函数用指针
p接收这个地址,然后就可以通过*p访问或修改global_num。
🧠 比喻:
指针函数就像一个“地址生成器”——你问它:“global_num 在哪?”,它就写一张纸条(指针)给你,告诉你地址。
✅ 一句话总结区别
| 名称 | 本质 | 示例代码 | 类比 |
|---|---|---|---|
| 函数指针 | 是一个指针 | int (*f)(int, int); | 指向函数的遥控器 |
| 指针函数 | 是一个函数 | int* f(int, int); | 返回地址的“地址生成器” |
📌 小结
- 函数指针:先有函数,再用指针去“指向”它,方便灵活调用。
- 指针函数:函数执行完后,把一个地址“交给你”,让你可以访问某个变量。
它们只是名字像,其实是完全不同的东西。记住:看括号和返回类型,就能分清。
四、多级指针:线索套线索(指向指针的指针)
在前面的内容中,我们已经了解了如何通过指针来访问变量和函数。现在,让我们再深入一步,探讨一种更复杂的指针——多级指针,即指向指针的指针。
🎯 什么是多级指针?
想象一下,如果你有一张纸条,上面写的不是房间号,而是另一张纸条的位置,这张新的纸条才写着实际的房间号。这就是多级指针的概念。
最常见的多级指针是二级指针,它是指向指针的指针。换句话说,二级指针存储的是另一个指针的地址。
🔧 声明语法:
// 类型 **指针名;
int **pp;
// pp 是一个指向 int* 类型指针的指针
🧠 解读:
int **pp:表示pp是一个指向int*的指针。int*是一个指针,指向int类型的数据。pp存储的是int*的地址。
我们来看一个用二级指针修改一个指针的值的例子:
#include <stdio.h>
// 函数:通过二级指针修改传入的指针
void set_pointer(int **pp, int *target) {
*pp = target; // 把 pp 指向的指针,改为指向 target
}
int main() {
int a = 100;
int b = 200;
int *p = &a; // p 指向 a
printf("初始: p 指向 a, *p = %d\n", *p); // 输出:100
set_pointer(&p, &b); // 用二级指针让 p 指向 b
printf("修改后: p 指向 b, *p = %d\n", *p); // 输出:200
return 0;
}
🔍 逐行解析:
-
int *p = &a;
→p是一个指针,目前指向变量a。 -
set_pointer(&p, &b);
→ 把p的地址(即“纸条放在哪个抽屉里”)传给函数。 -
函数内
*pp = target;
→pp指向p这个指针变量,*pp就是p本身。
→ 所以*pp = &b;相当于p = &b;,修改了p的值。
📌 小结
- 二级指针(
int **pp)是指向指针的指针。 - 想象它是“指向纸条的线索”——你可以通过它去修改那张纸条上写的内容。
- 三级指针(
int ***ppp)则是指向二级指针的指针,虽然较少使用,但原理类似。
💡 提示:无论多少级指针,核心思想都是相同的:每增加一层指针,就是对前一层指针的操作。理解这一点,就能轻松应对任何级别的指针。
五、void* 指针:万能钥匙
1. 什么是 void*?
void* 是一种特殊的指针类型,称为通用指针或无类型指针。它可以指向任何数据类型的对象,但不指定具体的数据类型。这意味着你不能直接通过 void* 指针进行算术运算或访问数据,除非先将其转换为特定类型的指针。
声明方式:
void *ptr;
ptr是一个void*类型的指针,可以指向任意类型的对象。
🧠 比喻:
- 如果把普通指针比作“特定房间号的纸条”,那么
void*就是一张“通向任意房间的万能钥匙”——你可以用它打开任何门,但需要知道具体要开哪个门(即需要类型转换)。
2. 使用 void* 的基本示例
让我们来看一个简单的例子,演示如何使用 void* 指针来存储不同类型的数据地址,并通过显式类型转换来访问这些数据。
#include <stdio.h>
void printAddress(void *ptr, const char *type) {
// 使用 (void*) 进行强制类型转换,以匹配 %p 格式说明符的要求
printf("%s 地址: %p\n", type, (void*)ptr);
}
int main() {
int a = 10;
double b = 3.14;
char c = 'A';
void *ptr;
ptr = &a;
printAddress(ptr, "int");
ptr = &b;
printAddress(ptr, "double");
ptr = &c;
printAddress(ptr, "char");
return 0;
}
🔍 逐行解析:
- 我们定义了一个
printAddress函数,它接受一个void*类型的指针参数和一个描述类型的字符串。 - 在
main函数中,我们创建了三个不同类型的变量:int、double和char。 - 我们将这些变量的地址赋值给
void*类型的指针ptr,然后调用printAddress打印它们的地址。
📌 小结
void*可以指向任何类型的对象。- 必须通过显式类型转换才能访问或操作
void*指向的数据。
六、const 与指针——谁不能变?
在 C 语言中,const 关键字用于定义“不可修改”的变量。但当它和指针一起使用时,就会出现三种不同的情况,让人困惑:
const int *p; // 指向“常量”的指针
int *const p; // “常量”指针
const int *const p; // 指向“常量”的“常量”指针
int a = 10, b = 20;
const int *p = &a;
*p = 100; // ❌ 编译错误!不能修改 *p
p = &b; // ✅ 可以改变 p 的指向
int a = 10, b = 20;
int *const p = &a;
*p = 100; // ✅ 可以修改 a 的值
p = &b; // ❌ 编译错误!不能改变 p 的指向
int a = 10, b = 20;
const int *const p = &a;
*p = 100; // ❌ 错误!不能改内容
p = &b; // ❌ 错误!不能改指向
✅ 快速记忆口诀
| 写法 | 口诀 |
|---|---|
const int *p | * 前有 const:内容不能改 |
int *const p | * 后有 const:指针不能改 |
const int *const p | 前后都有:谁都别想改 |
💡 小技巧:
const int *p 和 int const *p,此时*p在一起,表示指针指向的内容,因此内容不能变。int *const p, 此时p单独存在,表示指针本身,因此指针不能变。
📌 小结
const和指针结合,决定了谁可以被修改:是指针本身?还是它指向的内容?- 掌握这三种形式,有助于写出更安全、更清晰的代码。
- 虽然现在可能还用不到复杂场景,但了解
const是迈向“专业级 C 编程”的第一步。
🔜 后续在讲解函数参数、字符串处理、数组只读访问等应用时,我们会再次遇到
const,到时候你会感谢现在打下的基础。
七、野指针:潜伏的危险
1. 什么是野指针?
野指针是指向已释放或未初始化的内存地址的指针。使用野指针可能会导致不可预测的行为,包括程序崩溃、数据损坏甚至安全漏洞。因此,识别和避免野指针是编写健壮代码的重要部分。
常见产生野指针的情况:
- 未初始化的指针
- 如果一个指针在声明时没有被初始化,它可能包含任意值,指向随机的内存位置。
- 局部变量生命周期结束后
- 局部变量在其作用域结束后会被销毁,如果指针指向这些局部变量,那么当函数返回后,这些指针就变成了野指针。
2. 示例分析
示例1:未初始化的指针
#include <stdio.h>
#include <assert.h>
void danger() {
int *ptr; // 未初始化的指针
assert(ptr != NULL); // 使用 assert 检查 ptr 是否为 NULL
*ptr = 10; // 危险!尝试写入未初始化的指针
printf("%d\n", *ptr); // 结果不确定
}
int main() {
danger();
return 0;
}
在这个例子中,ptr 没有初始化就被解引用,这将导致未定义行为。通过 assert(ptr != NULL) 可以在运行时检查指针是否已经初始化。
示例2:局部变量生命周期结束后
#include <stdio.h>
#include <assert.h>
int *returnLocalPtr() {
int x = 10;
return &x; // 返回局部变量的地址
}
int main() {
int *p = returnLocalPtr(); // p 是一个野指针
assert(p != NULL); // 尽管 p 不为 NULL,但它是野指针
printf("%d\n", *p); // 危险!访问已销毁的局部变量
return 0;
}
在 returnLocalPtr 函数返回后,局部变量 x 的生命周期结束,p 成为野指针。即使 assert(p != NULL) 通过了检查,p 仍然是无效的。
3. 如何避免野指针
为了避免野指针带来的风险,可以采取以下措施:
1. 确保指针初始化
在声明指针时,总是初始化它。如果暂时不需要使用,可以将其初始化为 NULL。
int *ptr = NULL;
2. 使用 assert 进行运行时检查
在使用指针之前,可以通过 assert 宏进行检查,确保指针不是 NULL 或者已经被正确初始化。对应代码在示例1中。
3. 避免返回局部变量的地址
不要返回局部变量的地址,因为这些变量在函数返回后会被销毁。如果需要返回指针,考虑使用静态变量或全局变量。
int* safeFunction() {
static int x = 10; // 使用静态变量代替局部变量
return &x;
}
📌 小结
- 野指针是指向已释放或未初始化内存的指针,使用它们可能导致未定义行为。
- 常见产生野指针的情况包括未初始化指针以及局部变量生命周期结束后。
- 避免野指针的关键在于:
- 确保指针初始化。
- 使用
assert进行运行时检查。 - 避免返回局部变量的地址。
📝 总结:指针之路,才刚刚开始
在这篇文章中,我们系统地梳理了C语言中几个重要的指针概念:
- 从 数组指针与指针数组 的语法差异,到它们在内存中的不同布局;
- 从 函数指针与指针函数 的命名陷阱,到如何正确理解它们的本质区别;
- 再到 多级指针 的“线索套线索”模型,理解指针也可以被指向;
- 我们还介绍了
void*指针 这把“万能钥匙”,以及它在通用性设计中的潜力; - 最后,我们警惕了 野指针 这个潜伏的危险,强调了初始化和检查的重要性。
这些内容大多停留在概念解析和基础理解层面。通过比喻、图示和简单示例,希望能帮助像我一样的初学者,扫清指针学习路上的迷雾,建立起正确的认知框架。
但我也清楚地知道:
👉 懂了概念,不等于会用。
👉 能看懂,不等于能写出来。
目前我对指针的理解还很浅,很多实际应用场景——比如用指针实现动态数组、链表、回调机制、字符串处理、内存管理等——还没有深入展开。这些更实用、更强大的内容,我会在后续的“指针应用篇”中逐步学习和分享。
如果你在阅读过程中发现任何理解偏差、表述不清或错误之处,欢迎大家批评指正。学习的路上,最宝贵的不是“全对”,而是“不断修正”。
愿我们都能在学习C语言的路上,稳扎稳打,步步为营,最终真正掌握这门强大而优雅的计算机语言。

被折叠的 条评论
为什么被折叠?



