跳过正文

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

·2535 字·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 中的字符是可修改的,因为它们是栈上的副本。

如果你想看汇编......