当前位置:首页 » 《关注互联网》 » 正文

深入理解指针——C语言

3 人参与  2024年03月21日 18:52  分类 : 《关注互联网》  评论

点击全文阅读


目录

1. 内存和地址

2. 指针变量和地址

3. 指针变量类型的意义

4. const修饰指针

5. 指针运算

6. 野指针

7. assert断言

8. 指针的使用和传址调用

9. 数组名的理解

10. 使用指针访问数组

11. 一维数组传参的本质

12. 冒泡排序

13. 二级指针

14. 指针数组

15. 指针数组模拟二维数组

16. 字符指针变量

17. 数组指针变量

18. 二维数组传参的本质

19. 函数指针变量

20. 函数指针数组

21. 转移表

22. 回调函数是什么?

23. qsort使用举例

24. qsort函数的模拟实现

25. sizeof和strlen的对比

1. 内存和地址

1.1 内存

在讲内存和地址之前,我们想有个生活中的案例:
假设有⼀栋宿舍楼,把你放在楼里,楼上有100个房间,但是房间没有编号,你的⼀个朋友来找你玩,如果想找到你,就得挨个房子去找,这样效率很低,但是我们如果根据楼层和楼层的房间的情况,给每个房间编上号,如:

有了房间号,如果你的朋友得到房间号,就可以快速的找房间,找到你。 
生活中,每个房间有了房间号,就能提⾼效率,能快速的找到房间。
如果把上面的例子对照到计算中,⼜是怎么样呢?
我们知道计算上CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中,那我们买电脑的时候,电脑上内存是8GB/16GB/32GB等,那这些内存空间如何高效的管理呢?
其实也是把内存划分为⼀个个的内存单元,每个内存单元的大小取1个字节。
计算机中常见的单位(补充):
一个比特位可以存储一个2进制的位1或者0

其中,每个内存单元,相当于一个学生宿舍,一个人字节空间里面能放8个比特位,就好比同学们住的八人间,每个人是⼀个比特位。
每个内存单元也都有⼀个编号(这个编号就相当于宿舍房间的门牌号),有了这个内存单元的编 号,CPU就可以快速找到⼀个内存空间。

 

生活中我们把门牌号也叫地址,在计算机中我们把内存单元的编号也称为地址。C语言中给地址起
了新的名字叫:指针。
所以我们可以理解为:
内存单元的编号 == 地址 == 指针

1.2 究竟该如何理解编址

CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,⽽因为内存中字节
很多,所以需要给内存进⾏编址(就如同宿舍很多,需要给宿舍编号⼀样)。
计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。
钢琴、吉他 上面没有写上“都瑞咪发嗦啦”这样的信息,但演奏者照样能够准确找到每⼀个琴弦
的每⼀个位置,这是为何?因为制造商已经在乐器硬件层面上设计好了,并且所有的演奏者都知
道。本质是⼀种约定出来的共识!硬件编址也是如此

首先,必须理解,计算机内是有很多的硬件单元,⽽硬件单元是要互相协同⼯作的。所谓的协
同,至少相互之间要能够进行数据传递。
但是硬件与硬件之间是互相独立的,那么如何通信呢?答案很简单,用"线"连起来。
而CPU和内存之间也是有大量的数据交互的,所以,两者必须也用线连起来。
不过,我们今天关心⼀组线,叫做地址总线。
我们可以简单理解,32位机器有32根地址总线,每根线只有两态,表示0,1【电脉冲有无】,那么
⼀根线,就能表示2种含义,2根线就能表示4种含义,依次类推。32根地址线,就能表示2^32种含
义,每⼀种含义都代表⼀个地址。
地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传入
CPU内寄存器。

2. 指针变量和地址

2.1 取地址操作符(&)

理解了内存和地址的关系,我们再回到C语言,在C语言中创建变量其实就是向内存申请空间,比如:

比如,上述的代码就是创建了整型变量a,内存中申请4个字节,用于存放整数10,其中每个字节
都有地址,上图中4个字节的地址分别是:

0x006FFD70
0x006FFD71
0x006FFD72
0x006FFD73
那我们如何能得到a的地址呢?
这⾥就得学习⼀个操作符(&)-取地址操作符

按照我画图的例子,会打印处理:006FFD70
&a取出的是a所占4个字节中地址较小的字节的地址。
虽然整型变量占用4个字节,我们只要知道了第一个字节地址,顺藤摸瓜访问到4个字节的数据也是可行的。

2.2 指针变量和解引用操作符(*)

2.2.1 指针变量

那我们通过取地址操作符(&)拿到的地址是⼀个数值,比如:0x006FFD70,这个数值有时候也是需要存储起来,方便后期再使用的,那我们把这样的地址值存放在哪⾥呢?答案是:指针变量中。
比如:
#include <stdio.h>int main(){ int a = 10; int* pa = &a;//取出a的地址并存储到指针变量pa中 return 0;}
指针变量也是⼀种变量,这种变量就是用来存放地址的,存放在指针变量中的值都会理解为地址。

2.2.2 如何拆解指针类型

我们看到pa的类型是 int* ,我们该如何理解指针的类型呢?
int a = 10;int * pa = &a;

这里pa左边写的是 int* , * 是在说明pa是指针变量,而前面的 int 是在说明pa指向的是整型(int)
类型的对象。

那如果有⼀个char类型的变量ch,ch的地址,要放在什么类型的指针变量中呢? 
char ch = 'w';pc = &ch;//pc 的类型怎么写呢?

2.2.3 解引用操作符

我们将地址保存起来,未来是要使用的,那怎么使用呢?
在现实生活中,我们使用地址要找到⼀个房间,在房间⾥可以拿去或者存放物品。
C语言中其实也是⼀样的,我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象,这⾥必须学习⼀个操作符叫解引用操作符(*)。
#include <stdio.h>int main(){ int a = 100; int* pa = &a; *pa = 0; return 0;}
上面代码中第7行就使用了解引用操作符, *pa 的意思就是通过pa中存放的地址,找到指向的空间,*pa其实就是a变量了;所以*pa = 0,这个操作符是把a改成了0.
有同学肯定在想,这⾥如果⽬的就是把a改成0的话,写成 a = 0; 不就完了,为啥非要使用指针呢?
其实这⾥是把a的修改交给了pa来操作,这样对a的修改,就多了⼀种的途径,写代码就会更加灵活,后期慢慢就能理解了。

2.3 指针变量的大小

前⾯的内容我们了解到,32位机器假设有32根地址总线,每根地址线出来的电信号转换成数字信号后是1或者0,那我们把32根地址线产生的2进制序列当做⼀个地址,那么⼀个地址就是32个bit位,需要4个字节才能存储。
如果指针变量是用来存放地址的,那么指针变的大小就得是4个字节的空间才可以。
同理64位机器,假设有64根地址线,⼀个地址就是64个二进制位组成的二进制序列,存储起来就需要8个字节的空间,指针变的大小就是8个字节。
#include <stdio.h>//指针变量的⼤⼩取决于地址的⼤⼩//32位平台下地址是32个bit位(即4个字节)//64位平台下地址是64个bit位(即8个字节)int main(){ printf("%zd\n", sizeof(char *)); printf("%zd\n", sizeof(short *)); printf("%zd\n", sizeof(int *)); printf("%zd\n", sizeof(double *)); return 0;}

结论:
• 32位平台下地址是32个bit位,指针变量大小是4个字节
• 64位平台下地址是64个bit位,指针变量大小是8个字节
• 注意指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的。

3. 指针变量类型的意义

指针变量的大小和类型⽆关,只要是指针变量,在同⼀个平台下,大小都是⼀样的,为什么还要有各种各样的指针类型呢?
其实指针类型是有特殊意义的,我们接下来继续学习。

3.1 指针的解引用

对比,下面2段代码,主要在调试时观察内存的变化
代码1:
//代码1#include <stdio.h>int main(){ int n = 0x11223344; int *pi = &n;  *pi = 0;  return 0;}

 

 代码2:
//代码2#include <stdio.h>int main(){ int n = 0x11223344; char *pc = (char *)&n; *pc = 0; return 0;}

调试我们可以看到,代码1会将n的4个字节全部改为0,但是代码2只是将n的第⼀个字节改为0。
结论:指针的类型决定了,对指针解引用的时候有多大的权限(⼀次能操作几个字节)。
比如: char* 的指针解引用就只能访问⼀个字节,而 int* 的指针的解引用就能访问四个字节。

3.2 指针+-整数

先看⼀段代码,调试观察地址的变化。

#include <stdio.h>int main(){int n = 10;char *pc = (char*)&n;int *pi = &n;printf("%p\n", &n);printf("%p\n", pc);printf("%p\n", pc+1);printf("%p\n", pi);printf("%p\n", pi+1);return 0;}

代码运行的结果如下:

我们可以看出, char* 类型的指针变量+1跳过1个字节, int* 类型的指针变量+1跳过了4个字节。
这就是指针变量的类型差异带来的变化。
结论:指针的类型决定了指针向前或者向后走一步有多大(距离)。

3.3 void* 指针

在指针类型中有⼀种特殊的类型是 void* 类型的,可以理解为⽆具体类型的指针(或者叫泛型指
针),这种类型的指针可以⽤来接受任意类型地址。但是也有局限性, void* 类型的指针不能直接进⾏指针的+-整数和解引用的运算。
举例:
#include <stdio.h>int main(){ int a = 10; int* pa = &a;char* pc = &a; return 0;}

在上面的代码中,将⼀个int类型的变量的地址赋值给⼀个char*类型的指针变量。编译器给出了⼀个警告(如下图),是因为类型不兼容。而使用void*类型就不会有这样的问题。

使用void*类型的指针接收地址:
#include <stdio.h>int main(){ int a = 10; void* pa = &a; void* pc = &a;  *pa = 10; *pc = 0; return 0;}
VS2022编译代码的结果: 

这⾥我们可以看到, void* 类型的指针可以接收不同类型的地址,但是无法直接进行指针运算。
那么 void* 类型的指针到底有什么用呢?
⼀般 void* 类型的指针是使⽤在函数参数的部分,用来接收不同类型数据的地址,这样的设计可以
实现泛型编程的效果。使得⼀个函数来处理多种类型的数据。

4. const修饰指针

4.1 const修饰变量

变量是可以修改的,如果把变量的地址交给⼀个指针变量,通过指针变量的也可以修改这个变量。
但是如果我们希望⼀个变量加上⼀些限制,不能被修改,怎么做呢?这就是const的作用。

#include <stdio.h>int main(){ int m = 0; m = 20;//m是可以修改的 const int n = 0; n = 20;//n是不能被修改的 return 0;}

上述代码中n是不能被修改的,其实n本质是变量,只不过被const修饰后,在语法上加了限制,只
要我们在代码中对n就⾏修改,就不符合语法规则,就报错,致使没法直接修改n。

但是如果我们绕过n,使用n的地址,去修改n就能做到了,虽然这样做是在打破语法规则。
#include <stdio.h>int main(){ const int n = 0; printf("n = %d\n", n); int*p = &n; *p = 20; printf("n = %d\n", n); return 0;}
输出结果:

我们可以看到这⾥⼀个确实修改了,但是我们还是要思考⼀下,为什么n要被const修饰呢?就是
为了不能被修改,如果p拿到n的地址就能修改n,这样就打破了const的限制,这是不合理的,所
以应该让p拿到n的地址也不能修改n,那接下来怎么做呢?

4.2 const修饰指针变量

我们看下面代码,来分析
#include <stdio.h>//代码1void test1(){ int n = 10; int m = 20; int *p = &n; *p = 20;//ok? p = &m; //ok?}void test2(){ //代码2 int n = 10; int m = 20; const int* p = &n; *p = 20;//ok? p = &m; //ok?}void test3(){ int n = 10; int m = 20; int *const p = &n; *p = 20; //ok?p = &m; //ok?}void test4(){ int n = 10; int m = 20; int const * const p = &n; *p = 20; //ok? p = &m; //ok?}int main(){ //测试⽆const修饰的情况 test1(); //测试const放在*的左边情况 test2(); //测试const放在*的右边情况 test3(); //测试*的左右两边都有const test4(); return 0;}
结论:const修饰指针变量的时候
• const如果放在*的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。
但是指针变量本⾝的内容可变。
• const如果放在*的右边,修饰的是指针变量本⾝,保证了指针变量的内容不能修改,但是指针指
向的内容,可以通过指针改变。

5. 指针运算

指针的基本运算有三种,分别是:
• 指针+- 整数
• 指针-指针
• 指针的关系运算

5.1 指针+- 整数

因为数组在内存中是连续存放的,只要知道第⼀个元素的地址,顺藤摸⽠就能找到后⾯的所有元素。
int arr[10] = {1,2,3,4,5,6,7,8,9,10};

#include <stdio.h>//指针+- 整数int main(){ int arr[10] = {1,2,3,4,5,6,7,8,9,10}; int *p = &arr[0]; int i = 0; int sz = sizeof(arr)/sizeof(arr[0]); for(i=0; i<sz; i++) { printf("%d ", *(p+i));//p+i 这⾥就是指针+整数 } return 0;}
 运行结果:

5.2 指针- 指针 

//指针-指针#include <stdio.h>int my_strlen(char *s){ char *p = s; while(*p != '\0' ) p++; return p-s;}int main(){ printf("%d\n", my_strlen("abc")); return 0;}
解析: 

5.3 指针的关系运算 

//指针的关系运算#include <stdio.h>int main(){ int arr[10] = {1,2,3,4,5,6,7,8,9,10}; int *p = &arr[0]; int i = 0; int sz = sizeof(arr)/sizeof(arr[0]); while(p<arr+sz) //指针的⼤⼩⽐较 { printf("%d ", *p); p++; } return 0;}
解析: 

6. 野指针

概念: 野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)

6.1 野指针成因

1. 指针未初始化

#include <stdio.h>int main(){  int *p;//局部变量指针未初始化,默认为随机值 *p = 20; return 0;}

2. 指针越界访问

#include <stdio.h>int main(){ int arr[10] = {0}; int *p = &arr[0]; int i = 0; for(i=0; i<=11; i++) { //当指针指向的范围超出数组arr的范围时,p就是野指针 *(p++) = i; } return 0;}

3. 指针指向的空间释放

#include <stdio.h>int* test(){ int n = 100; return &n;}int main(){ int*p = test();//函数调用完后会释放空间,所以指针指向的是空地址 printf("%d\n", *p); return 0;}

6.2 如何规避野指针

6.2.1 指针初始化

如果明确知道指针指向哪⾥就直接赋值地址,如果不知道指针应该指向哪⾥,可以给指针赋值NULL.
NULL 是C语言中定义的⼀个标识符常量,值是0,0也是地址,这个地址是无法使用的,读写该地址会报错。
初始化如下:
#include <stdio.h>int main(){ int num = 10; int*p1 = &num; int*p2 = NULL; return 0;}

6.2.2 小心指针越界

⼀个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就
是越界访问。

6.2.3 指针变量不再使用时,及时置NULL,指针使用之前检查有效性

当指针变量指向⼀块区域的时候,我们可以通过指针访问该区域,后期不再使用这个指针访问空间的时候,我们可以把该指针置为NULL。因为约定俗成的⼀个规则就是:只要是NULL指针就不去访问,同时使用指针之前可以判断指针是否为NULL。
我们可以把野指针想象成野狗,野狗放任不管是非常危险的,所以我们可以找⼀棵树把野狗拴起来,就相对安全了,给指针变量及时赋值为NULL,其实就类似把野狗栓前来,就是把野指针暂时管理起来。
不过野狗即使拴起来我们也要绕着走,不能去挑逗野狗,有点危险;对于指针也是,在使用之前,我们也要判断是否为NULL,看看是不是被拴起来起来的野狗,如果是不能直接使用,如果不是我们再去使用。
int main(){ int arr[10] = {1,2,3,4,5,67,7,8,9,10}; int *p = &arr[0]; for(i=0; i<10; i++) { *(p++) = i; } //此时p已经越界了,可以把p置为NULL p = NULL; //下次使⽤的时候,判断p不为NULL的时候再使⽤ //... p = &arr[0];//重新让p获得地址 if(p != NULL) //判断 { //... } return 0;}

6.2.4 避免返回局部变量的地址

如造成野指针的第3个例子,不要返回局部变量的地址。

7. assert断言

assert.h 头文件定义了宏 assert() ,用于在运行时确保程序符合指定条件,如果不符合,就报
错终止运行。这个宏常常被称为“断言”。⬇️
assert(p != NULL);
上面代码在程序运行到这⼀行语句时,验证变量 p 是否等于 NULL 。如果确实不等于 NULL ,程序继续运行,否则就会终止运行,并且给出报错信息提示。
assert() 宏接受⼀个表达式作为参数。如果该表达式为真(返回值非零), assert() 不会产生
任何作用,程序继续运行。如果该表达式为假(返回值为零), assert() 就会报错,在标准错误
流 stderr 中写⼊一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
assert() 的使用对程序员是非常友好的,使用 assert() 有几个好处:它不仅能自动标识文件和
出问题的行号,还有⼀种无需更改代码就能开启或关闭 assert() 的机制。如果已经确认程序没有问
题,不需要再做断言,就在 #include <assert.h> 语句的前面,定义⼀个宏 NDEBUG 。
#define NDEBUG#include <assert.h>
然后,重新编译程序,编译器就会禁用文件中所有的 assert() 语句。如果程序⼜出现问题,可以移
除这条 #define NDBUG 指令(或者把它注释掉),再次编译,这样就重新启用了 assert() 语
句。

assert() 的缺点是,因为引入了额外的检查,增加了程序的运行时间。
⼀般我们可以在 Debug 中使用,在 Release 版本中选择禁用 assert 就行,在 VS 这样的集成开
发环境中,在 Release 版本中,直接就是优化掉了。这样在debug版本写有利于程序员排查问
题,在 Release 版本不影响用户使用时程序的效率。

8. 指针的使用和传址调用

8.1 strlen的模拟实现

库函数strlen的功能是求字符串长度,统计的是字符串中 \0 之前的字符的个数。
函数原型如下:
size_t strlen ( const char * str );
参数str接收⼀个字符串的起始地址,然后开始统计字符串中 \0 之前的字符个数,最终返回长度。
如果要模拟实现只要从起始地址开始向后逐个字符的遍历,只要不是 \0 字符,计数器就+1,这样直到 \0 就停止。
参考代码如下:
int my_strlen(const char * str){ int count = 0; assert(str); while(*str) { count++; str++; } return count;}int main(){ int len = my_strlen("abcdef"); printf("%d\n", len); return 0;}

8.2 传值调用和传址调用

学习指针的目的是使用指针解决问题,那什么问题,非指针不可呢?
例如:写⼀个函数,交换两个整型变量的值
⼀番思考后,我们可能写出这样的代码:
#include <stdio.h>void Swap1(int x, int y){ int tmp = x; x = y; y = tmp;}int main(){ int a = 0; int b = 0; scanf("%d %d", &a, &b); printf("交换前:a=%d b=%d\n", a, b); Swap1(a, b); printf("交换后:a=%d b=%d\n", a, b); return 0;}

当我们运行代码,结果如下:

我们发现其实没产生交换的效果,这是为什么呢?
调试⼀下,试试呢?

 

我们发现在main函数内部,创建了a和b,a的地址是0x00cffdd0,b的地址是0x00cffdc4,在调用
Swap1函数时,将a和b传递给了Swap1函数,在Swap1函数内部创建了形参x和y接收a和b的值,
但是x的地址是0x00cffcec,y的地址是0x00cffcf0,x和y确实接收到了a和b的值,不过x的地址和
a的地址不⼀样,y的地址和b的地址不⼀样,相当于x和y是独立的空间,那么在Swap1函数内部交
换x和y的值,自然不会影响a和b,当Swap1函数调用结束后回到main函数,a和b的没法交换。
Swap1函数在使用的时候,是把变量本⾝直接传递给了函数,这种调用函数的方式我们之前在函
数的时候就知道了,这种叫传值调用。

结论:实参传递给形参的时候,形参会单独创建⼀份临时空间来接收实参,对形参的修改不影响实
参。
所以Swap是失败的了。

那怎么办呢?
我们现在要解决的就是当调⽤Swap函数的时候,Swap函数内部操作的就是main函数中的a和b,直接将a和b的值交换了。那么就可以使⽤指针了,在main函数中将a和b的地址传递给Swap函数,Swap函数⾥边通过地址间接的操作main函数中的a和b,并达到交换的效果就好了。
#include <stdio.h>void Swap2(int*px, int*py){ int tmp = 0; tmp = *px; *px = *py; *py = tmp;}int main(){ int a = 0; int b = 0; scanf("%d %d", &a, &b); printf("交换前:a=%d b=%d\n", a, b); Swap2(&a, &b); printf("交换后:a=%d b=%d\n", a, b); return 0;}

首先看输出结果: 

我们可以看到实现成Swap2的方式,顺利完成了任务,这⾥调用Swap2函数的时候是将变量的地址传递给了函数,这种函数调用方式叫:传址调用。

传址调用,可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数中的变量;所以未来函数中只是需要主调函数中的变量值来实现计算,就可以采用传值调用。如果函数内部要修改主调函数中的变量的值,就需要传址调用。

9. 数组名的理解

在上⼀个章节我们在使用指针访问数组的内容时,有这样的代码:
int arr[10] = {1,2,3,4,5,6,7,8,9,10};int *p = &arr[0];
这⾥我们使用 &arr[0] 的方式拿到了数组第⼀个元素的地址,但是其实数组名本来就是地址,而且
是数组首元素的地址,我们来做个测试。
#include <stdio.h>int main(){ int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; printf("&arr[0] = %p\n", &arr[0]); printf("arr = %p\n", arr); return 0;}
输出结果:

我们发现数组名和数组首元素的地址打印出的结果一模一样,数组名就是数组首元素(第⼀个元素)
的地址。
这时候有同学会有疑问?数组名如果是数组首元素的地址,那下面的代码怎么理解呢?
#include <stdio.h>int main(){ int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; printf("%d\n", sizeof(arr)); return 0;}
输出的结果是:

如果arr是数组首元素的地址,那输出应该的应该是4/8才对。
其实数组名就是数组首元素(第⼀个元素)的地址是对的,但是有两个例外:

• sizeof(数组名),sizeof中单独放数组名,这⾥的数组名表示整个数组,计算的是整个数组的大小,单位是字节
• &数组名,这⾥的数组名表示整个数组,取出的是整个数组的地址(整个数组的地址和数组首元素的地址是有区别的)
除此之外,任何地方使用数组名,数组名都表示首元素的地址。
这时有好奇的同学,再试⼀下这个代码:
#include <stdio.h>int main(){ int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; printf("&arr[0] = %p\n", &arr[0]); printf("arr = %p\n", arr); printf("&arr = %p\n", &arr); return 0;}

运行结果:
三个打印结果⼀模⼀样,这时候又纳闷了,那arr和&arr有啥区别呢?

#include <stdio.h>int main(){ int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; printf("&arr[0] = %p\n", &arr[0]); printf("&arr[0]+1 = %p\n", &arr[0]+1); printf("arr = %p\n", arr); printf("arr+1 = %p\n", arr+1); printf("&arr = %p\n", &arr); printf("&arr+1 = %p\n", &arr+1); return 0;}

运行结果:
这⾥我们发现&arr[0]和&arr[0]+1相差4个字节,arr和arr+1 相差4个字节,是因为&arr[0] 和 arr 都是首元素的地址,+1就是跳过⼀个元素。
但是&arr 和 &arr+1相差40个字节,这就是因为&arr是数组的地址,+1 操作是跳过整个数组的。
到这⾥大家应该搞清楚数组名的意义了吧。
数组名是数组首元素的地址,但是有2个例外。

10. 使用指针访问数组

有了前面知识的⽀持,再结合数组的特点,我们就可以很方便的使用指针访问数组了。
#include <stdio.h>int main(){ int arr[10] = {0}; //输⼊int i = 0; int sz = sizeof(arr)/sizeof(arr[0]); //输⼊ int* p = arr; for(i=0; i<sz; i++) { scanf("%d", p+i); //scanf("%d", arr+i);//也可以这样写 } //输出 for(i=0; i<sz; i++) { printf("%d ", *(p+i)); } return 0;}
这个代码搞明白后,我们再试⼀下,如果我们再分析⼀下,数组名arr是数组首元素的地址,可以赋值给p,其实数组名arr和p在这⾥是等价的。那我们可以使用arr[i]可以访问数组的元素,那p[i]是否也可以访问数组呢?
#include <stdio.h>int main(){ int arr[10] = {0}; //输⼊ int i = 0; int sz = sizeof(arr)/sizeof(arr[0]); //输⼊ int* p = arr; for(i=0; i<sz; i++) { scanf("%d", p+i); //scanf("%d", arr+i);//也可以这样写 } //输出 for(i=0; i<sz; i++) { printf("%d ", p[i]); } return 0;}
在第18行的地方,将*(p+i)换成p[i]
也是能够正常打印的,所以本质上p[i] 是等价于 *(p+i)。

同理arr[i] 应该等价于 *(arr+i),数组元素的访问在编译器处理的时候,也是转换成首元素的地址
+偏移量求出元素的地址,然后解引用来访问的。

11. ⼀维数组传参的本质

数组我们学过了,之前也讲了,数组是可以传递给函数的,这个小节我们讨论⼀下数组传参的本质。
首先从⼀个问题开始,我们之前都是在函数外部计算数组的元素个数,那我们可以把函数传给⼀个函数后,函数内部求数组的元素个数吗?
#include <stdio.h>void test(int arr[]){ int sz2 = sizeof(arr)/sizeof(arr[0]); printf("sz2 = %d\n", sz2);}int main(){ int arr[10] = {1,2,3,4,5,6,7,8,9,10}; int sz1 = sizeof(arr)/sizeof(arr[0]); printf("sz1 = %d\n", sz1); test(arr); return 0;}
输出的结果:

我们发现在函数内部是没有正确获得数组的元素个数。
这就要学习数组传参的本质了,上个小节我们学习了:数组名是数组⾸元素的地址;那么在数组传参的时候,传递的是数组名,也就是说本质上数组传参本质上传递的是数组⾸元素的地址。
所以函数形参的部分理论上应该使用指针变量来接收首元素的地址。那么在函数内部我们写 sizeof(arr) 计算的是⼀个地址的大小(单位字节)而不是数组的大小(单位字节)。正是因为函 数的参数部分是本质是指针,所以在函数内部是没办法求的数组元素个数的。
void test(int arr[])//参数写成数组形式,本质上还是指针{printf("%d\n", sizeof(arr));}void test(int* arr)//参数写成指针形式{ printf("%d\n", sizeof(arr));//计算⼀个指针变量的⼤⼩}int main(){ int arr[10] = {1,2,3,4,5,6,7,8,9,10}; test(arr); return 0;}
总结:⼀维数组传参,形参的部分可以写成数组的形式,也可以写成指针的形式。

12. 冒泡排序

冒泡排序的核心思想就是:两两相邻的元素进行比较。
方法一:
//⽅法1void bubble_sort(int arr[], int sz)//参数接收数组元素个数{ int i = 0; for(i=0; i<sz-1; i++) { int j = 0; for(j=0; j<sz-i-1; j++) { if(arr[j] > arr[j+1]) { int tmp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = tmp; } } }}int main(){ int arr[] = {3,1,7,5,8,9,0,2,4,6}; int sz = sizeof(arr)/sizeof(arr[0]); bubble_sort(arr, sz);for(i=0; i<sz; i++) { printf("%d ", arr[i]); } return 0;}
方法二:
//⽅法2 - 优化void bubble_sort(int arr[], int sz)//参数接收数组元素个数{ int i = 0; for(i=0; i<sz-1; i++) { int flag = 1;//假设这⼀趟已经有序了 int j = 0; for(j=0; j<sz-i-1; j++) { if(arr[j] > arr[j+1]) { flag = 0;//发⽣交换就说明,⽆序 int tmp = arr[j]; arr[j] = arr[j+1]; arr[j+1] = tmp; } } if(flag == 1)//这⼀趟没交换就说明已经有序,后续⽆序排序了 break; }}int main(){ int arr[] = {3,1,7,5,8,9,0,2,4,6}; int sz = sizeof(arr)/sizeof(arr[0]); bubble_sort(arr, sz); for(i=0; i<sz; i++) { printf("%d ", arr[i]); } return 0;}

13. 二级指针

指针变量也是变量,是变量就有地址,那指针变量的地址存放在哪⾥?
这就是二级指针 。⬇️

对于二级指针的运算有:
• *ppa 通过对ppa中的地址进行解引用,这样找到的是 pa , *ppa 其实访问的就是 pa .
int b = 20;*ppa = &b;//等价于 pa = &b;
 • **ppa 先通过 *ppa 找到 pa ,然后对 pa 进行解引用操作: *pa ,那找到的是 a .
**ppa = 30;//等价于*pa = 30;//等价于a = 30;

14. 指针数组

指针数组是指针还是数组?
我们类比⼀下,整型数组,是存放整型的数组,字符数组是存放字符的数组。
那指针数组呢?是存放指针的数组。

15. 指针数组模拟二维数组 

#include <stdio.h>int main(){ int arr1[] = {1,2,3,4,5}; int arr2[] = {2,3,4,5,6}; int arr3[] = {3,4,5,6,7}; //数组名是数组⾸元素的地址,类型是int*的,就可以存放在parr数组中 int* parr[3] = {arr1, arr2, arr3}; int i = 0; int j = 0; for(i=0; i<3; i++) { for(j=0; j<5; j++) { printf("%d ", parr[i][j]); } printf("\n"); } return 0;}

parr[i]是访问parr数组的元素,parr[i]找到的数组元素指向了整型⼀维数组,parr[i][j]就是整型⼀维数组中的元素。
上述的代码模拟出二维数组的效果,实际上并非完全是二维数组,因为每一行并非是连续的。

16. 字符指针变量

在指针的类型中我们知道有⼀种指针类型为字符指针 char* ;
一般使用:
int main(){ char ch = 'w'; char *pc = &ch; *pc = 'w'; return 0;}
还有一种使用方式如下:
int main(){ const char* pstr = "hello bit.";//这⾥是把⼀个字符串放到pstr指针变量⾥了吗? printf("%s\n", pstr); return 0;}

代码 const char* pstr = "hello bit."; 特别容易让同学以为是把字符串 hello bit 放到字符指针 pstr ⾥了,但是本质是把字符串 hello bit. ⾸字符的地址放到了pstr中。

上面代码的意思是把⼀个常量字符串的首字符 h 的地址存放到指针变量 pstr 中。
《剑指offer》中收录了⼀道和字符串相关的笔试题,我们⼀起来学习⼀下:

#include <stdio.h>int main(){ char str1[] = "hello bit."; char str2[] = "hello bit."; const char *str3 = "hello bit."; const char *str4 = "hello bit."; if(str1 ==str2) printf("str1 and str2 are same\n"); else printf("str1 and str2 are not same\n"); if(str3 ==str4) printf("str3 and str4 are same\n"); else printf("str3 and str4 are not same\n"); return 0;}

运行结果:

解析: 
这⾥str3和str4指向的是⼀个同⼀个常量字符串。C/C++会把常量字符串存储到单独的⼀个内存区域,当几个指针指向同⼀个字符串的时候,他们实际会指向同⼀块内存。但是用相同的常量字符串去初始化不同的数组的时候就会开辟出不同的内存块。所以str1和str2不同,str3和str4相同。

17. 数组指针变量

17.1 数组指针变量是什么?

之前我们学习了指针数组,指针数组是⼀种数组,数组中存放的是地址(指针)。
数组指针变量是指针变量?还是数组?
答案是:指针变量。
我们已经熟悉:
• 整形指针变量: int * pint; 存放的是整形变量的地址,能够指向整形数据的指针。
• 浮点型指针变量: float * pf; 存放浮点型变量的地址,能够指向浮点型数据的指针。
那数组指针变量应该是:存放的应该是数组的地址,能够指向数组的指针变量。
下面代码哪个是数组指针变量?
int *p1[10];int (*p2)[10];
思考⼀下:p1, p2分别是什么?

数组指针变量
int (*p)[10];
解释:p先和*结合,说明p是⼀个指针变量变量,然后指着指向的是⼀个大小为10个整型的数组。所以p是⼀个指针,指向⼀个数组,叫 数组指针。
这⾥要注意:[]的优先级要高于*号的,所以必须加上()来保证p先和*结合。

17.2 数组指针变量怎么初始化

数组指针变量是用来存放数组地址的,那怎么获得数组的地址呢?就是我们之前学习的&数组名 。
int arr[10] = {0};&arr;//得到的就是数组的地址
如果要存放个数组的地址,就得存放在数组指针变量中,如下:
int(*p)[10] = &arr;

我们调试也能看到 &arr 和 p 的类型是完全⼀致的。
数组指针类型解析:

18. 二维数组传参的本质

有了数组指针的理解,我们就能够讲⼀下二维数组传参的本质了。
过去我们有⼀个二维数组的需要传参给⼀个函数的时候,我们是这样写的:

#include <stdio.h>void test(int a[3][5], int r, int c){ int i = 0; int j = 0;for(i=0; i<r; i++){ for(j=0; j<c; j++) { printf("%d ", a[i][j]); } printf("\n"); }}int main(){ int arr[3][5] = {{1,2,3,4,5}, {2,3,4,5,6},{3,4,5,6,7}}; test(arr, 3, 5); return 0;}
这⾥实参是二维数组,形参也写成二维数组的形式,那还有什么其他的写法吗?
首先我们再次理解⼀下二维数组,二维数组起始可以看做是每个元素是⼀维数组的数组,也就是二维数组的每个元素是⼀个⼀维数组。那么二维数组的⾸元素就是第一行,是个⼀维数组。
如下图:

所以,根据数组名是数组⾸元素的地址这个规则,二维数组的数组名表示的就是第一行的地址,是⼀维数组的地址。根据上⾯的例子,第一行的⼀维数组的类型就是 int [5] ,所以第一行的地址的类型就是数组指针类型 int(*)[5] 。那就意味着二维数组传参本质上也是传递了地址,传递的是第⼀ 行这个⼀维数组的地址,那么形参也是可以写成指针形式的。如下:
#include <stdio.h>void test(int (*p)[5], int r, int c){ int i = 0; int j = 0; for(i=0; i<r; i++) { for(j=0; j<c; j++) { printf("%d ", *(*(p+i)+j)); } printf("\n"); }}int main(){ int arr[3][5] = {{1,2,3,4,5}, {2,3,4,5,6},{3,4,5,6,7}}; test(arr, 3, 5); return 0;}
 总结:二维数组传参,形参的部分可以写成数组,也可以写成指针形式。

19. 函数指针变量

19.1 函数指针变量的创建

什么是函数指针变量呢?
根据前面学习整型指针,数组指针的时候,我们的类比关系,我们不难得出结论:
函数指针变量应该是用来存放函数地址的,未来通过地址能够调用函数的。
那么函数是否有地址呢?
我们做个测试:
#include <stdio.h>void test(){ printf("hehe\n");}int main(){ printf("test: %p\n", test); printf("&test: %p\n", &test); return 0;}
输出结果如下:

确实打印出来了地址,所以函数是有地址的,函数名就是函数的地址,当然也可以通过 &函数名 的方式获得函数的地址。
如果我们要将函数的地址存放起来,就得创建函数指针变量咯,函数指针变量的写法其实和数组指针非常类似。如下:
void test(){ printf("hehe\n");}void (*pf1)() = &test;void (*pf2)()= test;int Add(int x, int y){ return x+y;}int(*pf3)(int, int) = Add;int(*pf3)(int x, int y) = &Add;//x和y写上或者省略都是可以的

 

19.2 函数指针变量的使用

通过函数指针调用指针指向的函数。
#include <stdio.h>int Add(int x, int y){ return x+y;}int main(){ int(*pf3)(int, int) = Add;  printf("%d\n", (*pf3)(2, 3)); printf("%d\n", pf3(3, 5)); return 0;}
输出结果:

19.2.1 typedef关键字

typedef 是用来类型重命名的,可以将复杂的类型,简单化。
比如,你觉得 unsigned int 写起来不方便,如果能写成 uint 就方便多了,那么我们可以使用:
typedef unsigned int uint;//将unsigned int 重命名为uint
如果是指针类型,能否重命名呢?其实也是可以的,比如,将 int* 重命名为 ptr_t ,这样写:
typedef int* ptr_t;
但是对于数组指针和函数指针稍微有点区别:
比如我们有数组指针类型 int(*)[5] ,需要重命名为 parr_t ,那可以这样写:
typedef int(*parr_t)[5]; //新的类型名必须在*的右边
函数指针类型的重命名也是⼀样的,⽐如,将 void(*)(int) 类型重命名为 pf_t ,就可以这样写:
typedef void(*pfun_t)(int);//新的类型名必须在*的右边
那么要简化代码2,可以这样写:
typedef void(*pfun_t)(int);pfun_t signal(int, pfun_t);

20. 函数指针数组

数组是⼀个存放相同类型数据的存储空间,我们已经学习了指针数组,
比如:
int *arr[10];//数组的每个元素是int*
那要把函数的地址存到⼀个数组中,那这个数组就叫函数指针数组,那函数指针的数组如何定义呢?
int (*parr1[3])();int *parr2[3]();int (*)() parr3[3];
答案是:parr1
parr1 先和 [] 结合,说明 parr1是数组,数组的内容是什么呢?
是 int (*)() 类型的函数指针。

21. 转移表

函数指针数组的用途:转移表
举例:计算器的⼀般实现:
#include <stdio.h>int add(int a, int b){ return a + b;}int sub(int a, int b){ return a - b;}int mul(int a, int b){ return a * b;}int div(int a, int b){ return a / b;}int main(){ int x, y; int input = 1; int ret = 0; do { printf("*************************\n"); printf(" 1:add 2:sub \n"); printf(" 3:mul 4:div \n"); printf(" 0:exit \n"); printf("*************************\n"); printf("请选择:"); scanf("%d", &input); switch (input) { case 1: printf("输⼊操作数:"); scanf("%d %d", &x, &y); ret = add(x, y); printf("ret = %d\n", ret); break; case 2: printf("输⼊操作数:"); scanf("%d %d", &x, &y); ret = sub(x, y); printf("ret = %d\n", ret); break; case 3: printf("输⼊操作数:"); scanf("%d %d", &x, &y); ret = mul(x, y); printf("ret = %d\n", ret); break; case 4: printf("输⼊操作数:"); scanf("%d %d", &x, &y); ret = div(x, y); printf("ret = %d\n", ret); break; case 0: printf("退出程序\n"); break; default: printf("选择错误\n"); break; } } while (input); return 0;}
使用函数指针数组的实现:
#include <stdio.h>int add(int a, int b){ return a + b;}int sub(int a, int b){ return a - b;}int mul(int a, int b){ return a*b;}int div(int a, int b){ return a / b;}int main(){ int x, y; int input = 1; int ret = 0; int(*p[5])(int x, int y) = { 0, add, sub, mul, div }; //转移表 do { printf("*************************\n"); printf(" 1:add 2:sub \n"); printf(" 3:mul 4:div \n"); printf(" 0:exit \n"); printf("*************************\n"); printf( "请选择:" ); scanf("%d", &input); if ((input <= 4 && input >= 1)) { printf( "输⼊操作数:" ); scanf( "%d %d", &x, &y); ret = (*p[input])(x, y); printf( "ret = %d\n", ret); } else if(input == 0) { printf("退出计算器\n"); } else { printf( "输⼊有误\n" );  }}while (input); return 0; }

22. 回调函数是什么?

回调函数就是⼀个通过函数指针调用的函数。
如果你把函数的指针(地址)作为参数传递给另⼀个函数,当这个指针被用来调用其所指向的函数 时,被调用的函数就是回调函数。回调函数不是由该函数的实现⽅直接调用,⽽是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进⾏响应。
第13讲中我们写的计算机的实现的代码中,红⾊框中的代码是重复出现的,其中虽然执⾏计算的逻辑是区别的,但是输⼊输出操作是冗余的,有没有办法,简化⼀些呢?
因为红⾊框中的代码,只有调用函数的逻辑是有差异的,我们可以把调⽤的函数的地址以参数的形式传递过去,使用函数指针接收,函数指针指向什么函数就调⽤什么函数,这⾥其实使⽤的就是回调函数的功能。
使用回调函数改造前:
//使⽤回调函数改造前#include <stdio.h>int add(int a, int b){ return a + b;}int sub(int a, int b){ return a - b;}int mul(int a, int b){ return a * b;}int div(int a, int b){ return a / b;}int main(){ int x, y; int input = 1; int ret = 0; do { printf("****************** printf(" 1:add  printf(" 3:mul  printf("****************** printf("请选择:"); scanf("%d", &input); switch (input) { case 1: printf("输⼊操作数:"); scanf("%d %d", &x, &y) ret = add(x, y); printf("ret = %d\n", r break; case 2: printf("输⼊操作数:"); scanf("%d %d", &x, &y) ret = sub(x, y); printf("ret = %d\n", r break; case 3: printf("输⼊操作数:"); scanf("%d %d", &x, &y) ret = mul(x, y); printf("ret = %d\n", r break; case 4: printf("输⼊操作数:"); scanf("%d %d", &x, &y) ret = div(x, y); printf("ret = %d\n", r break; case 0: printf("退出程序\n"); break;default: printf("选择错误\n"); break; } } while (input); return 0;}

使用回调函数改造后:
//使⽤回到函数改造后#include <stdio.h>int add(int a, int b){ return a + b;}int sub(int a, int b){ return a - b;}int mul(int a, int b){ return a * b;}int div(int a, int b){ return a / b;}void calc(int(*pf)(int, int)){ int ret = 0; int x, y; printf("输⼊操作数:"); scanf("%d %d", &x, &y); ret = pf(x, y); printf("ret = %d\n", ret);}int main(){ int input = 1; do { printf("****************** printf(" 1:add  printf(" 3:mul  printf("****************** printf("请选择:"); scanf("%d", &input); switch (input) { case 1: calc(add); break; case 2: calc(sub); break; case 3: calc(mul); break; case 4: calc(div); break; case 0: printf("退出程序\n"); break; default: printf("选择错误\n"); break; } } while (input);return 0;}

23. qsort使用举例

23.1 使用qsort函数排序整型数据

#include <stdio.h>//qosrt函数的使⽤者得实现⼀个⽐较函数int int_cmp(const void * p1, const void * p2){ return (*( int *)p1 - *(int *) p2);}int main(){ int arr[] = { 1, 3, 5, 7, 9, 2, 4, 6, 8, 0 }; int i = 0;  qsort(arr, sizeof(arr) / sizeof(arr[0]), sizeof (int), int_cmp); for (i = 0; i< sizeof(arr) / sizeof(arr[0]); i++) { printf( "%d ", arr[i]); } printf("\n"); return 0;}

23.2 使用qsort排序结构数据

struct Stu //学⽣{ char name[20];//名字 int age;//年龄};//假设按照年龄来⽐较int cmp_stu_by_age(const void* e1, const void* e2){ return ((struct Stu*)e1)->age - ((struct Stu*)e2)->age;}//strcmp - 是库函数,是专⻔⽤来⽐较两个字符串的⼤⼩的//假设按照名字来⽐较int cmp_stu_by_name(const void* e1, const void* e2){ return strcmp(((struct Stu*)e1)->name, ((struct Stu*)e2)->name);}//按照年龄来排序void test2(){ struct Stu s[] = { {"zhangsan", 20}, {"lisi", 30}, {"wangwu", 15} }; int sz = sizeof(s) / sizeof(s[0]); qsort(s, sz, sizeof(s[0]), cmp_stu_by_age);}//按照名字来排序void test3(){ struct Stu s[] = { {"zhangsan", 20}, {"lisi", 30}, {"wangwu", 15} }; int sz = sizeof(s) / sizeof(s[0]); qsort(s, sz, sizeof(s[0]), cmp_stu_by_name);}int main(){ test2(); test3(); return 0;}

24. qsort函数的模拟实现

使用回调函数,模拟实现qsort(采用冒泡的方式)。
注意:这⾥第⼀次使用 void* 的指针,讲解 void* 的作用。
1.void指针是一种特别的指针
2.任何指针都可以赋值给void指针
3.void指针赋值给其他类型的指针时都要进行转换
4.void指针不能解引用
#include <stdio.h>int int_cmp(const void * p1, const void * p2){ return (*( int *)p1 - *(int *) p2);}void _swap(void *p1, void * p2, int size){ int i = 0; for (i = 0; i< size; i++) { char tmp = *((char *)p1 + i); *(( char *)p1 + i) = *((char *) p2 + i); *(( char *)p2 + i) = tmp; }}void bubble(void *base, int count , int size, int(*cmp )(void *, void *)){ int i = 0; int j = 0; for (i = 0; i< count - 1; i++) { for (j = 0; j<count-i-1; j++) { if (cmp ((char *) base + j*size , (char *)base + (j + 1)*size) > 0) { _swap(( char *)base + j*size, (char *)base + (j + 1)*size, size); } } }}int main(){ int arr[] = { 1, 3, 5, 7, 9, 2, 4, 6, 8, 0 }; int i = 0; bubble(arr, sizeof(arr) / sizeof(arr[0]), sizeof (int), int_cmp); for (i = 0; i< sizeof(arr) / sizeof(arr[0]); i++) { printf( "%d ", arr[i]); } printf("\n"); return 0;}

25. sizeof和strlen的对比

25.1 sizeof

在学习操作符的时候,我们学习了 sizeof , sizeof 计算变量所占内存内存空间大小的,单位是字节,如果操作数是类型的话,计算的是使用类型创建的变量所占内存空间的大小。
sizeof 只关注占用内存空间的大小,不在乎内存中存放什么数据。
例如:
#inculde <stdio.h>int main(){ int a = 10; printf("%d\n", sizeof(a)); printf("%d\n", sizeof a); printf("%d\n", sizeof(int)); return 0;}
运行结果:

25.2 strlen

strlen 是C语言库函数,功能是求字符串长度。函数原型如下:
size_t strlen ( const char * str );

统计的是从 strlen 函数的参数 str 中这个地址开始向后, \0 之前字符串中字符的个数。
strlen 函数会⼀直向后找 \0 字符,直到找到为⽌,所以可能存在越界查找。
#include <stdio.h>int main(){ char arr1[3] = {'a', 'b', 'c'}; char arr2[] = "abc"; printf("%d\n", strlen(arr1)); printf("%d\n", strlen(arr2));  printf("%d\n", sizeof(arr1)); printf("%d\n", sizeof(arr1)); return 0;}

25.3 sizeof 和 strlen的对比

sizeof

1. sizeof是操作符
2. sizeof计算操作数所占内存的大小,单位是字节
3. 不关注内存中存放什么数据

strlen

1. strlen是库函数,使用需要包含头文件 string.h
2. srtlen是求字符串长度的,统计的是 \0 之前字符的隔个数
3. 关注内存中是否有 \0 ,如果没有 \0 ,就会持续往后找,可能会越界

PS:看到这里了,码字不易,给个一键三连鼓励一下吧!有不足或者错误之处欢迎在评论区指出! 

点击全文阅读


本文链接:http://zhangshiyu.com/post/83244.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1