经过一段时间的学习,我们已经接触到了C语言的很多知识了。不过目前我们接下来我们要接触C语言中一个最大的“门槛”:指针。
目录
概念:指针指向的位置没有是不可知的(随机的、不正确的、没有限制的)。
PS:这里做这项补充一下之前遗漏的知识点:static和extern
四、当指针变量不再使用的时候,及时地将它们置NULL,指针使用前检查有效性
模拟qsort函数对冒泡排序进行改造,使之实现对所有数据类型的排列
什么是指针?
在介绍指针之前,我们首先要明白变量与地址之间的关系。
举一个生活中的案例:一个宿舍楼内有一百个房间,你就身处其中一个房间内,你的朋友想来找你玩,但是这么多房间一个一个找很麻烦,效率很低。但是如果我们把每个房间编上号码,如101、102……,这样就方便你的朋友找到你了。
而对应到C语言中,内存空间就是这个“宿舍楼”,而想要对内存进行高效的管理就需要“房间”来将内存划分为一个又一个的内存单元,就如同学生宿舍一样,每个内存单元的大小对应一个字节,有8个比特位。一个比特位只能存放一个1或0
内存的储存关系对应关系如上。
每个内存单元都有一个编号(相当于宿舍门牌号),有了这个编号就可以快速找到一个内存单元。生活中我们将门牌号称之为地址,C语言中我们也将内存单元对应的编号称之为地址,而在C语言中这个地址有一个新的名字:指针。因此有内存单元的编号==地址==指针
接下来我们该如何理解编地呢?
前面我们讲过变量创建的本质就是向内存申请空间,就会有属于自己的地址,而&符号就是取出变量所在地址的符号。
如图所示的代码中,变量p与&a实际上是等价的,因此可以称p为指针变量,而int *是p的类型。我们可以得出以下结论
(1)指针就是地址
(2)指针变量是变量,是专门用来存放地址的变量
(3)存放在指针变量中的值,我们认为是地址。
指针的变量的意义
指针的解引用
如下图的代码中,指针变量为怕,而*p就是通过p所在的地址,找到指向的内存空间。实际上*p就是a。
指针的大小
如下图的代码,打印出来的就是分别在X64(64位环境)和X86(32位环境)下指针变量的长度。
在X64环境下的结果
在X86环境下的结果
int 是4个字节、char是1个字节,但是由上图可知,在相同环境下int *与char *的大小相同。
由上图可知,指针变量的长度与类型是无关的。在64位环境下指针变量长度为8;在32位环境下指针变量长度为4。
既然如此,那不同类型的指针意义是什么呢?实际上指针的类型象征着对取地址权限的差异。如int * 可以取4个字节的地址,char * 只可以取出1个字节的地址,就像下面这张图一样。
指针+-整数
指针的类型决定了指针向后访问的“距离”有多大。
以上为代码展示,以下为原理概述。
void * 类型指针
我们知道指针变量都有对应的类型,应用时必须使用对应的类型,使用错误的类型编译器就会报错,但是当对方向我们传递了一个未知类型的指针时我们应该怎么办呢?这时我们就需要用到void *这种特殊类型的指针。void*类型的指针没有具体的类型,又被称之为泛指针,可以用来接受任意类型的指针。但是void * 的指针无法应用于解引用和加减饭。
void * 类型的指针通常应用于函数参数的部分,用来接收不同类型的指针,实现泛性编程的效果,使得一个函数可以处理多种数据。
指针的运算
指针与整数
在之前的练习中,我们尝试过用正常循环的方式打印数组的元素,但是这一次我们将使用指针来打印数组的内容。
结果为:
因为数组的元素是连续存放的,因此当我们得知第一个元素的地址的时候就可以顺藤摸瓜的知道所有的内容了。
指针与指针
指针-指针 的前提:两个指针指向同一个内存空间
指针-指针的结果:两个指针之间的元素个数。
结果为:
指针-指针的应用
结果为:·
拓展内容:size_t
. 定义与头文件
-
类型定义:
size_t
是 无符号整数类型,在C标准中定义为能够表示任何对象的最大大小。 -
头文件:通过以下头文件引入:
#include <stddef.h> // 最常见 #include <stdio.h> // 文件操作相关 #include <stdlib.h> // 内存管理相关 #include <string.h> // 字符串操作相关
2. 核心特性
特性 说明 无符号性 只能表示非负数( 0
和正整数)平台依赖性 具体位数由编译器决定,通常与系统的地址总线宽度一致(如32位系统为32位,64位系统为64位) 最大表示范围 能表示当前平台允许的最大对象大小(如32位系统为 0
到4,294,967,295
)
3. 为什么使用 size_t
?
-
安全性:避免负值(大小或索引不能为负)。
-
可移植性:自动适配不同平台的内存模型。
-
明确语义:代码中清晰表达“大小”或“索引”的意图。
-
兼容性:与标准库函数参数和返回值类型匹配。
-
4. 典型应用场景
(1) 内存分配函数
void* malloc(size_t size); // 分配内存 void* realloc(void* ptr, size_t new_size); // 调整内存大小
示例:
int* arr = (int*)malloc(10 * sizeof(int)); // 10个int的空间
(2) 字符串和数组操作
size_t strlen(const char* str); // 字符串长度 void* memcpy(void* dest, const void* src, size_t n); // 内存拷贝
示例:
char str[] = "Hello"; size_t len = strlen(str); // len = 5
(3) 循环索引
size_t i; for (i = 0; i < buffer_size; i++) { // 处理buffer[i] }
(4) 容器大小
自定义数据结构中表示元素数量:
typedef struct { size_t capacity; // 容器容量 size_t size; // 当前元素数量 int* data; } Vector;
5.
size_t
的底层实现 -
典型映射:
-
32位系统:
typedef unsigned int size_t;
(4字节,范围0
到4,294,967,295
) -
64位系统:
typedef unsigned long size_t;
(8字节,范围0
到18,446,744,073,709,551,615
)
-
-
验证方法:
printf("size_t 的字节数:%zu\n", sizeof(size_t));
6. 注意事项
(1) 避免与有符号数混用
int a = -1;
size_t b = 10;
if (a < b) {
// 条件始终为假,因为a被转换为非常大的无符号数
}
(2) 格式说明符
使用 %zu
输出 size_t
值:
size_t size = 100;
printf("Size: %zu\n", size); // 正确
// printf("Size: %d\n", size); // 错误!可能截断数据
(3) 循环中的递减
// 错误:i可能变为极大值(死循环)
for (size_t i = 5; i >= 0; i--) {}
// 正确:改用逆向循环
for (size_t i = 5; i > 0; ) {
i--;
}
7. 与相关类型的对比
类型 | 符号性 | 用途 | 头文件 |
---|---|---|---|
| 无符号 | 对象大小、索引 |
|
| 有符号 | 可能返回错误的大小(如Linux系统调用) |
|
| 有符号 | 指针差值 |
|
| 有/无符号 | 通用整数 | 内置类型 |
8. 代码示例
(1) 正确使用
#include <stdio.h>
#include <stddef.h>
int main() {
size_t array_size = 10;
int array[array_size];
for (size_t i = 0; i < array_size; i++) {
array[i] = i * 2;
}
printf("数组大小:%zu\n", array_size);
return 0;
}
(2) 错误示例
int main() {
int a = -1;
size_t b = 10;
if (a < b) {
printf("这行永远不会执行!\n");
}
return 0;
}
总结
-
size_t
是C语言中表示对象大小和索引的标准无符号整数类型。 -
它确保了代码的可移植性和安全性,避免了负值带来的逻辑错误。
-
在与内存操作、字符串处理、数组索引相关的场景中必须优先使用。
-
注意与有符号数的混合操作,并始终使用
%zu
格式说明符进行输出。
指针的关系运算
const修饰指针
const修饰变量
const表示常变量,即你无法改变的。在下图所示的代码中,a是无法直接改变的,在C语言中a被称之为常变量,意思就是a是变量但是无法被修改
但是可以通过以下的方式修改
结果为:
const修饰指针变量
const可以放在*左边和右边。其代表的含义各不相同。
一、const int *p
对于这种情况下,const限制的是*p,也就是说指针指向的内容无法通过改变p来修改了
二、int * const p
对于这种情况下,const限制的是变量p本身,p本身无法改变,但是p所指向的内容是可以通过p来改变的。
可以参考如下代码:
野指针
概念:指针指向的位置没有是不可知的(随机的、不正确的、没有限制的)。
每个程序运行后都会向内存申请空间程序中的指针,指向的内存不属于当前的程序,因此就会产生野指针。
野指针成因
一、指针没有初始化
二、指针越界访问
打印结果如下
当指针超出了数组的范围时候,会随机分配到内存的一个值
三、指针的空间被释放
如果你实在要返回n的指针,建议加上static修饰n
这样就能返回n的值了。
PS:这里做这项补充一下之前遗漏的知识点:static和extern
在C语言中,static
和extern
是两个用于控制变量或函数作用域和链接属性的关键字,它们的核心区别如下:
1. static
关键字
用途:
-
限制作用域:将变量或函数的作用域限定在当前文件或当前函数内。
-
改变存储周期:局部变量声明为
static
时,生命周期延长至程序运行结束。
应用场景:
1.静态局部变量(函数内部):
void counter() {
static int count = 0; // 仅在函数内可见,但生命周期全局
count++;
printf("Count: %d\n", count);
}
特点:
-
每次函数调用时保留上一次的值。
-
初始化仅执行一次
静态全局变量/函数(文件作用域):
static int hidden_var = 42; // 仅当前文件可见
static void internal_func() { ... } // 仅当前文件可调用
2. extern
关键字
用途:
-
声明外部符号:表示变量或函数定义在其他文件中。
-
扩展作用域:允许跨文件访问全局变量或函数。
应用场景:
-
引用其他文件的全局变量:
File1.c:int global_var = 100; // 定义全局变量 - File2.c:extern void public_func(); // 声明外部函数
3. 核心区别对比
特性 | static | extern |
---|---|---|
作用域控制 | 限制为当前文件或函数 | 扩展为跨文件访问 |
存储周期影响 | 可延长局部变量的生命周期 | 不影响存储周期 |
是否分配存储空间 | 定义时分配 | 声明时不分配,依赖外部定义 |
多文件项目中的作用 | 隐藏内部实现(信息隐藏) | 共享全局资源(接口暴露) |
默认链接属性 | 内部链接(仅当前文件) | 外部链接(跨文件可见) |
4. 综合示例
场景:多文件项目中控制符号的可见性
FileA.c
static int private_var = 10; // 仅FileA可见
int public_var = 20; // 其他文件可通过extern访问
static void private_func() { // 仅FileA可调用
// ...
}
void public_func() { // 其他文件可调用
// ...
}
FileB.c
extern int public_var; // 正确:引用FileA的public_var
extern void public_func(); // 正确:调用FileA的public_func
// extern int private_var; // 错误:无法访问FileA的static变量
// extern void private_func(); // 错误:无法调用FileA的static函数
5. 常见问题
问题1:static
全局变量能被其他文件访问吗?
-
不能。
static
全局变量仅在定义它的文件中可见,其他文件无法通过extern
声明访问。
问题2:extern
变量可以初始化吗?
-
不可以。
extern
仅用于声明,而非定义。初始化必须在定义时完成:
extern int var = 5; // 错误:extern声明不能初始化
int var = 5; // 正确:定义并初始化
问题3:函数默认是static
还是extern
?
-
默认情况下,函数具有外部链接(相当于
extern
),除非显式声明为static
。
总结
-
static
:用于隐藏和持久化,限制作用域或延长生命周期。 -
extern
:用于共享和扩展,实现跨文件访问全局资源。 -
关键设计原则:
-
使用
static
保护模块内部实现(封装)。 -
使用
extern
暴露模块公共接口。
-
如何规避野指针
一、指针初始化
如果明确知道指针的指向哪里就直接赋值地址,如果不知道指针指向哪里,就给指针赋值NULL。
NULL是C语言定义的一个标识常量,值为0,但是0也是一个地址。但是这个地址无法使用,读写该地址会报错。
如下所示的代码中,指针变量*p表示传递的是一个野指针。
二、小心指针越界
一个程序向内存申请了多少空间,通过指针就只能访问多少空间,不能超出范围,超出了就是非法访问。
三、避免返回局部变量的地址
参考成因三图一
四、当指针变量不再使用的时候,及时地将它们置NULL,指针使用前检查有效性
当指针变量只想一块区域的时候,我们通过指针可以访问该区域,后期不再使用该指针的时候,我们可以将该指针置为NULL。因为约定俗成的规则就是:只要是NULL指针就不去访问。同时使用前可以判断指针是否为NULL。
我们可以将指针视之为野狗,野狗放任不管的情况下十分危险的,所以我们可以找一棵树把野狗拴起来,,就相对安全了,给指针变量及时赋值为NULL就是类似于把野狗拴起来,也就是把野指针暂时管理起来。
不过野狗即使拴起来也很危险,所以我们要绕着走不要挑逗他。对于指针来说也是如此,在使用前,我们也要判断指针是否为NULL,看看是不是被拴起来的野狗。如果是我们不能使用,如果不时我们再去使用。
示范案例:
assert断言
<assert.h>头文件中定义了宏assert(),用于运行时确保程序符合指定条件,如果不符合终止程序,这个宏常常被称之为“断言”。
当程序运行到该段代码时,验证变量p是否等于NULL,如果不等于NULL则程序正常运行,否则程序会 终止运行并给出错误信息。
assert()宏需要接受一个表达式作为参数,如果表达式为真(返回值为非零),assert()不会产生任何作用,程序继续运行;如果该表达式为假(返回值为零),assert()就会报错,在标准错误流stderr中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式在内的文件名和行号。
assert()的使用对程序员是相当友好的,使用assert()有几个好处:它不仅可以自动表示文件和出问题的行号,还有一种无需更改代码就能换开启或者关闭的assert的机制,如果已经确认程序没有问题,就不需要再做判断,就在#include<assert.h>的语句前定义一个宏NDEBUG。
然后重新编译程序,编译器就会禁用文件中所有的assert()语句。如果程序又出现了问题,可以移除或者注释掉 #define NDEBUG 这条指令重新编译这样就可以重启assert()这条语句了。
缺点是:因为引入了额外的检查,增加了程序运行的时间。
一般我们旨在Debug中使用,在Release版本中选择禁用assert,在VS这样的集成开发环境中,在Release版本中,直接就优化掉了。这样在Debug版本中不影响程序员问题,在Release版本不影响用户的效率。
指针的使用和传址调用
指针的传值和传址调用
指针的内容写到这里已经不少了,那么实际应用中有什么问题非要用指针不可呢?
举例:写一段代码实现两个数值之间的交换:
这个代码在上一期的操作符也写过几个,这里用指针在写一次。
算法一:要求有第三个变量
算法二:有溢出风险
算法三:按位异或算法
算法四:传址调用算法
在算法四中,当我们将exchange2函数注释后(即exchange1函数调用),结果为
这是为什么呢?
在exchange1 函数中,函数中定义的a、b与实际main()函数中的a、b并不一样。函数当中的a、b确实接受了main函数中的a、b的值,但是两组变量是独立的空间,无法互相影响。(如下所示)
exchange1函数使用时将变量本身传递给函数,这种调用方式称之为传值调用
结论:当实参传递给函数时形参会单独创立一个空间传递实参,对形参的修改是不影响实参的。
exchange2函数是将变量的地址直接传递给函数,称之为传址调用
传址调用可以让函数与主函数之间建立真正的联系,在函数内部修改主函数的变量;在未来函数中只是需要主函数中的变量值来进行计算的就可以采用传值调用。如果函数内部要修改主函数内变量的值的,就需要传址调用。
数组名的理解
数组名实际上就是地址,代码验证如下:
但是有两个例外
1.sizeof(数组名):单独放数组名这个数组出了表示整个数组,计算的是整个数组的大小,单位是字节。
2.&数组名:这个数组名表示的整个数组,取出的是整个数组的地址(整个数组的地址和数组首元素的地址是有区别的)
实验验证如下:
整个数组的地址和数组首元素的地址是有区别的,具体体现在如下情况
可知,指针类型决定了+1跳过了多少距离。
指针访问数组
算法二与上相同
本质上,p[i]等价于*(p+i)。
在此处,*(p+i)等价于*(arr+i)等价于arr[i]等价于i[arr]。
结果如下:(直截取一部分)
原理:[]只是一个下标引用操作符。
数组传参的本质
之前我们都是函数外进行数组元素个数的计算的,今天我们来试试将数组交给函数,让函数帮我拿计算数组的元素个数。
错误示范:
结果为:
这是为什么呢?
已知指针变量大小为8,则下列解析:
由以上可知,本质上数组传参传递的是首元素的地址。
因此实际上函数形参部分应该使用指针变量来接受首元素的地址。那么在函数内部我们用sizeof()计算的是一个地址的大小而不是数组的大小。正是因为函数参数的本质是地址,所以函数内部没有办法求数组元素个数。
冒泡排序(重点!!!)
冒泡排序:两两相邻的元素进行比较。
比如:9 8 7 6 5 4 3 2 1 0将其排成升序,即0 1 2 3 4 5 6 7 8 9
代码如下:
主函数和打印函数
冒泡排序函数:
结果:
但是如果当数组中已经有一部分排列有序时候,就会多点计算量,如:
所以我们可以做如下的优化:
这样效率就会更高。
二级指针
关于二级指针,可通过如下代码及其原理图理解。
类比于一级指针,我们可以知道,对*ppa解引用得到的是pa的地址。*ppa访问的是pa
同理,如上图可知,**ppa通过*pa找到pa,然后通过对pa的解引用找到a。因此**ppa访问的就是a
指针数组
指针数组本质上仍然是数组。类比于字符数组char arr[]是用来存放字符元素的可知,指针数组是用来存放指针类型的的元素的。
指针数组的每个元素是地址,又单独的指向一片区域。
但是注意,指针数组的元素必须为同一类型的指针
以下是指针数组代码的打印。
指针数组模拟二维数组
在学习过指针数组之后,我们可以用指针数组来模拟一个二维数组。具体原理如下所示。
有代码如下:
结果为:
字符指针变量
下图所示的是一个字符数组,数组是可以修改的。
但是当你这么写的时候,
和数组的区别是二图的写法下字符串不可以修改。
且二图中的代码并不是把字符串赋值给了p,而是把字符串首字符的地址赋值给了p。
但是实际写的时候二图的写法不足够严谨,建议在char前面加上const修饰(二者含义没有变化,但是加上了更方便理解)
结果为
为什么会这样呢?
因为当我们创建str1和str2两个数组的时候,会分别的想内存申请两份空间(地址不同不能比),初始化为数组的时候会开辟出不同的字符串;而str3和str4创建的是同一个常量字符串,且常量字符串无法被修改(C\C++会把常量字符串储存到单独的内存区域内),当几个指针指向同一个内容的时候,实际上它们会指向同一块内存。因此只要内容一样,只存储一份就够了,大家共同使用就够了(节省内存)。
数组指针变量
数组指针变量是什么?
存放数组的指针,指向的是数组。
int arr[10]={0}的地址是什么呢?答案是&arr。
根据前面学到的知识,我们可知&数组名取出的就是数组的地址
&arr与p的类型完全一样
数组指针类型解析:
二维数组传参的本质
二维数组传参的时候形参是可以写成数组的。
二维数组实际上是一维数组的数组。而且二维数组的数组如果表示数组首元素的地址,那么它就是第一行的地址。
第一行的地址是一维数组的地址。
数组形式写法:
指针形式写法:
总结:二维数组传参时,形参可以写成数组也可以写成指针。
函数指针变量
函数指针变量的创建:
函数指针变量就是存储函数的地址,指向函数。
函数名和&函数名都是函数的地址。数组名是首元素的地址,&数组名是数组的地址。
函数指针变量的创建:
上图所示的指针变量后的 int 后面也可以加上形参名字(但是没有实际意义)。
类比上图还有一个:
把指针变量的名字去掉。剩下的就是它的类型。
函数指针变量的调用
通过函数指针变量来调用函数:
结果为:
以下写法与上文等效:
有两段有意思的代码
以下两段代码均来自于书籍:《C语言陷阱与缺陷》
代码1:
类比以上还有一个
代码2:
代码解释:
1.上面的是一次函数声明
2.函数名叫:signal,有两个参数,第一个参数类型是int,第二个参数类型是函数指针void(*)(int)。
3.该函数指针所指向的函数参数为int,返回类型为void。
typedef关键字
typedef是将复杂的类型重新命名的。通常使复杂的类型简单化。
如何定义两个指针呢?
函数指针的重新命名。
但是切记不要想当然的这样写:
类比以下,数组类型指针也是如此:
函数指针数组
函数指针数组的用途:
做一个计算器程序实现加减乘除:(这里仅仅是为了演示,具体层序运行导致的数据丢失不精确等问题暂时不予以考虑,感兴趣者可自行修改)
转移表
初等算法:
定义函数
代码骨干:
这样一个初等的计算器代码就写好了。
但是我们显然发现一个问题:当函数越来越多的时候,switch语句中case写的就越来越多,代码也越来越臃肿。
因此可以用函数指针数组来改进(定义函数的内容与上一个算法相同)
效果与上图相同。
但是这种算法也有缺点:函数指针数组内的类型都被写死了,如果要改变很麻烦
回调函数
利用函数指针,我们可以实现回调函数的效果。
回调函数的概念
通过函数指针调用的函数。
如果你将一个函数的指针作为参数传递给另外一个函数,当这个函数被用来调用其所指向的函数时,被调用的函数就是回调函数。
回调函数不是由该函数是现房直接调用的,而是在特定的事件或者条件发生的时候由另一方调用的,用于对该事件或条件进行响应。
方案一是直接调用A函数,方案二是将A函数指针传递给B函数。
利用回调函数,针对上面计算器程序的算法一,我们可以做出如下的优化:
定义函数:
代码骨架:
回调函数的机制:
qsort的使用
qsort是一个库函数。是用来排序的。基于快速排序算法实现的库函数,可以用来排序任意类型的数据.
上图中我们所写的冒泡排序算法中,可以排整型浮点型数据,但是不能排结构体数据!即只能排列同一类型的数据。
(这里将冒泡排序放到这里再次复习一下)
qsort的语法结构:
当p1指向的元素大于p2指向的元素的时候返回一个大于0的数字。
当p1指向的元素等于p2指向的元素的时候返回一个0
当p1指向的元素小于p2指向的元素的时候返回一个小于0的数字。
两个字符串比较大小不是比较长度,而是比较对应位置上字符的大小。比如在字符串“abcdefg”和字符串"abcqb"比较大小的时候,因为q的码值大于d,因此即使前面的字符串更长,前面的字符串后续的 f 比 b 更大,仍然是后面的字符串大于前面的字符串。
qsort函数排序代码示例:
头文件包含:
main函数主体:
qsort排列整型数据
qsort排列结构体数据:
模拟qsort函数对冒泡排序进行改造,使之实现对所有数据类型的排列
关于原来的qsort函数语法结构的分析如下:
因此,我们仿照qsort函数可以将冒泡排序的类型定义为如下
在第四个参数中之所以设计为int,是因为两个函数在进行比较过后,只会返回大于0、小于0、等于0三项就足够了。e1 和 e2用void*是为了适应所有类型的数据而设计。加const修饰是因为仅仅只是为了比较大小而不会修改 e1 和 e2 的值,所以加上const来使得代码更加严谨。
test2函数调用了bobbleSort_qsort函数,bobbleSort_qsort函数又将cmp_int函数指针作为第四个参数,在第一趟的时候,arr数组中1和3进入cmp_int函数,返回值为-2,不满足if语句的判断条件,因此flag仍然等于1,不进行数据交换,跳出内层循环。
当数据为9和2的时候,满足内层循环if语句的判断条件,因此进入swap函数进行数据交换。
代码:
头文件:
后续刚需的函数:
模拟的冒泡排序函数:
实现函数:
结果:
结构体(本次没有放置打印函数)
主函数:
以上就是本次指针的内容了。
感谢看到这里的读者朋友,看在如此大的内容量上可否给一个赞呢?感激不尽
但是关于指针方面的内容并没有就此结束。后续我们还会写一些经典的指针例题哦,敬请期待。