本章将正式引入指针的概念,并且介绍它与数组的关联;同时也将解释清楚为何
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中的字符是可修改的,因为它们是栈上的副本。
如果你想看汇编......