跳过正文

丙加·第6章·你指针所指的方向,是我此生不变的信仰(指针、数组与字符串)

·2533 字·12 分钟·
目录

本章将正式引入指针的概念,并且介绍它与数组的关联;同时也将解释清楚为何 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;

那么,ab 的类型分别是什么呢?

想好了就点开吧

指针的类型有很多,基本上有“基本类型”就有其对应的指针。你甚至可以套娃,声明一个 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 的意思是指向(普通非 constint 类型的 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++ 还可以使用更现代的 newdeletedelete[])对。

下面的例子展示了常用的动态内存分配方式:

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;

*pcount_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]); // 指针也能用下标!

看到了吗,这里 parr 是完全等价的,甚至 [] 语法也完全一致。这也解释了为什么这 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 关键字,"hello" 类型就是朴素是 char [](赋值时会转为 char *),且这个特性一直保留到现在,所以可以直接赋值给之前的 char *ptr;而 C++ 中,hello 类型是 const char*,因此不能降级赋值给 char *ptr。编译器会报告类型转换错误

笨蛋!变量初始化时无法转换‘const char*’为‘double’类型,建议重开谢谢喵~

总之:注意安全。

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 字节)。因为它们本质上存储的都是一个内存地址,而这个地址的长度是由系统架构决定的,与它指向什么类型的数据无关——好比记录门牌的本子是固定的大小,但是对应的房子却不一样大。在你能遇见的大多数情况下,指针与 intlong 一样大。

再看这个 神奇 的现象:

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) 无法排除它,而且 访问它可能不会立即崩溃,但会悄悄地破坏数据,导致不可预知的行为。更糟糕的是,因为错误可能发生在远离指针失效的地方,所以调试是很困难的——这就要求我们养成良好的习惯。

悬挂指针的常见诞生方式:

  1. 函数返回局部变量的地址

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

    这就是经典的“返回局部变量地址”错误。函数栈帧被回收后,那块内存可能被其他数据覆盖。

  2. 释放内存后未置空

    int *ptr = (int*)malloc(sizeof(int)); // 动态分配内存
    *ptr = 100;
    free(ptr); // 释放内存
    
    // 现在ptr就是一个悬挂指针!它指向的内存已被系统回收
    // *ptr = 200; // 极度危险!写入已释放的内存(Use-After-Free)
    
    // 正确的做法:释放后立即将指针置为NULL
    ptr = NULL; // 现在它只是一个无害的空指针
    
  3. 多个指针指向同一块内存,其中一个释放了它

    int *ptr1 = (int*)malloc(sizeof(int));
    int *ptr2 = ptr1; // 两个指针指向同一块内存
    
    free(ptr1); // 释放了内存
    ptr1 = NULL; // ptr1被正确置空
    
    // 但ptr2对此一无所知!它现在是一个悬挂指针。
    // printf("%d", *ptr2); // 危险!
    

    要成为“正义的程序员”,就必须遵守指针的安全准则:

  4. 初始化:总是初始化指针,要么指向有效内存,要么设为 NULL/nullptr。

  5. 检查:在使用前检查指针的有效性(特别是解引用之前)。但记住,这只能抓住空指针,抓不住悬挂指针。

  6. 归零:释放内存后(free/delete),立即将指针置为 NULL。这是对付悬挂指针最有效的手段。

  7. 警惕作用域:永远不要返回局部变量的地址。

  8. 明确所有权:当多个指针指向同一资源时,必须清楚谁拥有释放它的责任(比如:命名、注释……),一定要避免重复释放或使用已释放的指针。

    理解了指针,你就真正踏入了 C/C++编程的殿堂。虽然前路可能充满“段错误”和“内存泄漏”,但只要秉持“正义”之心,你终将驾驭这股强大的力量。


你已经学会了指针了,快来看看这个三重引用的错误吧
完成以下习题:

  1. 给定一个整数变量 int value = 0xcaffee;,请完成以下任务:

    • a) 声明一个指针 ptr 并让其指向 value

    • b) 使用指针将 value 的值修改为 0xcacafefe

    • c) 分别使用 printfstd::cout 打印出 ptr 本身存储的地址、value 的地址,以及通过 ptr 访问到的值。

  2. 有一个整型数组 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 个元素。

  3. 下面的代码片段中存在一些潜在的危险或错误,请找出它们并说明原因。

    #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 才能安全地返回一个整数值?
  4. 下面提供了一个函数 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),预期输出会是什么?实际运行一下验证你的猜想。
  5. 编写一个小程序,声明一个 int 变量和一个 double 变量。然后声明一个 void* 指针,先让其指向 int 变量并打印出该值,再让其指向 double 变量并打印出该值。(注意所需的类型转换)

命令提示符@CommandPrompt-Wang
作者
命令提示符@CommandPrompt-Wang