# 第一章:C 语言中的数组指针(⭐) ## 1.1 扫清概念 * `数组指针`:当指针变量中存放的是一个数组的首地址的时候,就称该指针变量为指向数组的指针变量,简称为`数组指针`。 > [!NOTE] > > * ① `数组指针`是`指针变量`,即:指向数组的指针变量,而不是数组。 > * ② 如果整型指针,如:`int *p` ,表示的是指向整型数据的指针变量。那么,数组指针,表示的就是指向数组的指针变量。 * `指针数组`:数组可以用来存放一系列相同类型的数据,那么数组也可以用来存放指针,这种用来`存放指针的数组`就被称为`指针数组`。 > [!NOTE] > > * ① `指针数组`是`数组`,用来存放指针的数组。 > * ② 指针数组要求存放在数组中的指针的`数据类型必须一致`。 ## 1.2 数组指针相关的概念 * 数组(Array)就是一系列具有相同类型的数据的集合,每一份数据称为数组元素(Element)。数组中的所有元素在内存中都是连续排列的,整个数组占用的是一块大的内存。以 `int arr[] = {1,2,3,4,5,6};` 为例,该数组在内存中,就是这样的,如下所示: > [!NOTE] > > * ① 为了条理清晰,我并没有画出,数组中的所有元素在内存中是连续排列的;但是,我们需要知道,数组中的所有元素在内存中是连续排列的。 > * ② 当然,从内存地址上,我们可以看出数组中的元素在内存中是连续排列的!!! ![](./assets/1.svg) * 在定义数组的时候,需要给出数据名和数组长度,数组名通常`认为`是一个指针,它指向数组的第 `0` 个元素,如下所示: > [!NOTE] > > * ① 在 C 语言中,我们将第 `0` 个元素的地址称为数组的首地址。 > * ② 数组名的本意是表示整个数组,即:表示多份数据的集合,但是在使用过程中,经常会转换为指向数组第 0 个元素的内存地址(指针),所以上文中使用了`认为`;换言之,数组名和数组的首地址并不总是等价的。 > * ③ 此处,我们可以暂时忽略这个细节,将数组名就当做数组第 `0` 个元素的内存地址(指针)使用,后面再讨论具体细节。 ![](./assets/2.svg) > [!IMPORTANT] > > * ① 为了能够通过指针遍历数组的元素,在`定义数组指针`的时候需要`降维处理`,如:三维数组指针实际指向的数据类型是二维数组,二维数组指针实际指向的数据类型是一维数组,一维数组指针实际指向的数据类型是一个基本数据类型。 > > * ② 在表达式中,数组名也会进行同样的转换!!! ## 1.3 数组元素的遍历 * 使用`传统`的方式(`下标法`)遍历数组中的元素,如下所示: ```c {16} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 使用传统方式遍历数组 for (int i = 0; i < len; i++) { printf("arr[%d] = %d\n", i, arr[i]); } return 0; } ``` * 使用`指针`的方式遍历数组中的元素,如下所示: ```c {18} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 使用指针方式遍历数组 for (int i = 0; i < len; i++) { // arr 是 int* 类型的指针变量,每次 + 1 的时候,自身会增加 sizeof(int) * i // 在使用下标法的时候,C 语言底层会将 arr[i] 转换为 *(arr + i) printf("arr[%d] = %d\n", i, *(arr + i)); // *(arr+i) 等价于 arr[i] } return 0; } ``` * 当然,由于 `arr` 本身就是一个指针,所以我们也可以将其赋值给另一个指针变量 `p`,如下所示: ```c {15} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 将 arr 的地址赋值给 p int *p = arr; // 使用指针方式遍历数组 for (int i = 0; i < len; i++) { printf("arr[%d] = %d\n", i, *(p + i)); } return 0; } ``` > [!NOTE] > > * ① `arr` 是数组第 `0` 个元素的地址,所以 `int *p = arr;` 也可以写成 `int *p = &arr[0];`;换言之,`arr`、`p` 和 `&arr[0]` 是等价的,都是指向数组的第 0 个元素。 > * ② 需要说明的是,“arr 本身就是一个指针”的说法并不是很严谨。严格来说,应该是“arr 被转换为一个指针”,请暂时忽略这个细节。 > * ③ `数组名`和普通的`指针变量`不同,它是一个`常量`指针,意味着它的值(指向的地址)是固定的,不能被修改。 ## 1.4 数组指针的类型 * 如果一个`指针变量`指向了数组,我们就称它是`数组指针`。 > [!NOTE] > > * ① 通常情况下,数组指针指向的是数组中第 0 个元素。 > * ② 但是也未必,数组指针可以指向数组中的任意一个具体的元素。 * 如果是一个普通的变量,我们可以知道它的类型是什么,如下所示: ```c int num = 10; // 普通变量 num 的类型是 int 类型 char c = 'a'; // 普通变量 c 的类型是 char 类型 ``` * 那么,同样的道理,我们也可以知道一个数组指针(指针变量)的类型是什么,如下所示: ```c int arr[] = {1,2,3,4,5}; int *p = arr; // 指针变量 p 的类型是 int* ``` * 反过来想,对于一个指针变量 p 而言,它并不清楚它指向的是一个数组,还是一个整型的变量,如下所示: ```c {5-6} int num = 10; int arr[] = {1,2,3,4,5} int *p = NULL; p = arr; p = # ``` * 对于指针变量 p 而言,怎么使用,取决于程序员的编码,如下所示: ```c {19} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 将 arr 的地址赋值给 p int *p = arr; // 使用指针方式遍历数组 for (int i = 0; i < len; i++) { printf("arr[%d] = %d\n", i, *(p + i)); } return 0; } ``` > [!NOTE] > > * ① 数组的元素在内存中只是简单的排列,没有开始标识,也没有结束标识。所以,在获取数组长度的时候,并不可以使用 `sizeof(p) / sizeof(int)`,因为指向变量 `p` 就是一个指向 `int` 类型的指针,它的类型就是 `int*`,它并不清楚自己指向的是数组还是整数,而 `sizeof(p)`获取的是指针变量 `p`本身占用的字节数,在 `32` 位操作系统上是 `4` 个字节,在 `64` 位操作系统上是 `8` 个字节, 并不是数组中所有元素占用的字节数。 > * ② 换言之,数组指针不能逆推出整个数组元素的个数,以及数组从哪里开始、到哪里结束等信息。不像字符串,数组本身也没有特定的结束标志,如果不知道数组的长度,那么就无法遍历整个数组。 > * ③ 数组名是常量,它的值不能改变,而数组指针是变量(除非特别指明它是常量),它的值可以任意改变,即:数组名只能指向数组的开头,而数组指针可以先指向数组开头,再指向其他元素。 ## 1.5 指针带下标的使用 * 指向数组元素的指针变量也可以带下标,如:`p[i]` 就会在底层转换为 `*(p+i)`。如果指针变量 `p` 指向数组的第 `0` 个元素 `arr[0]`,则 `p[i]` 就代表 `arr[i]`。 > [!WARNING] > > * ① 必须搞清楚,指针变量 `p` 当前指向数组中的哪个元素?如果指针变量 `p` 指向 `arr[3]`,那么 `p[2]` 并不是代表 `arr[2]`,而是代表 `arr[3+2]`,即 `arr[5]`。 > * ② 在实际开发中,强烈建议将指针变量 `p` 指向数组中的第 `0` 个元素!!! * 示例: ```c {14,18} #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); // 定义数组 int arr[] = {1, 2, 3, 4, 5}; // 获取数组长度 int len = sizeof(arr) / sizeof(int); // 定义指针变量指向数组的第 0 个元素 int *p = arr; // 遍历数组 for (int i = 0; i < len; ++i) { printf("arr[%d] = %d\n", i, p[i]); } return 0; } ``` ## 1.6 数组遍历大总结 * 对于数组而言,可以根据`下标法`和`指针法`来遍历元素,如下所示: ![](./assets/3.svg) > [!NOTE] > > 通常而言,数组名通常`认为`是一个指针,它指向数组的第 `0` 个元素;并且,上图中的指针变量 `p`也指向数组的第 `0` 个元素,那么: > > * 对于数组元素 `arr[0]` 而言: > * 其`内存地址`可以这样表示:`&arr[0]`、`arr`、`p`。 > * 其`值`可以这样表示:`arr[0]`、`*arr`、`*p`。 > * 对于数组元素 `arr[1]` 而言: > * 其`内存地址`可以这样表示:`&arr[1]`、`arr+1`、`p+1`。 > * 其`值`可以这样表示:`arr[1]`、`*(arr+1)`、`*(p+1)`。 > * ... > * 对于数组元素 `arr[i]` 而言: > * 其`内存地址`可以这样表示:`&arr[i]`、`arr+i`、`p+i`。 > * 其`值`可以这样表示:`arr[i]`、`*(arr+i)`、`*(p+i)`。 * 示例:下标法 ```c {16} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 使用传统方式遍历数组 for (int i = 0; i < len; i++) { printf("arr[%d] = %d\n", i, arr[i]); } return 0; } ``` * 示例:指针法 ```c {17} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 使用指针方式遍历数组 for (int i = 0; i < len; i++) { // arr 是 int* 类型的指针变量,每次 + 1 的时候,自身会增加 sizeof(int) * i printf("arr[%d] = %d\n", i, *(arr + i)); // *(arr+i) 等价于 arr[i] } return 0; } ``` * 示例:指针法 ```c {15,19} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 将 arr 的地址赋值给 p int *p = arr; // 使用指针方式遍历数组 for (int i = 0; i < len; i++) { printf("arr[%d] = %d\n", i, *(p + i)); } return 0; } ``` * 示例:指针法 ```c {15,19} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 将 arr 的地址赋值给 p int *p = arr; // 使用指针方式遍历数组 for (int i = 0; i < len; i++) { printf("arr[%d] = %d\n", i, *p++); } return 0; } ``` * 示例:指针法 ```c {11,18} #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); // 定义数组 int arr[] = {1, 2, 3, 4, 5}; // 获取数组长度 int len = sizeof(arr) / sizeof(int); // 定义指针变量指向数组的第 0 个元素 int *p = arr; // 遍历数组 for (int i = 0; i < len; ++i) { printf("arr[%d] = %d\n", i, p[i]); } return 0; } ``` ## 1.7 数组指针的不同写法 * 如果指针变量 p 是指向数组 arr 中第 n 个元素的指针,那么 `*p++` 、`*++p`以及 `(*p)++` 分别是什么意思? * ① `*p++` 等价于 `*(p++)`,表示先获取第 `n` 个元素的值,再将 p 指向下一个元素。 * ② `*++p` 等价于 `*(++p)`,会先进行 `++p` 运算,使得 p 的值增加,指向下一个元素,整体上相当于 `*(p+1)`,所以会获得第 `n+1` 个数组元素的值。 * ③ `(*p)++` 就非常简单了,会先取得第 `n` 个元素的值,再对该元素的值加 1。如果 p 指向第 0 个元素,并且第 0 个元素的值为 99,执行完该语句后,第 0 个元素的值就会变为 100。 > [!NOTE] > > 在实际开发中,`*p++` 用的居多!!! # 第二章:C 语言中的字符串指针(⭐) ## 2.1 回顾字符串 * 在实际开发中,我们除了经常处理整数、浮点数、字符等,还经常和字符串打交道,如:`"Hello World"`、`"Hi"` 等。 > [!NOTE] > > 像这类`"Hello World"`、`"Hi"`等格式 ,使用`双引号`引起来的一串字符称为字符串字面值,简称字符串。 * 对于整数、浮点数和字符,C 语言中都提供了对应的数据类型。但是,对于字符串,C 语言并没有提供对应的数据类型,而是用`字符数组`来存储这类文本类型的数据,即字符串: ```c char str[32]; ``` * 字符串不像整数、浮点数以及字符那样有固定的大小,字符串是不定长的,如:`"Hello World"`、`"Hi"` 等的长度就是不一样的。在 C 语言中,规定了字符串的结尾必须是 `'\0'` ,这种字符串也被称为 `C 风格的字符串`,如: ```c "Hello World!" // 在 C 语言中,底层存储就是 Hello World!\0 ``` * 其对应的图示,如下所示: ![](./assets/4.png) * 在 C 语言中定义字符串有两种方式。其中,标准写法是手动在字符串的结尾添加 `'\0'`作为字符串的结束标识。 ```c {4} // 字符数组,不是字符串 char c1[] = {'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd'}; // C 风格的字符串 char c2[] = {'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd', '\0'}; ``` * 在 C 语言中定义字符串有两种方式。其中,简化写法就是双引号中的字符,会自动视为字符数组。 > [!IMPORTANT] > > * ① 因为字符串写成数组的形式,非常麻烦,所以 C 语言就提供了一种简化写法;但是,这种简化写法仅限于初始化。 > * ② 简化写法会自动在末尾添加 `'\0'` 字符,强烈推荐使用!!! ```c {1-2} char c1[] = {"Hello World"}; // 注意使用双引号,非单引号 char c2[] = "Hello World"; // 可以省略一对 {} 来初始化数组元素 ``` ## 2.2 字符数组和字符串指针的区别 ### 2.2.1 概述 * 因为`字符串`本质上就是`字符数组`,我们可以`下标法`和`指针法`来遍历字符串中的字符。 * 示例:下标法 ```c {9,16} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char str[] = "Hello World"; // 获取数组的长度 size_t len = strlen(str); // 使用下标法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", str[i]); } return 0; } ``` * 示例:指针法 ```c {9,16} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char str[] = "Hello World"; // 获取数组的长度 size_t len = strlen(str); // 使用指针法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", *(str + i)); } return 0; } ``` * 示例:指针法 ```c {9,15,19} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char str[] = "Hello World"; // 获取数组的长度 size_t len = strlen(str); // 定义指针变量 char *p = str; // 使用指针法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", *(p + i)); } return 0; } ``` * 示例: ```c {9,15,19} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char str[] = "Hello World"; // 获取数组的长度 size_t len = strlen(str); // 定义指针变量 char *p = str; // 使用指针法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", *p++); } return 0; } ``` * 但是,除了字符数组之外,C 语言还支持另外一种表示字符串的方式,就是直接使用一个`指针变量`指向`字符串`,如下所示: ```c char *str = "Hello World"; ``` > [!IMPORTANT] > > * ① 字符串中的所有字符在内存中是连续排列的,`str` 指向了字符串的第 `0` 个字符。我们通常将第 `0` 个字符的地址称为字符串的首地址。字符串中的每个字符的类型都是 `char` ,所以 `str` 的类型就是 `char*` 。 > * ② 普通的数组可没有这种写法!!! * 我们也可以`下标法`和`指针法`来遍历字符串中的字符。 * 示例: ```c {9,16} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char *str = "Hello World"; // 获取数组的长度 size_t len = strlen(str); // 使用下标法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", str[i]); } return 0; } ``` * 示例: ```c {9,16} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char *str = "Hello World"; // 获取数组的长度 size_t len = strlen(str); // 使用指针法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", *(str + i)); } return 0; } ``` * 示例: ```c {9,15,19} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char *str = "Hello World"; // 获取数组的长度 size_t len = strlen(str); // 定义指针变量 char *p = str; // 使用指针法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", *(p + i)); } return 0; } ``` * 示例: ```c {9,15,19} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char *str = "Hello World"; // 获取数组的长度 size_t len = strlen(str); // 定义指针变量 char *p = str; // 使用指针法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", *p++); } return 0; } ``` > [!NOTE] > > * ① 上述的种种现象,都给人一种错觉:`字符串指针`指向的`字符串`和`字符数组`没什么区别,都可以使用 `%s` 来输出整个字符串,都可以使用 `[]`(下标法)或 `*`(指针法)来获取单个字符。 > * ② 难道`字符串指针`指向的`字符串`和`字符数组`之间,真的一点区别都没有吗? ### 2.2.2 字符数组和字符串指针所指向的字符串绝不一样 * 在 C 语言中,不同类型的数据,在内存中的存储区域是不一样的,如下所示: > [!NOTE] > > 下图是 Linux 下 32 位环境的用户空间的内存分布情况。 ![](./assets/5.svg) > [!IMPORTANT] > > 对于`全局数据区`和`栈区`而言,是有`读取权限`和`写入权限`的,而对于`常量区`而言,只有`读取权限`,却没有`写入权限`。 * `字符数组`在内存中存储在`全局数据区`或`栈区`,而`字符串指针`所指向的`字符串`在内存中存储在`常量区`。 > [!IMPORTANT] > > * ① 内存权限的不同导致了一个非常明显的结果,那就是字符数组在定义后,是可以读取和修改每个字符。 > * ② 而对于字符串常量,一旦被定义就只能读取而不能修改,任何试图对它的赋值都是错误的。 * 换言之,如果针对字符串常量,进行修改,程序将会报错,如下所示: ```c {12,15} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义字符串 char *str = "Hello World"; // 正确,可以改变指针变量本身的指向 str = "abc"; // 错误,因为不能修改字符串常量中的字符 str[0] = 'a'; // [!code error] // 获取数组的长度 size_t len = strlen(str); // 定义指针变量 char *p = str; // 使用指针法,遍历字符串 for (int i = 0; i < len; i++) { printf("%c", *(p + i)); } return 0; } ``` ### 2.2.3 实际开发中,如何选择? * 在实际开发中,如果涉及到`只`对字符串进行`读取`,那么`字符数组`和`字符串常量`都是能够满足要求的。 * 但是,如果有`写入(修改)`操作操作,那么就只能使用`字符数组`,而不能使用`字符串`了。 > [!NOTE] > > 获取用户输入的字符串就是一个典型的写入操作,只能使用字符数组,而不能使用字符串常量。 * 示例: ```c {7} #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); char str[30] = {'\0'}; printf("请输入字符串:"); gets(str); printf("%s\n", str); return 0; } ``` ### 2.2.4 总结 * C 语言有两种表示字符串的方法: * ① 字符数组。 * ② 使用字符串指针指向的字符串常量。 * 正是由于在内存中的`存储位置`不同,使得`字符数组`和`字符串指针`指向`字符串常量`有很大区别,即: * `字符数组`可以`读取`和`修改`。 * `字符串指针`指向的`字符串常量`却只能`读取`,并不能`修改`。 # 第三章:C 语言中的二级指针(⭐) ## 3.1 概述 * 在 C 语言中,指针变量只能保存内存地址。那么,指针变量是可以指向普通类型的数据,即:一级指针,如: ```c {3} int num = 10; int *p = # // 表示 p 指针所指向的是一个 int 类型的数据,简称 int 指针 ``` ```c {3} char c = 'a'; char *p = &c; // 表示 p 指针所指向的是一个 char 类型的数据,简称 char 指针 ``` ```c {3} double d = 3.14; double *p = &d; // 表示 p 指针所指向的是一个 double 类型的数据,简称 double 指针 ``` * 其内存简图,就是这样的,如下所示: ![](./assets/6.svg) * 一级指针也是变量,也有自己的内存地址,并且指针变量只能保存内存地址,所以指针变量也可以指向一级指针,即:二级指针,如下所示: ```c {5} int num = 10; int *p = # // 表示 p 指针所指向的是一个 int 类型的数据,简称 int 指针 int **pp = &p; // 表示 pp 指针所指向的是一个 int* 类型的数据,简称 int 二级指针 ``` ```c {5} char c = 'a'; char *p = &c; // 表示 p 指针所指向的是一个 char 类型的数据,简称 char 指针 char **pp = &p; // 表示 pp 指针所指向的是一个 char* 类型的数据,简称 char 二级指针 ``` ```c {5} double d = 3.14; double *p = &d; // 表示 p 指针所指向的是一个 double 类型的数据,简称 double 指针 double **pp = &p; // 表示 pp 指针所指向的是一个 double* 类型的数据,简称 double 二级指针 ``` * 其内存简图,就是这样的,如下所示: ![](./assets/7.svg) ## 3.2 语法 * 二级指针的语法: ```c 数据类型 **指针变量名; ``` > [!NOTE] > > * ① 二级指针就是一个指针变量的值是另外一个指针变量的内存地址。 > * ② 通俗的讲,二级指针就是指向指针的指针。 > * ③ 针变量也是一种变量,也会占用存储空间,也可以使用`&`获取它的地址。C 语言不限制指针的级数,每增加一级指针,在定义指针变量时就得增加一个星号`*`。换言之,一级指针在定义的时候,有一个 `*` ,二级指针在定义的时候,有两个 `*` 。 > * ④ 在实际开发中,会经常使用一级指针和二级指针,几乎用不到高级指针。 * 示例:定义二级指针 ```c {9,12} #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); int num = 10; int *p = # // 一级指针 printf("num = %d\n", *p); // num = 10 int **pp = &p; // 二级指针 printf("num = %d\n", **pp); // num = 10 return 0; } ``` * 示例:定义多级指针 ```c {9,12,15} #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); int num = 10; int *p = # // 一级指针 printf("num = %d\n", *p); // num = 10 int **pp = &p; // 二级指针 printf("num = %d\n", **pp); // num = 10 int ***ppp = &pp; // 三级指针 printf("num = %d\n", ***ppp); // num = 10 return 0; } ``` # 第四章:数组和指针并不等价(⭐) ## 4.1 概述 * 通过之前的讲解,相信很多人会认为`数组`和`指针`是等价的,`数组名`表示的是`数组`的`首地址`,如下所示: > [!NOTE] > > 这里所说的`指针`是`指针变量`,而不是`内存地址`。 ```c {15} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(int); // 将 arr 的地址赋值给 p int *p = arr; // 使用指针方式遍历数组 for (int i = 0; i < len; i++) { printf("arr[%d] = %d\n", i, *(p + i)); } return 0; } ``` ## 4.2 数组和指针并不等价 * 但是,上面的看法,在严格意义上是不正确的,数组和指针(指针变量)并不等价,如下所示: > [!NOTE] > > * ① 在大多数情况下,数组名确实会转换会指针(内存地址);但是,并不代表数组就和指针(指针变量)等价。 > * ② 很好理解,当定义完数组之后,数组就不会再变化了,即:在程序运行过程中,数组名就是一个内存地址,不会再发生变化,我们可以将数组名认为是一个常量地址。但是,指针变量是一个变量,而变量是可以变化的。 ```c {15-16,19-20} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 将 arr 的地址赋值给 p int *p = arr; // 计算长度 int lenA = sizeof(arr) / sizeof(int); int lenB = sizeof(p) / sizeof(int); // 打印 printf("lenA = %d\n", lenA); // lenA = 6 printf("lenB = %d\n", lenB); // lenB = 2 return 0; } ``` > [!NOTE] > > * ① 数组是一系列数据的集合,没有开始标识和结束标识,指针变量 `p` 仅仅是一个指向 `int` 类型的指针,编译器不会知道它到底指向的是一个整数还是一堆整数,对 `p` 进行 `sizeof` 运算,获取的只是指针变量本身在内存空间占据的字节数,如:在 `32` 位操作系统是 `4` 字节,在 `64` 位操作系统是 `8` 字节,所以 `lenB = 8 ÷ 4 = 2` 。换言之,编译器并没有将指针变量 `p` 和`数组`关联起来,`p` 仅仅代表是一个指针变量,不管它指向哪里,对其进行 `sizeof` 运算,获取的永远是它本身在内存空间占据的的字节数。 > * ② 从编译器的角度而言,数组名、变量名就是一种符号而已,它们最终都要和数据绑定在一起的。变量名就是用来代表内存空间中的一份数据,而数组名就是用来代表内存空间中的一组数据或者数据的结合,它们都是有类型的,以便推断出所指向的数据在内存空间中占据的字节数。 >[!IMPORTANT] > >总结:数组就是有类型的,如果我们将 `int`、`float`、`char` 等理解为基本数据类型,那么数组就可以理解为由基本数据类型派生得到的稍微复杂一些的数据类型,`sizeof` 就是根据`符号的类型`来计算长度的。 * 对于数组 `arr` 而言,它的类型是 `int[6]`,表示是一个拥有 `6` 个 `int` 数据的集合,`1` 个 int 类型的长度是 `4` ,`6` 的 `int` 的长度就是 `6 × 4 = 24` 。所以,通过 `sizeof` 就可以很容易的获取到 `arr` 的长度。 ```c int arr[] = {1, 2, 3, 4, 5, 6}; ``` * 对于指针变量 `p` 而言,它的类型是 `int *`,在 `32` 位操作系统是 `4` ,在 `64` 位操作系统是 `8` 。 ```c int *p = NULL; ``` > [!NOTE] > > * ① `p` 和 `arr` 的符号的类型不同,所代表的数据也不是同的,而 `sizeof` 是根据符号的类型来获取长度的,`p` 和 `arr` 的类型不同,当然获取的长度也是不一样的。 > > * ② 同样的道理,就二维数组而言,如:`int arr[3][3] = {1, 2, 3, 4, 5, 6, 7, 8, 9};`,它的类型就是 `int[3][3]`,长度就是 `4 × 3 × 3 = 36` 。 ## 4.3 从编译器的角度看问题 * 编程语言的目的就是为了将`计算机指令(机器语言)`抽象为人类能够理解的`自然语言`,让程序员能够更加容易,去管理和操作计算机中的各种资源,如:CPU、内存、硬盘等,这些`计算机资源`最终表现为`编程语言`中的各种`符号`和`语法规则`。 * `编译器`在`编译过程`中会维护一种`数据结构`,通常称为`符号表`,如下所示: | 变量名称 | 数据类型 | 大小(字节) | 内存地址 | 作用域 | 初始值 | | ---------- | -------- | ------------ | ------------ | -------------- | -------------------------------------- | | `intVar` | `int` | 4 | `0x7ffeabc0` | 局部(函数内) | `10` | | `floatVar` | `float` | 4 | `0x7ffeabc4` | 局部(函数内) | `3.14` | | `arrayVar` | `int[5]` | 20 | `0x7ffeabc8` | 全局 | `{1,2,3,4,5}` | | `ptr` | `int*` | 8 | `0x7ffeabcc` | 局部(函数内) | `0x7ffeabc8`(指向 `arrayVar` 的地址) | > [!NOTE] > > * ① 符号表用于存储程序中所有标识符的信息,包括:变量名、函数名、类型、作用域以及在内存中的地址等。 > * ② 在编译过程中,当编译器遇到一个标识符时,它会查找符号表,以确认这个标识符的属性。这使得程序员在编写代码时可以使用易于理解的名字,而不需要关心底层的内存地址和数据布局。 > * ③ `sizeof` 操作符通过查询符号表,能够获取到标识符所对应的数据类型的大小(以字节为单位)。因此,程序员只需使用 `sizeof` 来获取数据类型的大小,而不必自己去计算或查找其在内存中的占用。 > [!IMPORTANT] > > * ① 符号表在编译器中起到桥梁的作用,使得高级语言的抽象能够有效地转化为底层机器能够理解的指令和内存布局。 > * ② 这种机制提高了编程的效率和安全性,让程序员能够专注于逻辑而非内存管理。 > [!NOTE] > > * ① 但是,和普通变量名相比,数组名就既有“一般性”,也有“特殊性”。 > * ② 所谓的“一般性”,就是数组名和普通变量类似,是用来代替特定的内存空间的存储区域,有自己的类型和长度,如:`int arr[5] = {1, 2, 3, 4, 5};` 中的 `arr` 就是一个`数组名`,它的类型是 `int[5]` ,它的长度是 `5 × 4 = 20` 。 > * ③ 所谓的“特殊性”,就是数组名在大多数情况下会被隐式转换为指向其第一个元素的指针,也被称为“数组到指针的衰退”,这就意味着:数组名本身并不是直接表示数组的所有值,而是指向数组第一个元素的地址。 # 第五章:数组到底在什么时候会转换为指针(⭐) ## 5.1 C 语言编译器的处理过程 * C 语言编译器在编译代码的时候,其步骤大致分为`词法分析`、`语法分析`、`语义分析`、`中间代码生成`、`优化`、`目标代码生成`、`汇编与链接`,如下所示: ![](./assets/8.svg) * C 语言在语义分析阶段,会将变量名、类型、作用域等信息存储在符号表中,如下所示: | 变量名称 | 数据类型 | 大小(字节) | 内存地址 | 作用域 | 初始值 | | :--------- | :------- | :----------- | :----------- | :------------- | :------------------------------------- | | `intVar` | `int` | 4 | `0x7ffeabc0` | 局部(函数内) | `10` | | `floatVar` | `float` | 4 | `0x7ffeabc4` | 局部(函数内) | `3.14` | | `arrayVar` | `int[5]` | 20 | `0x7ffeabc8` | 全局 | `{1,2,3,4,5}` | | `ptr` | `int*` | 8 | `0x7ffeabcc` | 局部(函数内) | `0x7ffeabc8`(指向 `arrayVar` 的地址) | * 当编译器遇到`数组`参与的`表达式计算`或作为`函数参数`的时候,会进行以下的操作: * ① 查符号表:编译器会通过符号表查找到这个标识符(数组名)对应的类型和内存地址。 * ② 识别数组类型:编译器识别出这是一个数组,而不是普通变量。 * ③ 自动转换:据语言规则,编译器会在`特定的场景`下自动将`数组名`转换为一个指向数组第一个元素的`指针`。 > [!IMPORTANT] > > * ① C 语言标准规定:当数组名作为数组定义的标识符,即:定义或声明数组时,遇到 `sizeof` 运算符或 `&` 操作符的时候,数组名代表整个数组;否则,数组名会被转换为指向第 `0` 个元素的指针(地址)。 > * ② C 语言标准规定,作为“类型的数组”的形参应该调整为“类型的指针”。在函数形参定义这个特殊情况下,编译器必须把数组形式改写成指向数组第 `0` 个元素的指针形式。编译器只向函数传递数组的地址,而不是整个数组的拷贝。 ## 5.2 数组名转换为指针 ### 5.2.1 概述 * 在大多数情况下,C 语言的编译器会自动将数组名转换为指向数组第 `0` 个元素的指针,这个过程也被称为“数组到指针的衰退”。 * C 语言编译器,会在如下的场景下,自动将`数组名`转换为指向数组第 `0` 个元素的`指针`: * ① `数组名作为函数的参数`。 * ② `数组名参与表达式计算`。 ### 5.2.2 数组名作为函数的参数 * 当我们将`数组名`作为`参数`传递给`函数`的时候,编译器会自动将其转换为指向该数组第 `0` 个元素的指针。 > [!IMPORTANT] > > * ① 数组名作为函数参数的时候,我们需要事先计算好数组的长度,并和数组名一起作为参数传递给函数作为形参。 > * ② 否则,由于数组名在作为函数形参的时候,会自动退化为指针;当使用 `sizeof(数组名)` 的时候,获取的不是数组所有元素在内存中占据的存储单元,而是指针在内存中占据的存储单元,将不能正确的遍历数组中的所有元素。 * 示例: ```c {25} #include #include void print(int arr[], int len) { // arr 的长度是 = 8,64 位机器的指针长度是 8 printf("arr 的长度是 = %zu\n", sizeof(arr)); for (int i = 0; i < len; ++i) { printf("%d ", arr[i]); } } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[6] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(arr[0]); // 打印数组 print(arr, len); return 0; } ``` ### 5.2.3 数组名参与表达式计算 * 在大多数情况下,`数组名`参与`表达式计算`时,编译器也会将其转换为指向数组第 `0` 个元素的指针。 * 示例: ```c {12,15} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[6] = {1, 2, 3, 4, 5, 6}; // 数组名参与表达式计算,赋值也属于计算 int *p = arr; // arr 会自动转换为指针,等同于 &arr[0] // 数组名参与表达式计算,加减也属于计算 int *p2 = arr + 1; // // arr 会自动转换为指针,等同于 &arr[1] printf("arr[0] = %d\n", *p); // arr[0] = 1 printf("arr[1] = %d\n", *p2); // arr[1] = 2 return 0; } ``` ## 5.3 特殊情况不转换 ### 5.3.1 概述 * 当数组名作为数组定义的标识符,即:定义或声明数组时,遇到 `sizeof` 运算符或 `&` 操作符的时候,数组名代表整个数组,不会自动转换为指向数组第 `0` 个元素的指针。 ### 5.3.2 sizeof 运算符 * 在使用 `sizeof`运算符时,数组名不会自动转换为指针,而是表示整个数组。 > [!IMPORTANT] > > `sizeof` 返回的是整个数组的大小(元素个数 × 单个元素大小)。 * 示例: ```c {12} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[6] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(arr[0]); // 打印数组的长度 printf("len = %d\n", len); // len = 6 return 0; } ``` ### 5.3.3 & 操作符 * 在使用 `&`运算符时,数组名不会自动转换为指针。`&arr` 的结果是指向整个数组的指针,而不是指向第 `0` 个元素的指针。 * 示例: ```c {11} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义普通数组 int arr[6] = {1, 2, 3, 4, 5, 6}; int(*p)[6] = &arr; // p 指向整个数组 // 通过指针访问数组元素 for (int i = 0; i < 6; i++) { printf("%d ", (*p)[i]); } return 0; } ``` # 第六章:数组名为什么需要转换为指针?(⭐) ## 6.1 概述 * 在 C 语言中,函数的参数不仅仅可以是整数、小数、字符等具体的数据,如下所示: ```c {1,10} int add(int a,int b){ ... } int main(){ int a = 10; int b = 20; add(a,b); return 0; } ``` * 还可以是指向它们的指针,如下所示: ```c {1,10} int add(int* a,int* b){ ... } int main(){ int a = 10; int b = 20; add(&a,&b); return 0; } ``` > [!NOTE] > > * ① 用`指针变量`作为`函数参数`将函数外部`变量的地址`传递给函数内部,使得在函数内部就可以操作函数外部的数据,并且这些数据不会随着函数的结束而被销毁。 > * ② 诸如`数组`、`字符串`、`动态分配的内存`都是一系列的集合,是没有办法通过一个参数将其直接传入函数内部的,只能传递它们的指针,以便在函数内部通过指针来操作这些数据集合(至于为什么,看下文)。 ## 6.2 交换两个变量 ### 6.2.1 概述 * 有的时候,对于整数、小数、字符等基本数据类型的操作可能也需要借助指针,最为典型的例子就是交换两个变量了。 > [!NOTE] > > * ① 如果你学过 Java 等语言,一定会明白,如果要在函数中交换两个变量,最为有效的方案就是传递引用。 > > * ② 其实,在 C 语言中,也是类似的,只不过 C 语言更为直接而已,传递指针。 ### 6.2.2 借助函数来交换两个变量的值 * 为了程序的通用性,我们想到最为有效的方案可能就是借助函数了,如下所示: ```c {9-14,21-22,28} #include #include /** * 交换两个变量的值 * @param a * @param b */ void swap(int a, int b) { int temp = 0; a = temp; b = a; a = b; } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义两个变量 int a = 10; int b = 20; // 输出结果 printf("a = %d, b = %d\n", a, b); // a = 10, b = 20 // 调用交换函数 swap(a, b); // 输出结果 printf("a = %d, b = %d\n", a, b); // a = 10, b = 20 return 0; } ``` * 从结果很容易得就可以看出,变量 a 和变量 b 内部报错的值并没有发生改变,导致交换失败。在 C 语言中,源代码在经过编译器编译之后会产生可执行文件,而这个可执行文件中的代码会被加载进内部的不同区域,如下所示: ![](./assets/9.svg) > [!NOTE] > > * ① 在 C 语言程序运行的时候,通常会将代码加载进`程序代码区`,该部分是用来存储`程序的机器指令`,即编译后的代码,这个区域通常是只读的,就是防止程序在运行的时候修改自己的代码。 > * ② 在 C 语言程序运行的时候,`全局数据区`是用来存储`全局变量`或`静态变量`。 > * ③ 在 C 语言程序运行的时候,`栈`通常是用于`函数调用`时的`局部变量`、`函数参数`以及`返回地址`等数据。 > * ④ 在 C 语言程序运行的时候,`堆`通常是用于`动态内存分配`,如:`malloc`、`calloc`、`realloc` 函数分配的内存。 * 其中,`栈`的特点是`先进后出`,如下所示: ![](./assets/10.gif) > [!IMPORTANT] > > * ① 函数在`调用的时候`就是`入栈`,而函数`调用完毕之后`就是`出栈`。 > * ② 当一个函数被调用时,程序会将当前的执行状态(`返回地址`、`局部变量`和`参数(形式参数,形参)`)压入调用栈中。这通常涉及: > - 保存`返回地址`,以便函数执行完毕后可以返回到正确的位置。 > - 分配空间以存储函数的`局部变量`和`参数(形式参数,形参)`。 > * ③ 当函数执行完毕后,程序会从调用栈中弹出先前保存的状态。这通常涉及: > * 恢复`返回地址`,以便继续执行被调用函数后的代码。 > * 释放为`局部变量`和`参数(形式参数,形参)`分配的栈空间。 > * ④ 总体而言,入栈是为了保存函数调用的上下文,而出栈则是为了恢复这些上下文以继续执行程序。 > [!NOTE] > > `返回地址`是在程序中用于确保函数执行完毕后能够正确返回调用位置的一个重要概念。它表示的是程序在调用函数之后,下一条需要执行的指令的地址。 > > * ① 当我们调用一个函数时,程序需要暂停当前的执行并跳转到函数的代码中。为了能在函数执行完后,能回到之前调用的地方继续执行,就需要记录下调用该函数的那条指令的`下一条指令的地址`,这就是`返回地址`。 > * ② `保存返回地址`:在函数调用时,系统会将调用函数的指令地址压入调用栈(通常是函数调用的下一条指令的位置),这样在函数执行完毕后,程序知道从哪里继续执行。 > * ③ `恢复返回地址`:当函数执行完毕,程序会从栈中弹出保存的返回地址,并跳转到这个地址,继续执行调用函数后的代码。 > > 总结:`返回地址`就是为了让程序能够正确返回到调用函数的位置,而不至于在执行函数后迷失方向。 * 对于上述的代码,我们将其标注一下,如下所示: ```c {9-14,21-22,28} #include #include /** * 交换两个变量的值 * @param a * @param b */ void swap(int a, int b) { // 在栈中 int temp = 0; // 在栈中 a = temp; // 在栈中 b = a; // 在栈中 a = b; // 在栈中 } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义两个变量 int a = 10; // 在全局数据区 int b = 20; // 在全局数据区 // 输出结果 printf("a = %d, b = %d\n", a, b); // a = 10, b = 20 // 调用交换函数 swap(a, b); // 输出结果 printf("a = %d, b = %d\n", a, b); // a = 10, b = 20 return 0; } ``` * 那么,其在内存中就是这样的,如下所示: ![](./assets/11.svg) > [!NOTE] > > * ① 也许,你会感觉困惑? `swap()` 函数内部的`变量 a` 、`变量 b` 和 `main()` 函数内部的`变量 a` 、`变量 b`都是在栈中的,应该一样的啊。其实不然,而是当执行到 `main()` 函数或 `swap()` 函数的时候,会在栈中由系统为它们开辟自己的内存空间,并在栈中对应的内存空间中创建各自的变量。 > * ② `swap()` 函数内部的`变量 a` 、`变量 b` 和 `main()` 函数内部的`变量 a` 、`变量 b` 是不同的变量,在内存中的位置是不一样的,它们之间除了名字一样,没有任何关联。 > * ③ `swap()` 函数交换的是其内部`变量 a` 和`变量 b` 的值,并不会影响 `main()` 函数内部的`变量 a` 和`变量 b` 的值。 ### 6.2.3 在函数中借助指针来交换变量的值 * 如果我们改用`指针`作为函数的`参数`,就可以很容易实现:在函数中的交换两个变量的值,如下所示: ```c {9-13,20-21,27} #include #include /** * 交换两个变量的值 * @param a 指针 * @param b 指针 */ void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义两个变量 int a = 10; int b = 20; // 输出结果 printf("a = %d, b = %d\n", a, b); // a = 10, b = 20 // 调用交换函数 swap(&a, &b); // 输出结果 printf("a = %d, b = %d\n", a, b); // a = 20, b = 10 return 0; } ``` > [!NOTE] > > * ① 当我们调用 `swap()` 函数的时候,将 `main()` 函数中`变量 a`的`内存地址`和`变量 b`的`内存地址`分别赋值给 `swap()` 函数的参数`指针变量 a`和`指针变量 b`,并且 `swap()` 函数内部的 `*a` 和 `*b` 就是 `main()` 函数内部的`变量 a` 和`变量 b`。 > * ② 这样,虽然当 `swap()` 函数运行结束之后,会将 `指针变量 a`和 `指针变量 b`销毁;但是,由于`指针变量 a`和`指针变量 b`操作的是`内存地址`,是“持久化”的,并不会随着 `swap()` 函数的结束而“恢复原样”。 ## 6.3 数组作为函数的参数 * 在 C 语言中,数组是一系列数据的集合,无法直接通过`参数`将它们一次性的传递给函数内部。如果需要在函数内部操作数组,则必须传递`数组指针`,如下所示: ```c {4,28} #include #include int max(int *p, int len) { // 假设第 0 个元素是最大值 int max = *p; // 遍历数组,获取最大值 for (int i = 0; i < len; i++, p++) { if (*p >= max) { max = *p; } } // 返回最大值 return max; } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(arr[0]); // 调用函数 int maxValue = max(arr, len); // 打印结果 printf("数组中的最大值为:%d\n", maxValue); // 数组中的最大值为:6 return 0; } ``` * 但是,之前也说过,数组名作为函数的参数,编译器会自动将其转换为指向该数组第 `0` 个元素的指针,如下所示: ```c {4,28} #include #include int max(int arr[], int len) { // 假设第 0 个元素是最大值 int max = arr[0]; // 遍历数组,获取最大值 for (int i = 1; i < len; i++) { if (arr[i] > max) { max = arr[i]; } } // 返回最大值 return max; } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, NULL); // 定义数组 int arr[] = {1, 2, 3, 4, 5, 6}; // 计算数组的长度 int len = sizeof(arr) / sizeof(arr[0]); // 调用函数 int maxValue = max(arr, len); // 打印结果 printf("数组中的最大值为:%d\n", maxValue); // 数组中的最大值为:6 return 0; } ``` > [!NOTE] > > * ① `参数的传递,本质上是一次赋值的过程,所谓的赋值就是对内存的拷贝。而所谓的内存拷贝就是将一块内存上的数据复制到另一块内存上。` > * ② 对于像 int、float、char 这样的基本数据类型的数据,它们占用的内存往往就只有几个字节,对它们进行内存拷贝速度非常快。但是,数组是一系列数据的集合,数量没有任何限制,可以很少,也可能很多,如果数组中元素的个数太多,对它们进行内存拷贝将会是一个漫长的过程,会严重拖慢程序的效率,为了防止技艺不佳的程序员写出低效的代码,C 语言从语法上就禁止数据集合的直接赋值。 > [!IMPORTANT] > > 除了 C 语言,像 C++、Java、Python 等现代化的编程语言,都禁止对大块内存进行拷贝;并且,在底层都使用了类似指针的实现方式!!! # 第七章:C 语言中的野指针(⭐) ## 7.1 概述 * 在 C 语言中,指针变量可以指向计算机中的任何一块内存,不管该内存有没有被分配,也不管该内存是否有权限,只要将内存地址给这个指针变量,该指针变量就可以指向这块内存。 > [!IMPORTANT] > > * ① 其实这就是野指针,即指针指向的位置是不可知( 随机性 , 不正确 , 没有明确限制的 )。 > * ② 换言之,C 语言并没有一种机制来保证指向的内存的正确性,需要程序员自己提高警惕!!! * 许多初学者,无意间会对没有初始化的指针进行操作,非常危险,如下所示: ```c {7,10} #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); char *str; printf("请输入:"); gets(str); printf("%s\n", str); return 0; } ``` * 上述的代码,没有任何语法错误,都能够编译和链接。但是,当用户输入完字符串之后并按下回车键的时候,就会发生错误,其在 Windows 下,会直接程序崩溃,如下所示: ![](./assets/12.gif) * 之前说过,未初始化的局部变量的值是不确定的,C 语言并没有对此作出规定,不同的编译器有不同的实现。其实,在 C 语言中,变量的默认定义是这样的: ```c auto 数据类型 变量名 = 值; // 默认 auto 是缺省的 ``` * 在 C 语言中,不同类型的数据,在内存中的存储区域是不一样的,如下所示: ![](./assets/13.svg) * 上述代码中的 `str` 就是一个没有初始化的局部变量,它的值是不确定的,究竟指向哪块内存是未知的,如果它做指向的内存没有被分配或者没有读写权限,那么使用 `gets()` 函数向它里面写入数据,显然就是错误的。 ## 7.2 如何避免? * 强烈建议对`没有初始化`的`指针`赋值为 `NULL`,如下所示: ```c {1} char *str = NULL; ``` > [!NOTE] > > * ① `NULL` 是零值的意思,在 C 语言中表示空指针,即不指向任何数据的指针,是无效指针,程序使用它并不会产生效果。 > * ② 其实,在 C23 标准之前,`NULL` 是一个宏定义,其定义是:`#define NULL ((void *)0)`,就是用来指代内存中的 `0` 地址。 > * ③ 在 C23 标准中,提供了 `nullptr_t` 类型表示空指针,并加入了 `nullptr` 常量;所以,如果你的项目支持 C23 标准,也可以使用 nullptr 来代替 `NULL`。 * 很多库函数都对传入的指针做了判断,如果是空指针就不做任何操作,或给出对应的提示,如下所示: ```c {7,11} #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); char *str = NULL; printf("请输入:"); gets(str); printf("%s\n", str); return 0; } ``` * 执行结果,如下所示: ![](./assets/14.gif) * 当程序运行后,还没等到用户输入任何字符,程序就结束了,并没有报错。 > [!NOTE] > > * ① `gets()`函数不会让用户输入字符串,也不会向指针指向的内存中写入数据。 > * ② `printf()` 不会读取指针指向的内容,只是简单地给出提示,让程序员意识到使用了一个空指针。 * 所以,我们自己定义的函数中也可以进行类似的判断,如下所示: ```c {2} void func(char *p){ if(p == NULL){ printf("(null)\n"); }else{ printf("%s\n", p); } } ``` ## 7.3 为什么空指针是 NULL,而不是其它? * 上文提过,NULL 是一个宏定义,其具体内容是: ```c #define NULL ((void *)0) ``` * `(void *)0`是将数值 `0` 强制转换为`void *`类型,最外层的`()`把宏定义的内容括起来,防止发生歧义。 > [!NOTE] > > 从整体上来看,`NULL` 指向了地址为 `0` 的内存,而不是前面说的不指向任何数据。 * 在 C 语言中,不同类型的数据,在内存中的存储区域是不一样的,如下所示: ![](./assets/15.svg) > [!NOTE] > > * ① 在虚拟地址中的最低处,有一段内存区域被称为保留区,这个区域既不能存储有效数据的,也不能被用户程序访问,将 NULL 指向这块区域就可以很容易的,被操作系统检测到是违规指针,进而被其停止运行。 > * ② 在大多数操作系统中,极小的地址通常不保存数据,也不允许程序访问,NULL 可以指向这段地址区间中的任何一个地址。 * 需要注意的是,C 语言并没有定义 `NULL` 的指向。不过,大多数的标准库都约定将 `NULL` 指向了 `0` ,不要将 `NULL` 和 `0` 等同起来,如下所示: ```c {2,5} // 不标准 int *p = 0 ; // [!code warning] // 标准写法 int *p = NULL; ``` ## 7.4 野指针的成因 ### 7.4.1 指针使用前未初始化 * 指针变量在定义时如果未初始化, 其值是随机的 ,此时操作指针就是去访问一个不确定的地址,所以结果是不可知的。 * 示例: ```c {7,11] #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); char *str; // [!code warning] printf("请输入:"); gets(str); printf("%s\n", str); return 0; } ``` ### 7.4.2 指针越界访问 * 在使用指针操作或访问数组元素的时候,超出了数组的访问范围。 * 示例: ```c {12} #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); int arr[10] = {0}; int *p = arr; for (int i = 0; i <= 10; i++, p++) { // [!code warning] *p = i; // i=10 时越界 printf("arr[%d] = %d ", i, *p); } return 0; } ``` ### 7.4.3 指向已经释放的空间 * 对于函数中的`局部变量`、`函数参数`以及`函数返回值`等,在调用的时候,存储在栈中。一旦,函数调用结束,这些数据所占据的空间将会被释放。 > [!NOTE] > > * ① 其实,在函数调用完毕之后,这些数据占据的内存空间,未必会立即释放。 > * ② 只是,在函数调用完毕之后,程序已经失去了对这些数据所占据内存空间的访问权限而已!!! * 如果,在函数调用之后,依然通过指针指向这些被释放数据的内存空间,就会导致野指针。 * 示例: ```c {14} #include int *test() { // [!code warning] int num = 10; return # // [!code warning] } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); int *p = test(); printf("num = %d", *p); return 0; } ``` # 第八章:void* 指针(⭐) ## 8.1 概述 * 在 C 语言中,`void` 关键字通常在函数定义中使用,有两个作用: * ① 表示没有返回值:当 `void` 用在函数的返回值类型中的时候,它表示该函数不返回任何值。换言之,函数执行完毕之后不会返回任何结果。 ```c {1} void fun(){ // 该函数没有返回值 } ``` * ② 表示没有参数:在 C 语言中,如果函数没有参数,可以使用 `void` 来明确规定该函数不接收任何参数。当然,也可以省略 `void` ,只不过使用 `void` 更加明确。 ```c {1} int fun(void){ // 这个函数不需要任何参数 return 1; } ``` * 而 `void*` 表示所指针的数据类型是未知的。但是, `void*` 是一个有效的指针,它确实指向实实在在的数据,只是数据的类型尚未确定。因此,我们在操作指针指向的数据时,需要先将 `void*` 转换为适当的类型。 > [!IMPORTANT] > > `void*` 指针的作用: > > * ① **通用性**:`void*` 可以指向任意类型的数据,因此它具有很强的通用性。通过这种方式,可以编写与特定数据类型无关的函数或代码,使代码更加灵活和可扩展。 > * ② **类型转换**:虽然 `void*` 指针可以指向任何类型的数据,但在使用时必须将它转换回具体的数据类型。为了操作它所指向的内存,需要将它强制转换为具体的指针类型。 > * ③ **内存操作**:在很多内存管理操作中,如:动态内存分配(`malloc` 和 `free`),`void*` 被广泛使用。`malloc` 返回的就是 `void*`,因此可以将它转换为任何所需的类型。 > [!NOTE] > > * ① 如果你学过 Java 语言,可能将 `void*` 理解为 `Object` 类,`Object` 类是所有类的父类,一些通用的功能都在 `Object` 类中,如:`toString()` 方法,`getClass()` 方法等。 > * ② 但是,C 语言中的 `void*`是不安全的,因为 `void*` 没有任何类型信息,在使用的时候,如果不能正确的进行类型转换,将对程序的安全性构成危险。 ## 8.2 应用示例 * 需求:使用 C 语言提供的动态分配函数 `malloc()`,申请可以用于存储 `30` 个字符的内存空间。 > [!NOTE] > > `malloc()` 函数的声明是:`void *malloc (size_t __size)` ,即:`void *` 指针作为函数的`返回值`。 * 示例: ```c {8} #include #include int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); char *str = (char *)malloc(sizeof(char) * 30); printf("请输入[0,30]个字符: "); gets(str); printf("你输入的字符是: %s\n", str); return 0; } ``` ## 8.3 应用示例 * 需求:定义一个函数,用来交换两个变量记录的数据,要求具有通用性。 > [!NOTE] > > 函数的参数是 `void*` 类型,并在函数中将参数转换为 `char*` 类型,这样就可以一个字节一个字节的交换,即:`void*` 指针作为函数的`参数`。 * 示例:使用`普通指针`作为函数的`参数类型`,不具有通用性 ```c {8} #include /** * 交换两个变量记录的数据 * @param a * @param b */ void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); int a = 10; int b = 20; // 交换之前:a = 10,b=20 printf("交换之前:a = %d,b=%d\n", a, b); swap(&a, &b); // 交换之后:a = 20,b=10 printf("交换之后:a = %d,b=%d\n", a, b); return 0; } ``` * 示例: 使用`void*` 作为函数的`参数类型`,更具有通用性 ```c {9} #include /** * 交换两个变量记录的数据 * @param a void* 指针类型 * @param b void* 指针类型 * @param len 变量占据内存空间的大小 */ void swap(void *a, void *b, int len) { char *ac = (char *)a; char *bc = (char *)b; for (int i = 0; i < len; ++i) { /* 一个字节一个字节的交换数据 */ char temp = *ac; *ac = *bc; *bc = temp; /* 指针自增 */ ac++; bc++; } } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); int a = 10; int b = 20; // 交换之前:a = 10,b=20 printf("交换之前:a = %d,b=%d\n", a, b); swap(&a, &b, sizeof(int)); // 交换之后:a = 20,b=10 printf("交换之后:a = %d,b=%d\n", a, b); return 0; } ``` * 示例:使用`void*` 作为函数的`参数类型`,更具有通用性 ```c {9} #include /** * 交换两个变量记录的数据 * @param a void* 指针类型 * @param b void* 指针类型 * @param len 变量占据内存空间的大小 */ void swap(void *a, void *b, int len) { char *ac = (char *)a; char *bc = (char *)b; for (int i = 0; i < len; ++i) { /* 一个字节一个字节的交换数据 */ char temp = *ac; *ac = *bc; *bc = temp; /* 指针自增 */ ac++; bc++; } } int main() { // 禁用 stdout 缓冲区 setbuf(stdout, nullptr); char a = 'a'; char b = 'b'; // 交换之前:a = a,b = b printf("交换之前:a = %c,b = %c\n", a, b); swap(&a, &b, sizeof(char)); // 交换之后:a = b,b = a printf("交换之后:a = %c,b = %c\n", a, b); return 0; } ```