本章将正式引入指针的概念,并且介绍它与数组的关联;同时也将解释清楚为何
scanf("%s", ...不需要加上&
6.1 おい、その 先 は 空 ポインタだぞ #
这是雪藏于历史的一段对话(大雾)红 A:喂,那前面可是空指针啊。
士郎:这就是你曾经忽略的细节。
最初确实是因为对编程的向往,
但归根结底是内心的执着,
想要驾驭那个空指针的渴望:
明明想要掌控内存的奥秘,
最终却陷入段错误的男人 那未曾实现的理想。红 A:即使你的代码会被当做教科书般的反面案例?
士郎:是啊,
即使我的程序,
充满内存泄漏,
我还是要坚持成为正义的程序员。
这段“从者”间的对话(?),揭示了指针的两面性:它强大到可以让你直接掌控一片内存,也危险到一次错误的访问就可能会让你程序崩溃(段错误),或者留下难以察觉的 内存泄漏(占着内存不使用,越占越多)。
本章,我们就来揭开这位“魔术师”的神秘面纱,学习如何安全地驾驭它,成为一名真正的“正义的程序员”。
6.2 指针 #
指针,顾名思义,就是指向某个地址的变量。通过指针建立链表、指针数组等,可以方便地处理很多任务;此外,对于类的使用和动态内存分配,它们较为依赖指针,甚至完全离不开指针。因此,了解指针、学习指针是十分有必要的。
关于 C++ 的“智能指针”,请参阅 第11章。
6.2.1 指针的声明和赋值 #
和普通的变量相同,指针使用之前也要声明。语法如下:
类型 *变量名;
// 如果要同时赋值的话:
类型 *变量名 = 值;
举例:
// 这里的i使用Windows Api风格的参数说明符,i表示integer,p是pointer
// const是常量的意思(理论上不可更改)
const int iPrice = 100;
int *pPrice = &iPrice;
这里先声明了一个普通的整数 iPrice,然后定义了一个指针指向它。
int * 就是声明一个指向 int 数据的指针(当然也有人写成 int* price,这和断句一样都无可厚非)
我们注意等号后面的部分:&iPrice,这里 & 表示取地址,也就是得到 iPrice 变量的“门牌号”。由此 pPrice 里面就存储了 iPrice 的地址。
碎碎念:真的无可厚非吗……
考虑这样的代码:
int* a, b;那么,
a和b的类型分别是什么呢?
想好了就点开吧
指针的类型有很多,基本上有“基本类型”就有其对应的指针。你甚至可以套娃,声明一个 int **ppPrice = &pPrice; 萨卡萨卡班班甲鱼鱼(大雾)。二重指针、指针数组(也算二重指针,一会你会知道为什么)在一些场景下非常有用。
如果,你暂时不确定指针的类型,也可以使用一个通解 void *,这个指针可以接受任何变量的地址。
const int iPrice = 100;
double dWeight = 267.10
void *pJackOfAllTrades = &iPrice;
pJackOfAllTrades = &dWeight;
当然,不像 C,在 C++中,从 void* 转换回具体类型指针需要显式转换(C 会隐式自动完成)。这是为了安全,让你明确知道自己在做一件有风险的事。
void* ptr = &x;
int* intPtr = static_cast<int*>(ptr); // 静态转换
// 如果你嫌烦,也可以使用下面的C风格
// 但复杂的xxx_cast的目的是为了让你知道你在做一件有风险的事情
// int* intPtr = (int*)ptr; // C 风格转换
碎碎念:
const int* p还是int* const p天使と悪魔と天使と悪魔が这俩可不是一回事哦!
const的位置决定了是指针固定还是数据固定。
const int* p的意思是 p 指向的东西是常量——指针指向的值不可变,指针本身可变;而int* const p的意思是指针指向的值可变,而指针本身固定。这其实是个断句题。
const int* p的意思是,指向const int的指针 p(*p),而int* const的意思是指向(普通非const)int类型的const指针变量(*const p)。在部分 C 程序里面,你可能见过
const int* const p;的表达,这就是俩都固定了。你甚至可以继续套娃(多重指针):
const int* const* const pp;。很绕是吧,有一个较好的解析方法,也就是按照从右到左的顺序解释:pp自己不能变,它指向的那个指针也不能变,而且那个指针指向的整数值还不能被修改。如果是 C++的话,大部分以上的情况都可以用引用变量(比如
int& p或者const int& p)通杀。有关引用变量的内容我们将在第八章展开学习
新对象的分配 #
除了指向已有的变量,指针还可以指向动态分配的内存。在 C 和 C++中,动态内存分配的方式有所不同。C 语言使用 malloc() 和 free() 来分配、释放内存;而 C++ 还可以使用更现代的 new 和 delete(delete[])对。
下面的例子展示了常用的动态内存分配方式:
C 风格 - malloc/free:
// 分配一个int大小的内存
int *p = (int*)malloc(sizeof(int));
// 排除出错情况(返回NULL)
// 或者 if(p) 也可以
// 因为 NULL 是 ((void*)0),在条件判断中为假
if (p != NULL) {
*p = 100;
printf("%d\n", *p);
free(p); // 必须手动释放
// 好习惯:释放完后置空
// 避免形成悬挂指针
p = NULL;
}
// 分配数组
// 这样一来arr相当于一个int arr[10];
int *arr = (int*)malloc(10 * sizeof(int));
if (arr) {
for (int i = 0; i < 10; i++) {
arr[i] = i;
}
for (int i = 0; i < 10; i++) {
printf("%d ", arr[i]);
}
// 直接传入数组名即可
// 不需要传入大小,系统知道
free(arr);
arr = NULL;
}
C++风格 - new/delete:
/* C++引入了try-catch机制来处理异常 *
* 异常处理现在我们暂不介绍 *
* 但这里还是展示C++的风格 *
* 对于初学者,如果觉得try-catch太复杂 *
* 可以先使用new(nothrow)版本 *
* 这个基本和malloc()等效,出错返回NULL *
* 程序结构就可以参照上面的C版本 *
* 目前暂时不要因为新的错误处理机制而感到束手束脚 *
* 悄悄告诉你,其实大部分时候都是成功的 *
* 倒也没必要事事检查 */
int *p = nullptr;
// 允许的方式:
// p = new int(100); // 分配并初始化为100
// p = new int; // 分配但不初始化(值随机,是上次这片内存遗留的)
// p = new int(); // 分配并值初始化为默认值(0)
// 用熟了可以使用auto自动推导
// auto p = new int(100);
// 但是这里不合适,因为演示的try{}块限制了p的作用域
// 在{}之外就没法访问到p了
// 参见3.6
try {
// new(nothrow)版本:
// p = new int(100);
// 后续不再赘述
p = new int(100); // 直接初始化为100
cout << *p << endl;
} catch (const std::bad_alloc& e) {
cout << "err allocating mem: " << e.what() << endl;
// 后续可能的处理
// ...
}
// 安全释放
if (p) {
delete p;
p = nullptr;
}
// 分配类对象
// 关于类的管理参见第11章
// 这里不用太在意
class MyClass {
public:
MyClass(int x) : value(x) {}
int value;
};
MyClass *obj = nullptr;
try {
// 新建对象,顺便调用第21行的构造器完成赋值
obj = new MyClass(42);
} catch (const std::bad_alloc& e) {
cout << "err allocating mem: " << e.what() << endl;
}
if (obj) {
// 输出obj指向的对象的成员value的值
cout << obj->value << endl;
delete obj;
obj = nullptr;
}
// 分配数组
int *arr;
try {
arr = new int[10];
} catch (const std::bad_alloc& e) {
cout << "err allocating mem: " << e.what() << endl;
}
if (arr) {
for (int i = 0; i < 10; i++) {
arr[i] = i;
}
// 注意:数组要用delete[]
// **** 这与C不一样!!! ****
delete[] arr;
arr = nullptr;
}
重要区别:
malloc/free是函数,new/delete是运算符new/delete会自动调用构造/析构(人话:销毁)函数,malloc/free不会(所以复杂对象每次都需要手工销毁)new不需要类型转换(int *p = new int(100);,用熟了可以auto *p = new int(100);),malloc需要(int *p = (int*)malloc(sizeof(int));)new在分配失败时会抛出异常(throw一个exception,可以在任何合理的位置catch,参见 12 章),而malloc返回 NULL,需要立刻使用if检查
但是共同点都是:使用完后一定要记得销毁!!!!!! 建议在分配的时候就想好在哪里释放,销毁完成了也一定要置空防止悬挂。 也不要当混沌恶魔: new 分配, free() 销毁哈!大大的 UB(未定义)行为在向你招手
6.2.2 指针的使用 #
使用 * 来“解引用”指针,也就是顺着指针存储的门牌号上门访问指向的变量 我超,盒!。
// 蛇形命名法
int count_of_candies = 10;
// 一般不直接在一个作用域使用指针,我们这里仅供演示
// 所以指针的命名我就随意了
int *p = &count_of_candies;
*p = 15;
*p 和 count_of_candies 这里基本上是等效的。通过指针修改 *p,就直接修改了原变量 count_of_candies 的值。
如果是 void * 指针,使用的时候必须转换成具体类型,因为不同类型占用大小不同,格式也不一样,所以编译器不知道该如何解释那块内存里的数据——就好比知道门牌号是不够的,户型也需要清楚。
*static_cast<double*>(pJackOfAllTrades) = 250.75;
// 不熟悉可以先用C
*(double *)pJackOfAllTrades = 250.75;
要输出指针本身的值(即它存储的地址),可以使用 printf 的 %p 格式符,或直接使用 std::cout,但要注意使用 static_cast<const void*> 进行转换,这样可以确保正确输出指针的地址值,否则 std::cout 可能会尝试将其作为 C 风格字符串处理。
指针不仅仅能“看”(读取),更能“改”(写入)。这是它最强大的能力之一,允许函数修改其作用域之外的变量。为什么这么说?因为在没有引用变量的 C 语言中(C++有)变量是 按值传递 的。也就是说,当你把一个变量传递给函数时,函数得到的是这个变量的值,而不是原始变量本身。
考虑以下代码:
#include <stdio.h>
// 这个函数试图将传入的参数加倍
void try_to_double(int number) { // number 是外部变量的一个副本
number = number * 2;
printf("函数内部: number = %d\n", number);
}
int main() {
int my_number = 5;
printf("调用前: my_number = %d\n", my_number);
try_to_double(my_number); // 传递的是 my_number 的值(5)
printf("调用后: my_number = %d\n", my_number); // 仍然是 5!
return 0;
}
交互:
调用前: my_number = 5
函数内部: number = 10
调用后: my_number = 5
可以看到,函数内部修改的只是自己作用域下的副本 number,而外部的 my_number 丝毫未变。
在 C 语言中,如果我们想让函数真正修改外部变量,就不能传递变量的值,而必须传递变量的 地址。这样,函数通过指针这个“门牌号”,就能找到原始变量并进行修改。
#include <stdio.h>
// 这个函数成功地将传入指针所指向的变量加倍
void really_double(int *number_ptr) { // 接收一个地址(门牌号)
*number_ptr = *number_ptr * 2; // 通过指针找到变量并修改它
printf("函数内部: *number_ptr = %d\n", *number_ptr);
}
int main() {
int my_number = 5;
printf("调用前: my_number = %d\n", my_number);
really_double(&my_number); // 传递 my_number 的地址(&取地址)
printf("调用后: my_number = %d\n", my_number); // 成功变为 10!
return 0;
}
交互:
调用前: my_number = 5
函数内部: *number_ptr = 10
调用后: my_number = 10
可以看到这一次成功修改了 my_number 的值
这就是为什么 scanf 必须使用 & 来传递变量的地址! 如果只传 number(它的值),scanf 得到的只是一个莫名其妙的数字(比如 number 的初始值 0),它无法知道该把读取到的数据写回到哪里去。
6.2.3 指针的运算 #
指针可以进行加减运算,但其增减单位不是 1 个字节,而是 增加它所指向类型的大小。这就像按“房子”跳跃,而不是按“米”移动。具体看下面的代码:
#include <stdio.h>
int main() {
int arr[] = {10, 20, 30, 40, 50};
int *p = arr; // p 指向数组第一个元素(arr[0])
printf("p指向的地址: %p, 值: %d\n", (void*)p, *p);
printf("sizeof(int) = %zu\n", sizeof(int)); // %zu是size_t的格式符
p = p + 1; // 指针加1,指向下一个int元素
printf("p+1后的地址: %p, 值: %d\n", (void*)p, *p); // 输出 20 强制转换为(void*)是良好的习惯
return 0;
}
你会发现 p+1 后的地址比 p 的地址正好大了 sizeof(int)(通常是 4 个字节),换成 double arr(当然 %d 也要相应变成 %f),地址就大了 sizeof(double)——这就是指针运算的规则。而对于 void* 类型,编译器不知道该如何进行这种“跳跃”,这也是为什么 void* 指针不能进行算术运算。
6.3 数组 #
数组名在大多数情况下可以看作一个 指向数组首元素的常量指针。
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr; // 等价于 int *p = &arr[0];
printf("arr[2] = %d\n", arr[2]);
printf("*(arr + 2) = %d\n", *(arr + 2)); // 同样输出arr[2]的值
printf("p[2] = %d\n", p[2]); // 指针也能用下标!
看到了吗,这里 p 和 arr 是完全等价的,甚至 [] 语法也完全一致。这也解释了为什么这 scanf("%s", char_array) 不需要 &:因为数组名 char_array 本身就是一个地址(指针),这与 6.2.2 结尾的论断并不矛盾。
6.3.1 二维数组 #
如果说一维数组是「一排房子」,那么二维数组就是「一栋楼房」——它由多个「一排房子」(行)组成
6.3.1.1 二维数组的初始化 #
二维数组的初始化与一维相似:
// 定义一个3行4列的二维数组
int matrix[3][4] = {
{1, 2, 3, 4}, // 第0行
{5, 6, 7, 8}, // 第1行
{9, 10, 11, 12} // 第2行
};
// 也可以省略第一维的大小(行数),编译器会自动推断
// int matrix2[][4] = {
// {1, 2, 3, 4},
// {5, 6, 7, 8},
// {9, 10, 11, 12}
// };
但是与上面 3*4 的形式不太一样,数组在内存中仍然是一维存储的,像这样:
{1 2 3 4} {5 6 7 8} {9 10 11 12}
6.3.1.2 二维数组的指针 #
前面的二维数组 matrix 是一个指向“包含 4 个 int 的数组”(int[4])的指针:
int (*ptr)[4] = matrix; // ptr指向matrix的第一行
// 相当于{1, 2, 3, 4}那一行的数组名称
matrix 同样可以玩的很花:
printf("%d\n", matrix[1][2]); // 输出 7
printf("%d\n", *(*(matrix + 1) + 2)); // 同样输出 7
printf("%d\n", ptr[1][2]); // 还是输出 7
⚠ 危险的误解
int **wrong_ptr = matrix; // ❌ 错误!类型不匹配!! ❌
二维数组 不是 二级指针!它是一个指向数组的指针,这与指针数组(int* arr[4])有本质区别。我们看下图:
二维数组:
指针数组:
所以我们必须指定列数,因为编译器需要知道「一跳跳多远」——只有知道每行有多少元素,才能正确计算 arr[i][j] 的内存地址:
void print_matrix(int arr[][4], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
printf("%d ", arr[i][j]);
}
printf("\n");
}
}
// 调用,3行
print_matrix(matrix, 3);
6.3.2 字符串(字符数组) #
在 C 语言中,字符串本质就是以空字符 '\0' 结尾的字符数组。操作字符串,其实就是操作字符数组(或操作指向字符数组的指针)。
// 一个空的[]的意思是让编译器自己判断大小
char greeting[] = "Hello"; // 编译器自动添加'\0'
// 如果你闲得发慌也可以:
// char name[10] = {'W', 'o', 'r', 'l', 'd', '\0'};
// 一旦部分赋值,数组剩余部分会被自动初始化为0,也就是'\0'
char name[10] = "World";
char *p_greet = greeting; // 指针指向字符串
printf("%s\n", greeting); // %s 需要的是一个地址
printf("%s\n", p_greet);
C++的 STL(Standard Template Library,标准模板库)类型 std::string 的实现不太一样,理论上长度可以无限扩展,这里暂不讨论。
碎碎念:指针数组的实用场景
指针的概念看似抽象,但在实际项目中无处不在。一个典型的例子就是图形界面(GUI)开发。
在很多 GUI 框架(如 Qt、MFC、wxWidgets)中,界面上的控件(如按钮、文本框)通常是动态创建的。它们的生命周期必须由其父窗口管理——当父窗口关闭时,所有子控件也需要被自动清理。
使用
new在堆上动态创建这些控件是最常见的做法,这就自然需要用指针来管理它们。当你需要创建和管理一大批功能相似的同类型控件时,指针数组 就发挥了巨大作用。// C++ Qt 框架示例 // 创建一个可以存放10个按钮指针的数组 QPushButton *buttons[10]; for (int i = 0; i < 10; ++i) { // 动态创建按钮,指定父对象为parent窗口 // new 的意思是创建一个new后面的类型的对象(这里是QPushButton) // 返回的是指向这个指针 // 括号部分设置了父亲是parent(如果是null空指针,那么QPushButton是自由) // parent会负责在析构时自动删除所有子控件 // 这样就不会内存泄漏,占着内存不使用 buttons[i] = new QPushButton(parent); // 通过指针数组可以方便地批量设置属性 buttons[i]->setText("Button " + QString::number(i + 1)); // 设置文本为Button i+1 buttons[i]->move(10, 30 * i + 10); // 设置位置 // 也可以批量连接信号槽 connect(buttons[i], &QPushButton::clicked, this, &MyClass::handleButtonClick); }为什么要用指针数组而不是普通数组?前者优势在于:
统一管理:一个循环就能溜过(术语是:遍历)所有按钮,进行批量操作(比如隐藏所有按钮、修改所有按钮的文字等)
动态创建:按钮是在程序运行时才创建的,更加灵活(动态创建往往返回的就是指针)
自动清理:
相当轮椅!由于指定了父对象,当窗口关闭时,所有按钮都会被自动删除,不需要手动维护,所以也不用担心内存泄漏如果不用指针数组,你可能需要写 10 遍几乎相同的代码来创建和设置每个按钮,那会非常繁琐且难以维护。
这就是指针在实际工程中的一个典型应用场景——当你需要管理多个类似的对象时,指针数组能让你的代码更加简洁和高效。
6.3.2.1 赋值?拷贝?C 语言字符串避坑指南 #
很多从 C++转战 C 的同学(或者被其他编程语言“惯坏”的)常会踩到这个坑:在 C 语言中,数组名不是指针变量,不能直接赋值!
char str[20];
str = "Hello"; // ❌ 编译错误!数组名是常量指针!无法修改!
简而言之:
-
str是数组名,是一个 地址常量,表示数组的首个元素的地址(&str[0]) -
"Hello"是一个字符串字面量,也有自己的地址(多半在内存静态区,而这里不可修改)所以
str = "Hello";就像是说1 = 2一样,对编译器而言是无法接受的。更危险的例子:
char *ptr = "hello"; // 这里"Hello"在内存静态区!
ptr[0] = 'H'; // 危险!!!
正确的做法是使用拷贝函数,这个函数的工作就是逐字符拷一份进来:
#include <string.h> // 需要包含头文件
char str[20];
strcpy(str, "Hello");
不过 strcpy 也有保留节目:缓冲区溢出,原理和 scanf 差不多。所以推荐使用 _s 版本:
strncpy(str, "Hello", sizeof(str) - 1);
str[sizeof(str) - 1] = '\0'; // 确保字符串以'\0'结尾
6.3.2.2 安能辨我是 * []:数组与指针的些微差异
#
我们直接上代码:
char *ptr = "hello"; // "hello"在内存的静态只读区!
ptr[1] = 'H'; // 运行时错误!尝试修改只读内存
char arr[] = "hello"; // []的意思是会在栈上创建可修改的副本
// 然后赋值{'h', 'e', 'l', 'l', 'o', '\0'}
arr[0] = 'H'; // 没问题,修改的是自己的副本
printf("%s", arr); // 输出 "Hello"
这个抽象且反直觉的错误,发生的原因如下:
实际上,给字符指针、字符数组直接赋字符串值可以看作是 C/C++ 的 语法糖(方便程序员敲代码的简化语法结构),如果不看清语法糖的本质,就容易造成误解进而出错。
在 C/C++ 中,字符串字面量(如
"hello")存储在程序的静态存储区(具体是只读数据段)。这个区域在程序运行期间是只读的,任何试图修改它的行为都会导致运行时错误(段错误)。当你写:
char *ptr = "hello";这里的
"hello"是一个字符串字面量,它代表一个静态存储区的字符数组的首地址。这条语句相当于将这个地址赋值给指针ptr。因此,ptr指向的是只读内存,通过ptr修改该内存的内容是非法的。而当你写:
char arr[] = "hello";这里的
"hello"同样是一个字符串字面量,但此时它并不是直接作为地址使用。这条语句会被编译器识别为 数组初始化,自动转化为等价形式:char arr[6] = {'h', 'e', 'l', 'l', 'o', '\0'};也就是说,编译器会在栈(全局变量另说)上分配一个大小为 6 的字符数组
arr,然后直接按照上面的方式直接赋值。因此,arr中的字符是可修改的,因为它们是栈上的副本。说到底这还是 C 对象化程度低导致的,C++ 的 `std::string` 将字符串封装为一个对象,消除了上面反直觉的操作。在 C++ 中,我们更推荐使用 `std::string` 来避免这类问题。
如果你想看汇编...... 因此,在使用 C 字符串时要格外小心,务必清楚你操作的是指针(指向只读内存)还是数组(栈上的可修改副本)。
最后,在 C 中,由于早期 C 没有
笨蛋!变量初始化时无法转换‘const char*’为‘double’类型,建议重开谢谢喵~const关键字,"hello"类型就是朴素是char [](赋值时会转为char *),且这个特性一直保留到现在,所以可以直接赋值给之前的char *ptr;而 C++ 中,hello类型是const char*,因此不能降级赋值给char *ptr。编译器会报告类型转换错误
总之:注意安全。
6.4 函数指针 #
没错,函数本身也可以有指针。不如说函数名本身就是指针(稍后会说,这是个瓜(^ v ^))。
6.4.1 函数指针的声明 #
函数指针这样声明:
bool (*compare)(int, int);
意思是返回 bool,接受两个 int 的指针 compare。很好理解吧?返回值和参数列表的位置都与函数声明一模一样。
然后可以将实际函数赋给它:
bool isGreaterThan(int a, int b) { return a > b; }
bool isLesserThan(int a, int b) { return a < b; }
compare = isGreaterThan;
std::cout << "114 > 514: " << compare(114, 514) << std::endl;
compare = isLesserThan;
std::cout << "114 < 514: " << compare(114, 514) << std::endl;
实际上,函数指针往往应用在“回调函数”的场景下。std::sort 是一个标准的排序函数…………默认是……且只能接受数字但是我们可以让它按照一定标准排序,从而支持非数字对象的排序或者特殊方法的排序。
6.4.2 函数指针的应用 #
函数指针最强大的应用场景就是 回调函数。以 std::sort 为例:
#include <algorithm>
#include <vector>
#include <iostream>
// 默认排序(升序)
std::vector<int> numbers = {5, 2, 8, 1, 9};
std::sort(numbers.begin(), numbers.end()); // 排序后: 1, 2, 5, 8, 9
// 使用函数指针自定义排序规则
bool descending(int a, int b) {
return a > b; // 降序排列
}
std::sort(numbers.begin(), numbers.end(), descending); // 排序后: 9, 8, 5, 2, 1
// 更复杂的排序规则:奇数优先,偶数靠后,同奇偶性时数值大的在前
bool oddFirst_thenDescending(int a, int b) {
if (a % 2 != b % 2) { // 奇偶性不同
return a % 2 > b % 2; // 奇数(true) > 偶数(false)
} else { // 奇偶性相同
return a > b; // 数值大的在前
}
}
std::sort(numbers.begin(), numbers.end(), oddFirst_thenDescending);
// 排序后: 9, 5, 1, 8, 2 (奇数在前且降序,偶数在后且降序)
函数指针提供了强大的灵活性和抽象的多样性,其隐含的回调机制也是 WinAPI 图形界面的核心,是强大而常用的语法工具
6.4.3 禁止套娃!函数名的「魔法」:语法糖与历史渊源 #
函数指针的赋值有个有趣的现象:
// 以下四种写法完全等价!
bool (*ptr1)(int, int) = isGreaterThan; // 最常用
bool (*ptr2)(int, int) = &isGreaterThan; // 显式取地址
bool (*ptr3)(int, int) = *isGreaterThan; // 解引用?居然也可以!
bool (*ptr4)(int, int) = ****isGreaterThan; // 甚至无限套娃!
// 调用时也同样灵活:
bool result1 = ptr1(114, 514); // 正常调用
bool result2 = (*ptr2)(114, 514); // 解引用调用
bool result3 = (***ptr3)(114, 514); // 多重解引用调用
为什么这么「乱」?
这是因为函数名在表达式中会自动转换为函数指针(类似数组名),所以 isGreaterThan 和 &isGreaterThan 本质上是同一个地址。这种设计是为了向后兼容古老的代码,也体现了 C 语言的灵活性。
瓜瓜乐之:函数指针圣战
引入函数指针的时候,有两派人,就叫做咸派(显式取址派)和甜派(语法糖派)吧。
咸派认为应该显式取地址、显式解引用:int (*ptr)(int) = &func; // 明确表示取地址 int result = (*ptr)(10); // 明确表示解引用他们认为函数指针操作应该与其他指针一致,清晰表明“这是指针”。
而甜派认为应该允许直接像调用函数一样使用函数指针,让代码更简单统一:
int (*ptr)(int) = func; // 简洁明了 int result = ptr(10); // 像普通函数一样两派争论有些激烈
差点打起来(bushi),最终,C 标准委员会最终裁定:两种写法都合法,且完全等价。函数名就是函数指针。printf("%p %p\n", func, &func); // 输出相同的地址值!这就像豆腐脑的甜派和咸派,完全是个人风格和使用场景的问题了。
所以,你是甜派还是咸派?而我是混沌恶魔派 :
(*(**(***&&&**************************&&&&*********&&&&*********&compare)))(1, 2);
6.5 指针的本质 #
试试下面的代码:
// %zu表示size_t类型,z表示size_t,u进一步说明它是无符号的。
// 大多数平台size_t就是unsinged int的别名
// 也就是:
// typedef unsigned int size_t;
printf("sizeof(int*) = %zu\n", sizeof(int*));
printf("sizeof(double*) = %zu\n", sizeof(double*));
printf("sizeof(void*) = %zu\n", sizeof(void*));
你会发现,所有类型的指针在同一个平台上的大小通常是一样的(32 位系统是 4 字节,64 位系统是 8 字节)。因为它们本质上存储的都是一个内存地址,而这个地址的长度是由系统架构决定的,与它指向什么类型的数据无关——好比记录门牌的本子是固定的大小,但是对应的房子却不一样大。在你能遇见的大多数情况下,指针与 int 或 long 一样大。
再看这个 神奇 的现象:
int arr[5] = {10, 20, 30, 40, 50};
printf("arr[1] = %d\n", arr[1]); // 输出 20
printf("1[arr] = %d\n", 1[arr]); // 输出 20?!!WTF?!
Why? 因为 a[b] 在编译器内部被解释为 *(a + b)。根据加法交换律,*(a + b) 等价于 *(b + a),也就是 b[a]![ ] 运算符里的那个数字,本质上是 偏移量(offset),下标则是后来的叫法。这同时也解释了为什么数组下标从 0 开始,因为一开始就不叫下标,在 C 语言的文档中,它叫做 偏移量(offset)——第一个元素的地址是 基地址 + 0,第二个是 基地址 + 1,非常自然。
6.6 空指针、悬挂指针与段错误 #
空指针(NULL in C, nullptr in C++11+)是一个特殊的指针值,表示 指针不指向任何有效的内存地址。它是士郎想要驾驭的“理想”,也是红 A 警告的“地狱”。 对于 NULL,它是整数值 0((void*)0),而 nullptr 其类型就是 std::nullptr_t,不是具体的数字。
6.6.1 空指针的正确使用 #
空指针的“正常”用法是 表示“暂无指向”或作为错误返回值。
int *p_ptr = NULL; // 良好习惯:初始化暂时不用的指针为NULL
if (p_ptr != NULL) { // 在使用前检查
*p_ptr = 42; // 安全的操作
}
许多系统函数在出错时会返回空指针(C 里 NULL 通常是 (void*)0),如果 不检查就解引用,就会……
int *p_bad = NULL;
printf("%d", *p_bad); // 尝试访问空指针指向的内容
结果就是:段错误(Segmentation Fault)!(Windows 下只会未响应,错误码我记得是 AppHang?)程序会崩溃,操作系统会阻止这次非法内存访问。这就是“最终却陷入段错误”。
6.6.2 悬空指针:一个幽灵,陷入虚无的幽灵 #
悬空指针(Dangling Pointer)是指向 曾经有效但现在已经失效的内存地址 的指针。它比空指针更危险。它不一定是 NULL,检查 if (ptr != NULL) 无法排除它,而且 访问它可能不会立即崩溃,但会悄悄地破坏数据,导致不可预知的行为。更糟糕的是,因为错误可能发生在远离指针失效的地方,所以调试是很困难的——这就要求我们养成良好的习惯。
悬挂指针的常见诞生方式:
-
函数返回局部变量的地址
int* create_dangling_pointer() { int local_var = 42; // 局部变量,函数结束时将被销毁 return &local_var; // 错误!返回了一个指向即将失效内存的指针 } // 函数结束,local_var的内存被回收 int main() { int *dangling_ptr = create_dangling_pointer(); // 现在dangling_ptr是悬挂指针 printf("%d", *dangling_ptr); // 未定义行为!可能输出42,也可能输出乱码,或者崩溃 return 0; }这就是经典的“返回局部变量地址”错误。函数栈帧被回收后,那块内存可能被其他数据覆盖。
-
释放内存后未置空
int *ptr = (int*)malloc(sizeof(int)); // 动态分配内存 *ptr = 100; free(ptr); // 释放内存 // 现在ptr就是一个悬挂指针!它指向的内存已被系统回收 // *ptr = 200; // 极度危险!写入已释放的内存(Use-After-Free) // 正确的做法:释放后立即将指针置为NULL ptr = NULL; // 现在它只是一个无害的空指针 -
多个指针指向同一块内存,其中一个释放了它
int *ptr1 = (int*)malloc(sizeof(int)); int *ptr2 = ptr1; // 两个指针指向同一块内存 free(ptr1); // 释放了内存 ptr1 = NULL; // ptr1被正确置空 // 但ptr2对此一无所知!它现在是一个悬挂指针。 // printf("%d", *ptr2); // 危险!要成为“正义的程序员”,就必须遵守指针的安全准则:
-
初始化:总是初始化指针,要么指向有效内存,要么设为 NULL/nullptr。
-
检查:在使用前检查指针的有效性(特别是解引用之前)。但记住,这只能抓住空指针,抓不住悬挂指针。
-
归零:释放内存后(free/delete),立即将指针置为 NULL。这是对付悬挂指针最有效的手段。
-
警惕作用域:永远不要返回局部变量的地址。
-
明确所有权:当多个指针指向同一资源时,必须清楚谁拥有释放它的责任(比如:命名、注释……),一定要避免重复释放或使用已释放的指针。
理解了指针,你就真正踏入了 C/C++编程的殿堂。虽然前路可能充满“段错误”和“内存泄漏”,但只要秉持“正义”之心,你终将驾驭这股强大的力量。
你已经学会了指针了,快来看看这个三重引用的错误吧
完成以下习题:
-
给定一个整数变量
int value = 0xcaffee;,请完成以下任务:-
a) 声明一个指针
ptr并让其指向value。 -
b) 使用指针将
value的值修改为0xcacafefe。 -
c) 分别使用
printf和std::cout打印出ptr本身存储的地址、value的地址,以及通过ptr访问到的值。
-
-
有一个整型数组
int arr[5] = {10, 20, 30, 40, 50};和一个指针int *p = arr;。-
a) 不直接使用
arr[3],而是通过指针p和加法运算,打印出数组中第 4 个元素的值。 -
b) 使用
printf验证(p + 3)的地址值比p的地址值大了多少字节。这个数字和sizeof(int)有什么关系? -
c) 尝试使用
3[p]这种语法来访问数组的第 4 个元素。
-
-
下面的代码片段中存在一些潜在的危险或错误,请找出它们并说明原因。
#include <stdio.h> #include <stdlib.h> int* risky_function() { int local_var = 42; return &local_var; } int main() { int *p1; *p1 = 100; // C++风格应该是int *p = new int; // 如果要赋值就是int *p = new int(42); int *p2 = (int*)malloc(sizeof(int)); *p2 = 200; free(p2); int something_else = 300; printf("%d\n", *p2); int *p3 = risky_function(); printf("%d\n", *p3); return 0; }main里面有 2 处错误,请找出来并指出可能导致的后果。risky_function具体错在哪里?应该如何修改risky_function才能安全地返回一个整数值?
-
下面提供了一个函数
print_array的框架,它使用指针来打印数组的前size个元素。请完成函数中的空白部分。#include <stdio.h> // 函数功能:打印整型数组的元素 // 参数:arr - 指向数组的指针,size - 要打印的元素个数 // const目的是防止被意外修改 // 这里是故意写得很复杂,实际上arr[i]就搞定了 void print_array(const int* arr, const int size) { // 创建一个指针变量p,初始化为指向数组的第一个元素 const int *p = ______; // 空白1 printf("数组元素为: "); // 循环size次 for (int i = 0; i < size; i++) { // 通过指针p打印当前指向的元素 printf("%d ", ______); // 空白2 // 将指针p移动到下一个元素的位置 p = ______; // 空白3 } printf("\n"); } int main() { int test_arr[] = {5, 10, 15, 20, 25}; int size = sizeof(test_arr) / sizeof(test_arr[0]); // 计算数组元素个数 print_array(test_arr, size); // 调用函数打印数组 return 0; }小挑战:
- a) 改写
for循环部分,使用[]语法或者p + ...语法打印内容,而不需要修改p的值; - b) 如果将
main函数中的调用改为print_array(&test_arr[2], 2),预期输出会是什么?实际运行一下验证你的猜想。
- a) 改写
-
编写一个小程序,声明一个
int变量和一个double变量。然后声明一个void*指针,先让其指向int变量并打印出该值,再让其指向double变量并打印出该值。(注意所需的类型转换)