C语言进阶
C语言进阶
一、数据储存
1.栈
C语言中,栈区是用于存放局部变量的。
栈区的使用习惯是:先使用高地址,再使用低地址
2.release和debug的区别
在基础章节里说了,它们面向的对象是不一样的,但除了面向的对象不一样外还有一些地方也是不一样的,例如下面的代码:
1 | int main(){ |
我们知道这样的写法会导致指针越界,但这串代码在debug和release中的运行结果是不一样的。
在debug中的运行结果如下:
可以看到这个是一个死循环了。
而在release中:
他运行了几次就结束了。
为什么会出现上面的情况呢?这就需要介绍debug和release在内存中的储存了。
2.1 debug的内存结构
我们知道,在栈中先进后用,加入我们还是使用上面的代码,我们输出一下 i
和 arr
的地址。
代码如下:
通过这一行代码我们可以输出在debug方式中局部变量的地址值
我们可以看到,在debug方式运行后,arr
变量的地址比i
的地址低,在内存结构中,图是这样画的:
arr
只有个数,所以索引的值为0-9
从这个位置开始走,给它赋值为0,走着走着,到arr
最后一个元素时,i
的大小还没到12,arr[i]
还要继续赋值,一直走到i
地址的位置,然后将i
又重新赋值为0
一直重复该过程,就导致了死循环。
2.2 release的内存结构
我们还是通过上面的方法来进行分析,代码如下:
然后运行的结果为:
在release中,arr
的地址要高于i
变量的地址,所以无论我们索引是否超过数组arr
内元素的值,所以变量i
就不会被重新的覆盖,就不会出生死循环。
3.数据在内存中存储
3.1 数据类型
3.1.1 整型
1 | char |
3.1.2 浮点型
1 | float |
3.1.3 构造类型
自定义类型
1 | 数组类型 |
3.1.4 指针类型
1 | int* p; |
3.1.5 空类型
void
用于:函数的返回类型 void test();
函数参数 void test(void);
指针 void* p;
3.2 探究整型在内存中存储模式
在探究这个问题的时候,我们拿一个代码在演示
如下的代码:
然后我们调用vs2019中的调试模式,注意,调试时请一定确定是在debug
模式下,否则调试会失败
打开后
我们找到调试
中的窗口
,在里面找到内存
,然后选择一个进行查看
进去后我们可以看到,调试器并没有定位到我们创建的变量a上
所以我们要通过a的地址来查找a的地址,我们找到&a
能获得a的地址,所以我们在查找
那一行输入
然后按回车键
我们知道,数据在内存中都以二进制的形式存储
对呀整数来说,整数二进制有3种表示形式
3.2.1 原码、补码、反码
1 | 原码:十进制的二进制形式就是原码 |
注:正数的原码、补码、反码相同
int
类型的变量在内存中占4个字节,1个字节等于8个比特,所以就有32位二进制的数,为了方便我们查看,计算机转换为十六进制的数。
我们知道,4位二进制数等于一位十六进制数,然后32位二进制数就应该等于8个十六进制数,所以我们要将列改成4位
改完之后就是这样的了
3.2.2 原码、补码、反码(续)
刚刚只是简单介绍了一下原码、补码、反码,还说正数的原码、补码、反码相同,那负数呢?
现在我们介绍一下负数的原码、补码、反码的计算
例如:我们有一个a的值位-10,在int
型中
1 | a = -10 |
它的原码的形式是
1 | 1000 0000 0000 0000 0000 0000 0000 1010 |
然后我们把原码转换为反码后,反码如下:
1 | 1111 1111 1111 1111 1111 1111 1111 0101 |
之后我们再将反码+1就可以得到补码,补码如下
1 | 1111 1111 1111 1111 1111 1111 1111 0110 |
之后我们再把补码转换成十六进制的形式就得到:
1 | FF FF FF F6 |
我们把刚刚的代码改变一下
然后使用上面的方法来查看变量a中存放的数
我们可以看到,a变量中的-10存放的方式是以补码的形式存放的,所以我们就可以得出一个结论。
1 | 结论:数据在内存中是以补码的形式进行存放的 |
为什么在内存中数据是以补码的形式进行存储的呢?
3.2.3 为什么存放补码
我们知道,在CPU中只有加法器而没有减法器,我们要计算1-1其实是计算1+(-1)
,如果我们直接拿原码相加的话:
1 | 1的原码0000 0000 0000 0000 0000 0000 0000 0001 |
上面的结果换算出来是-2,肯定是不对的,但如果我们用原码来计算:
1 | 1的原码0000 0000 0000 0000 0000 0000 0000 0001 |
我们发现它已经超出32位了,但是我们只能存放32位长度的数,所以33位的1要省略,所以最后的结果为:
1 | 0000 0000 0000 0000 0000 0000 0000 0000 |
结果就为0。
补码和原码的互相转换是非常方便的,它们的电路是一样的,就比如-1的原码为:
1 | 1000 0000 0000 0000 0000 0000 0000 0001 |
补码为:
1 | 1111 1111 1111 1111 1111 1111 1111 1111 |
我们将补码取反再加1就能得到原码。
补码取反+1
1 | 1000 0000 0000 0000 0000 0000 0000 0001 |
所以为什么数据在内存中存放的形式必须是补码了。
我们回到最上面,为什么我们自己算出来的是
1 | FF FF FF F6 |
但在vs2019中的存储却是
1 | F6 FF FF FF |
呢?
这个就是需要知道大端存储和小段存储了
3.2.4 大端存储和小段存储
大端存储和小段存储是我们了解数据在内存中存储模式最需要了解的一个知识点
3.2.4.1 大端和小段
在开始讲大端存储和小段存储之前,我们先了解一下数据的大端是什么,小段是什么,有微机接口原理基础的小伙伴可以跳过这一节。
我们先用十进制给大家说,在十进制中,数据从左到右位数越来越大,例如我们有一个数为5623,5为千位,6为百位,2为十位,3为个位,所以5就为这个数的大端,3为这个数的小段。
但上面的这个例子只是让大家知道大端是在左,小段是在右,在计算机内部并不是以上面那个例子来的。
在计算机中,数据是以二进制的形式存放的,我们这里拿两位十六进制数来举例子,如下图:
(2为十六进制数等于8位二进制数)
在这个数中,前4位为大端,后4位为小段
这样我们就知道二进制的大端和小段的位在哪了。
3.2.4.2 什么是大端存储
大端存储又可以叫大端字节序
大端字节序是把数据的小段字节放在高地址处,把大端字节放在低位地址处。
举个例子:如果我们有一个数据
1 | 1001 1100 |
这个数据放在大端字节序中的存放方式是:
3.2.4.3 什么是小端存储
小端存储又可以叫小端字节序
小段字节序是把数据的小端字节放在低地址处,把大端字节放在高地址处。
例如:我们拿-10的二进制来距离
1 | FF FF FF F6 |
用大端字节存储的话就是:
1 | FF FF FF F6 |
我们拿大端存储的那个例子来说,在小端字节序中的存放方式是:
3.2.5 通过代码判断当前机器的字节序
通过上面的内容我们了解了数据在内存中的存放形式,还有存储方式中的大端存储和小端存储,通过这些我们可以通过代码来判断,一个数的存放方式为大端还是小端了。
补充:在调试的情况下,一般在左边的为低地址,右边为高地址
接下来我们要写代码对大端和小端进行判断了。
我们现在有一个int
型变量a,它里面的值为1(这里我们拿1来举例子)
1 | int main(){ |
int
类型的数在内存中占4个字节,也就是32位,8个十六进制数,在内存中的存放为:
1 | 00 00 00 01 |
如果它在小端存储的话,写法应该是:
1 | 01 00 00 00 |
如果是大端存储的话,写法应该是:
1 | 00 00 00 01 |
所以我们要进行判断就需要判断第一个2位十六进制数即可。
问题又来了,我们该如何判断第一个2位十六进制数呢?我们知道char
类型占一个字节,也就是8位,再换就是2个十六进制数,但我们要专门指向该用什么呢?
没错,就是使用指针,指针是指向地址的首地址值,如果指向的第一个数是1,那就证明是小端存储,而如果第一个数是0,则证明是大端存储。
所以我们代码就可以这样写:
1 | int main(){ |
一定要强制转换一下类型,虽然不会报错,但还是养成规范写法吧。
3.2.6 练习
3.2.6.1 练习1
1 |
|
**分析:**这道题考察的是原码补码的转换,还有不同类型的位数,和整型提升
-1的原码:
1 | 1000 0000 0000 0000 0000 0000 0000 0001 |
然后转换为补码:
1 | 1111 1111 1111 1111 1111 1111 1111 1111 |
因为a是char
类型的,所以要阶段,留8位,所以为:
1 | 1111 1111 |
但是输出需要整型提升,整型提升看有无符号位,如果有符号位,补1,没符号位补0,
char
类型有符号位,所以我们将char
类型的-1补位:
1 | 1111 1111 1111 1111 1111 1111 1111 1111 |
然后计算它的原码:
1 | 1000 0000 0000 0000 0000 0000 0000 0001 |
所以输出-1,signed char
类型也是一样的。
但unsigned char
整型提升是提升0:
1 | 0000 0000 0000 0000 0000 0000 1111 1111 |
因为最高位是0,是正数,原码就等于补码,所以输出为:255
补充
1.char
是 signed char
还是 unsigned char
C语言中是没有规定的,取决于编译器
2.int
是signed int
还是 unsigned int
int
就是 signed int
short
默认 signed short
3.2.6.2 练习2
1 |
|
分析:-128的二进制数为:
1 | 1000 0000 0000 0000 0000 0000 1000 0000 |
补码为:
1 | 1111 1111 1111 1111 1111 1111 1000 0000 |
因为是 char
类型进行存放的,所以只保留后8位,所以保存的形式是:
1 | 1000 0000 |
到输出位时,要求的是打印一个无符号的整数,所以要整型提升,整型提升时看存放的类型,在编译器中默认存储的是 signed char
类型的,所以前面补1,就得:
1 | 1111 1111 1111 1111 1111 1111 1000 0000 |
因为需要打印的无符号数,所以最高位不是符号位,答案就为:
1 | 4294967168 |
3.2.6.3 练习3
1 |
|
这道题和练习2的答案一样。
128在内存中的存放是:
1 | 0000 0000 0000 0000 0000 0000 1000 0000 |
但 char
只能存放8位,所以是:
1 | 1000 0000 |
然后进行整型提升:
1 | 1111 1111 1111 1111 1111 1111 1000 0000 |
所以答案是:
1 | 4294967168 |
补充
探讨char类型的取值范围
在char类型中可以存放8位比特位,所以在内存中可以取:
1 | char 0000 0000 |
前面0开头的都是正数,1开头的是负数,因为正数的原补码相同,所以前面存储的是0~127。
在负数中,我们要将补码变成原码,例如1111 1111的原码:
1 | 1111 1111 |
然后取反加一就得原码:
1 | 1000 0001 |
我们转换一下这个数是不是等于-1。
一直这样算,1000 0001的原码是:
1 | 1111 1111 |
转换一下就是-127。
1000 0000这个数没有办法再进行计算了,它默认为-128
如果我们在 char
类型中存放128,它会默认为是-128。
3.2.6.4 练习4
1 |
|
这道题非常的简单,重点就是二进制的加法,答案是:
1 | -10 |
3.2.6.5 练习5
1 |
|
这个代码是死循环,因为 i
是无符号数据,所以最小的是0。
3.2.6.6 练习6
1 |
|
分析:char
类型有一个存储数据值的图:
正常进行 char
类型加法的话,图应该是这样走的:
但是我们现在是进行减法,所以这幅图要倒着计算:
程序开始,整型-1-0,得到的结果为-1,-1-1,得到的结果为-2…..然后到-1-128,得到的结果为127。
继续相减,-1 - 254 = 1,-1 - 255 = 0,-1 - 256 = -1….一直重复,直到不满足条件为止,然后执行 strlen()
,
这个函数是遇见 \0
就结束,我们知道 \0
就是 0
,所以就128 + 127 = 255,结果就为255。
3.2.6.7 练习7
1 |
|
**答案 **:死循环
**分析:**变量 i
的类型为 unsigned char
,最大值为255,当 i
到最大数后再加1就得0了,就变成了死循环了。
3.3 探究浮点型在内存中存储模式
在开始仔细研究浮点数在内存中的存储模式之前,我们先看下面的这一道练习题:
1 |
|
这道题很容易就会写错,大家可能觉得输出的结果为:
1 | n的值为:9 |
那这样就错了,我们拿到dev-c++中进行一下验证:
是不是跟我们自己想的输出不一样,为什么会出现这种情况?这就要介绍一下浮点型数据在内存中存储模式了。
3.3.1 在内存中浮点数的存储
在C语言中存储浮点数是按照IEEE 754的规则进行存储的。
IEEE 754规定存储浮点数的方式为:S E M
。
S:符号位,正数写0,负数为1.
E:指数。
对于指数E的情况是比较复杂的,因为一些时候指数为正数,而有些时候指数为负数,所以就找一个中间数,让指数在存储前需要加上中间数。对于8位E(也就是float数据),中间数为127,而对于11位E(double型数据),中间数为1023。
M:有效数字。
在存储时会省略小数点前的一个数,因为前面的那个数默认为1,因此可以被省略,拿出时只需要加上1即可。
留给M的位数:
float:23位
double:52位
例如浮点数5.5f用二进制的形式转换为:
1 | 0101.1 |
然后我们把它移动一下变为:
1 | 1.011 * 2^2 |
s = 0
M = 1.011
E = 2
因为是存在float中,所以E位要加127,在内存中的存储就应该为:
1 | 0100 0000 1011 0000 0000 0000 0000 0000 |
转换为十六进制为:
1 | 40 B0 00 00 |
我们在程序中走一下看看结果是不是一样
倒着读,是不是和我们计算的一样呀。所以浮点数在内存中存储的方式也是分大小端的。
3.3.2 从内存中读浮点数
从内存中读取浮点数的方法是需要具体分析的
E不全为0或不全为1
就那我们上面的5.5f举例子
1 | 0100 0000 1011 0000 0000 0000 0000 0000 |
这样子看有点复杂,我们把它写成SEM
形式
1 | 0 10000001 01100000000000000000000 |
我们将E减去刚才加的127,然后就得:0.011*2^2,之后我们把1加上去,就得1.011*2^2。转换一下就得101.1
E为全0
如果E为全0,那有效数字M前面不再加1。这样做是为表示+-0,以及接近0的数
E为全1
表示+-无穷大(正负有S决定)
我们知道浮点数在内存中的存储模式后,我们就可以分析最开始的那个题目了
3.3.3 分析最开始的例子
1 |
|
开始时有一个int
型变量n,n中的值为:
1 | 0000 0000 0000 0000 0000 0000 0000 1001 |
之后float*
获取的直接是n变量的值,它默认上面n的二进制数为浮点数的形式
1 | 0 00000000 00000000000000000001001 |
printf("n的值为:%d\n", n);
这个输出没什么问题,只要是printf("pFloat的值为:%f\n", *pFloat);
的输出
因为它默认为浮点数的存储方式,所以要输出出来就是要把上面二进制转换为十进制数,而上面这种情况是我们E为全0
的形式,所以M位前面不+1。
转换的结果就为:1.001 * 2^-127,这个数接近为0,我们又知道,%f
只能输出小数点后6位的数,所以第二个输出就为:0.000000
之后我们将指针变量pFloat
中的内容修改为9.0,9.0的二进制为:
1 | 1001.0 |
在内存中的存储形式为:
1 | 0 10000010 00100000000000000000000 |
执行代码printf("n的值为:%d\n", n);
,输出是以%d
整型的输出方式,直接将上面的二进制看成整型的二进制,最高位为0,所以是正数直接输出出来,答案就为:
1 | 1091567616 |
和我们执行代码输出的结果一样。
执行代码printf("pFloat的值为:%f\n", *pFloat);
就将浮点型的二进制转换成十进制的浮点型。
二、指针进阶
之前我们学了基础的指针,现在我们要对指针进行更深入的讲解。
我们知道
- 指针就是个变量,用于存放地址的变量。
- 指针的大小是固定的4/8个字节(32位/64位)
这些是比较基础的概念,我们讲指针进阶先从字符指针开始讲
1.字符指针
我们知道,在C语言中我们可以通过数组的方式来存储字符和字符串,但其实指针也可以存储字符串的。
例如下面的代码:
1 | int main(){ |
我们定义了一个字符型指针p
,但我们知道指针变量最高只能存放8个字节,但我们给p
赋值的变量有12个字节(最后的\0
),指针应该是存不下的,那为什么我们说指针可以存放字符串呢?
是因为指针并不是存放这个字符串,而是存放字符串的首元素地址
我们可以验证一下这个说法,我们将*p
进行输出,如果输出的结果为h
,则指针就是存放字符串的首元素地址。
我们在dev-c++中将代码写好
然后运行:
看,结果是不是和我们猜想的一样
字符指针存放的其实是字符串的首元素值
可能有些同学会说是不是和数组一样的存放方式呀?
其实是不一样的,在数组中存放的是整个字符串,但是指针存放不了整个字符串,所以只能存放首元素的地址。
还有一点就是指针储存的字符串是不能修改里面的值的
例如:我们将首元素的地址中的元素修改
运行的结果:
可以看出,我们使用指针方式是不能修改字符串中的字符的。
1.1 面试题
1 | int main(){ |
这一道题会输出什么结果呢?
我们知道,数组是单独开辟一个空间来存放内容,但指针是使用的是共用的地址
我们从内存的角度进行分析,在内存中变量是存放在栈中
我们创建了一个 str1
的数组,在栈中的存放:
然后在里面存放的值为”hello,world”, str1
指向就为:
之后我们又创建了一个变量 str2
,给它的值也为”hello,world”,但在数组变量中就算是相同的值也会重新开辟空间来存放
存放情况:
所以在数组变量中,我们在不同的数值中存放相同的值,它们的地址指向绝对不可能是相同的。
之后我们创建了一个 char
型的指针 str3
,创建完后在内存中会创建一个字符串”hello,world”
然后指针 str3
就会指向创建出来的字符串的首元素地址
之后又创建了一个 char
型的指针 str4
,这个指针指向的也是字符串”hello,world”,内存中已经存在字符串”hello,world”,所以就不需要再重新创建,指针 str4
直接指向创建出来的字符串”hello,world”了
这两个指针都指向的是同一个字符串的首元素地址,所以它们的地址相同。
2.指针数组
在前面我们知道,指针数组就是存放指针的数组,它的使用方法:
1 | int* arr[2]; |
上面的代码是一个数组中可以存放2个 int
型的指针变量。
使用的例子:
1 | int main(){ |
但是这个用法基本上没什么人使用。
一般指针数组的使用是
1 | int main(){ |
将多个数组的地址存入一个数组中,方便使用。
上面那个代码的输出其实也可以写成
1 | printf("%d ", *(arr[i][j])); |
使用解引用 []
意思也是和+j一样的,只不过写法不一样。
在进阶内容中我们重点不是指针数组,指针数组在基础部分已经讲完了,现在的重点是我们在基础没有讲的一个知识,就是数组指针。
3.数组指针
我们前面学习了整型指针,浮点数正整,字符型指针,这些都是指针,数组指针也是一个指针
整型指针就是操作整型的指针,数组指针就是操作数组的指针。
3.1 定义
我们前面学过,数组名就是首元素的地址,我们 &数组名
得到的是整个数组的地址,我们可以使用 &数组名
来获取数组的地址然后存入指针中。
但这个数组指针如何定义?
数组指针的定义如下:
1 | int arr[5] = {0}; |
怎么理解呢?
我们一步一步来
首先我们先写 &arr
,这个获取的是 arr
的地址值
1 | &arr; |
我们取了地址后需要一个指针来存放
1 | *pa = &arr; |
指针pa
是需要有类型的,pa
的类型是数组,而在C语言中要表示数组类型是用type [number]
来表示的,所以我们可以按照这样来进行写
1 | int *pa [5] = &arr; |
但是我们看,如果这样写,计算机会先将
1 | pa[5] |
结合在一起,然后就变成了指针数组,但我们 *pa
是在一起的,所以我们需要用括号将 *pa
结合在一起
1 | int (*pa) [5] = &arr; |
注:解引用[]
中的值是该数组中的长度,要定义哪一个数组的指针,里面的值就为该数组的长度
3.2 指针数组和数组名
在定义中说到,数组名就是首元素的地址,指针数组获取数组的地址是整个数组的地址,我们可以通过下面的程序来说明
数组名和指针数组的地址都相同,那我们为什么还有指针数组这个东西呢?
我们用数组进行移位+1,得到的就是下一个元素的值
我们使用一下数组指针进行一下位移看看会发生什么
我们发现,通过数组指针+1后它直接就跳过20个字节,也就是一个数组
我们可以总结
数组指针前进是跳过这个数组
数组名就是首元素地址,但是有两个例外:
sizeof
后的数组名代表的是整个数组,单位是字节&数组名表示的是整个数组,取出的是整个数组的地址
3.3 数组指针的使用
3.3.1 一维数组中的使用
数组指针pa
中的地址是整个数组的地址,*pa
就为数组本身
例如我们有一个数组名,我们需要使用数组指针将它全部打印出来
1 |
|
也可以使用*pa
+i
得出下一个位置的地址出来,然后再进行解引用输出出来
1 |
|
两个的输出是一样的
我们也可以把输出的功能放在函数中使用
1 |
|
我们在函数中的形参中定义的是数组指针,输出的结果和上面的结果一样
但是一维数组基本上用不到数组指针,只有在二维数组中才能很好的使用
3.3.2 二维数组中的使用
一般使用二维数组的时候我们都是通过下面的代码来实现的
1 |
|
现在我们使用一下数组指针的方式来输出一下二维数组中的值
1 |
|
也可以使用
1 |
|
二维数组的数组名表示的是首元素的地址,二维数组首元素的地址是第一行,就比如我们上面的那个例子中的数组
二维数组的首元素的地址就是
所以我们的数组指针其实指向的是二维数组中的第一行,如果我们用这一行的地址加1就直接跳过第一行的所有元素,然后到第二行
所以我们的数组指针的正确指向其实是该数组每一行的长度。
输出的解释参考一维数组的输出即可,重点是二维数组的定义和指针到底指向的是哪一个地址。
3.3.3 使用函数输出数组传入加&
该怎么用
数组传入函数是传的地址值,我们可以在函数中使用指针或者数组来接受,一般都不使用 &
取址符来获取数组的地址,但如果你偏要使用 &数组名
那函数中该怎么写呢?
3.3.3.1 一维数组中
如果是一维数组的话,传的数组参数必须加上 &
看下面的代码
1 |
|
我们还是用数组指针进行接受,在上面代码中数组指针 pa
接受的是整个数组的地址,我们没有办法对它进行+1的操作,操作后就会跳过整个数组。
在上面说到, pa
是数组地址, *pa
就是整个数组,所以我们可以直接通过数组名[索引]
来转换成指针的形式 (*pa)[索引值]
当然我们也可以用下面的输出办法
1 |
|
这个原理也和上面的差不多,就是把 []
转换为+了。
3.3.3.2 二维数组中
在传二维数组的参数时我们一般都不添加 &
符号
如果传入函数中我们传 &数组名
那函数的形参该如何写呢?
用我们之前的那个函数的形参可以吗
很显然这个方法是不行的
我们可以通过上面学到的知识来说明这个问题
讲数组指针操作二维数组中说过,它获取的是二维数组的地址,二维数组的地址就是第一行的地址,我们其实是在对二维数组的行进行操作,也指定了操作的行的长度
而如果直接 &arr
,其实是将整个二维数组的地址传到这个函数中,我们要对整个二维数组进行操作就需要定义数组指针的长度,因为是二维数组,所以是需要有两个长度的。所以我们定义就需要这样定义
1 | void print(int (*pa)[3][5]) |
这样才是对整个二维数组的操作的方式。
我们现在知道函数的形参如何定义的了,那该如何对整个二维数组进行操作呢?
其实也和一维数组的操作差不多
第一种方法
1 |
|
因为我们传入的是整个二维数组的地址,所以 *pa
直接就是二维数组 arr
我们可以通过 *pa[][]
来获取值
这个办法是很简单的,但还有一个办法,有点复杂,需要仔细想想
1 |
|
这个办法我们在3.3.2中说过,只不过那种直接加得到的就是该元素的值,而我们这里得到的其实是地址,所以还需要使用*
来转义一下 ,才能得到值。
3.3.4 分析几个例子
我们先看下面的几个定义
1 | int parr1[5]; |
parr1很简单这就是数组
parr2也容易理解,是一个指针数组
parr3我们也学过,是一个数组指针
parr4有点复杂,我们之前没有见过。
我们可以一个一个的分析,看到
(*)
的就可以理解为一个数组指针,然后parr4[10]
就是一个数组,这就是一个存储数组指针的数组,该数组存放的长度为10,每个数组指针能指向一个数组,然后指向的数组的长度是5
4.数组参数、指针参数
4.1 一维数组传参
在一维数组中,传参有几种方法是可以的
第一种
形参是数组
1 | void test(int arr[], int num){ |
第二种
形参是指针
1 | void test(int* pa, int num){ |
第三种
形参是数组指针
1 | void test(int (*pa)[5], int num){ |
第四种
形参是指针数组
1 | void test(int* arr[20], int num){ |
第五种
形参是二级指针
1 | void test(int* *ppa, int num){ |
4.2 二维数组传输
第一种
形参是二维数组
1 | void test(int arr[][5], int left, int right){ |
第二种
形参是数组指针
1 | void test1(int (*pa)[5], int left, int right); |
不能写的
形参为一级指针
1 | void test(int* arr){ |
形参为二级指针
1 | void test1(int **pa, int left, int right); |
形参为指针数组
1 | void test1(int* pa[5], int left, int right); |
形参为二维数组,但第二个 []
中的数值省略
1 | void test(int arr[3][]){ |
一定要看好传入的是什么类型的,要用相同的类型去接收
4.3 一级指针传参
我们写一个程序的时候,一般是先写main函数里的东西,如果要传参数给函数也是规定好函数的形参是是什么形式的
例如我们下面写好了一个main函数,我们也规定函数test2中的形参为一个指针和一个整数来接受
1 | int main(){ |
所以我们的函数的写法就如下
1 | void test2(int* pa, int num){ |
但如果我们有一个写好的函数,我们需要使用,能给这个函数传什么值进去呢?
例如下面的这个函数
1 | void print(char* pa){ |
我们看,我们有一个函数,形参是一个指针,我们需要给这个函数传参数,只能选择传地址。
主函数main中有一个 char
类型的变量a
1 | int main(){ |
要把这个值传入函数中其实有两种写法
第一种直接就是把a的地址传入函数中
1 | int main(){ |
第二种写法就是用一个指针来接收a的地址,然后再传入函数中
1 | int main(){ |
这两种方式都要明白,传一级指针用什么接收,形参为一级指针该传什么值我们需要明白。
4.4 二级指针传参
当有一个二级指针
1 | int main(){ |
需要将这个二级指针传给一个函数中使用,函数的形参的写法可以是:
1 | void print(int** ppa){ |
那反过来,如果这个函数写好了
1 | void print(int** ppa){ |
该传什么值呢?有两种办法
第一种:
我们直接将定义的一级指针的地址传进去
1 | int main(){ |
第二种方式是定义二级指针,然后把二级指针传进去
1 | int main(){ |
第三种方式是把指针数组传进去
1 | int main(){ |
因为数组中是存放一级指针,所以可以使用二级指针的方式来接收,但尽量不要使用该方法,有可能会出现野指针的情况。
内容不是很难,重点是要将这些方法记住,并且能熟练的使用。
5.函数指针
在C语言中,所有的数据都有它的地址,整型变量、数组等都有地址,在函数中,函数也是有地址的,比如我们创建一个函数来输出它的地址
1 | int Add(int x, int y){ |
运行的结果:
所以函数是有地址的,只要有地址就可以使用指针来进行接收
5.1 函数指针的定义
可以结合数组指针的定义来互相推
1 | 函数返回值类型 (*指针名)(形参类型) = &函数名 |
举个例子
1 |
|
在初学的时候,可能会写成下面这个样子:
1 |
|
这个写法是错误的,因为 *
的结合优先级低,会让 pa(int x, int y)
先进行结合,然后再和 *
结合。
5.2 函数指针的使用
定义就是为了使用,定义好之后该如何进行使用,是需要知道的重点。
从上面定义知道, pa
是函数 Add
的地址,在前面如果加上 *
就直接是函数了,所以可以这样
1 |
|
答案是没有问题的。
5.3 探究函数的地址
在数组前面说过输出数组的地址可以用 &数组名
,也可以直接使用 数组名
1 | int main(){ |
带入函数中,&函数名
是函数的地址,那直接输出 函数名
得到的是不是函数的地址呢?
测试一下
1 | int Add(int x, int y){ |
通过上面的运行结果,可以发现,使用 &函数名
和 函数名
的输出结果是一样的,所以可以总结为
函数名是函数的地址
在使用的时候是直接通过
1 | 函数名(); |
进行调用的
可以尝试直接使用指针名来调用函数
1 | int Add(int x, int y){ |
可以看出,直接用指针名也可以调用函数,所以可以总结
可以使用函数指针直接调用函数
但不能使用数组指针直接调用数组
当然,将函数的地址传给函数指针的时候也可以不添加 &
就可以获得函数的地址
1 | int Add(int x, int y){ |
5.4 阅读代码
1 | (*(void (*)())0)(); |
出处:C陷阱和缺陷
这段代码得一步一步分析,void (*)()
是一个函数指针类型,它将 0
强制类型转换成一个函数指针的地址
然后对其解引用 *(void (*)()0)
后进行了调用
(*(void (*)()0))()
。
也就是调用了地址为0处的函数,它的返回值为0,无形参。
1 | void (* signal(int, void(*)(int)))(int); |
这个函数其实就是指针函数套一个指针函数
先看 signal(int, void(*)(int))
这是一个函数,第一个参数是 int
第二个参数是指向返回值为 void
形参为 int
的函数。
而这个函数它的返回值为一个函数指针。这个函数指针是指向返回值为 void
,形参为 int
的函数。
简化一下这个代码就能能看懂了
1 | void (*) (int) signal(int, void (*)(int)); |
这个函数的返回类型是一个函数指针类型,但是这种写法在C语言中是不能支持的,如果一个函数的返回类型是函数指针类型,那它的写法只能是
1 | void (* (signal(int, void (*)(int)))) (int); |
如果真的要简化其实也是可以的,可以使用 typedef
对类型重命名
在讲用 typedef
简化前,先把前面漏的东西补充一下
5.4.1 typedef
typedef
的作用就是对类型进行重新命名
比如要将 unsigned int
类型名字变成 ui
来使用,就需要在程序中这样写
1 |
|
运行的结果
如果你要将一个函数指针重新命名使用下面的代码是不行的
1 |
|
这个和5.4中的不能简化的那个一样
如果要给函数指针重新命名只能写成下面的样子
1 |
|
写成这样就不会出现问题了
现在就可以进行简化了,将函数指针类型重新命名,然后再使用
1 |
|
这样看是不是更好的就明白这个代码的意思了。
6.函数指针数组
之前学过指针数组,指针数组就是存放指针的数组
例如需要有一个数组来存放5个整型指针,那代码可以这样写:
1 | int* pa[5]; |
那函数指针数组不就是存放函数指针的一个数组吗,按照上面的写法改一下
1 | int (*)() pa[5]; |
但是从上面知道,不能这样写的,虽然 int(*)()
是函数指针类型,但是不能放在前面,数组名放在后面,而是要把数组名和 *
放在一起
1 | int (* pa[5])(); |
6.1 使用例子
例如有两个函数
1 | int Add(int x, int y){ |
可以看到这两个函数的返回值类型和形参类型都一样,可以直接使用一个函数指针数组将它存放在里面
main函数就可以这么写
1 | int main(){ |
这里直接写的是函数的名字,因为在5.3中讲到,函数名就是函数的地址,所以这直接可以将函数名存储在函数指针中。
也可以这样写
1 | int main(){ |
6.2 函数指针数组的使用
和查找数组中元素的方法一样,通过数组的索引来查找,只不过这里得到的是函数的地址,但前面说过,函数名就是函数地址,可以直接通过函数地址来调函数
还是用上面那个例子,已经将函数指针存放在函数指针数组中后,进行调用
1 | int Add(int x, int y){ |
知道函数指针数组的使用后,就可以找一个项目来练习了。
6.3 计数器项目
需要写一个程序,这个程序能做整型的加减乘除。
在没有学会函数指针数组前,写的程序应该是这样的
1 |
|
虽然这个程序可以执行,但是有很多地方有冗余(rong yu),重复得太多是不好的,可以使用函数指针数组来代替,因为这些函数都是同样的返回类型和同样的形参,所以可以使用函数指针数组来替代
替代之后的程序就为
1 |
|
是不是感觉上面的代码比之前的代码要简单且没造成冗余,这个使用了函数指针数组,通过函数指针数组存放函数地址,然后再通过函数指针数组的索引调用函数,这样就不用一直用 switch
和 case
进行反复判断,而且在后面需要增加新功能时也能很好的添加。
这种方法用专业的说法就是:转义表
在《C和指针》中提到
6.4 取函数指针数组的地址
之前学数组的时候知道,数组名就是数组地址,只要是地址就可以用指针来接收
比如说有一个整型变量 a
,可以使用一个整型指针来进行接收
1 | int a = 5; |
pa是指向【整型】的指针
比如说现在有一个数组,需要获得这个数组的地址,可以使用数组指针来进行接收
1 | int arr[5]; |
pa是指向【数组】的指针
再比如说有一个指针数组,需要获取指针数组的地址,可以使用
1 | int* arr[5]; |
pa是指向【整型指针的数组】的指针
那现在有一个函数指针,取它的地址值,用函数指针数组来接收
1 | int (*pa)(int, int); |
pa是指向函数指针的数组
那现在需要将函数指针数组的地址取出来,用一个指针来接收地址,那这个类型该如何写呢?
现在一步一步进行分析
有一个函数指针数组
1 | int (* pa[5])(int, int); |
取这个数组的地址
1 | &pa; |
用一个指针进行接收
1 | *ppa = &pa; |
但这个类型应该为函数指针的数组,所以类型应该为
1 | int (* [5])(int, int); |
如果直接把指针名加到后面
1 | int (* [5])(int, int) (*ppa); |
是不可以的,可以参考5.4,所以应该写为
1 | int (* (*ppa)[5])(int, int) = &pa; |
本节内容了解即可
7.回调函数
7.1 什么是回调函数
回调函数可以理解为函数的形参是函数指针,并且在函数中调用函数指针。
例如有一个a函数,需要它在b函数中使用
它的传送就是这个样子。
7.2 计算器(回调函数)
继续使用6.3的计数器的例子,这里使用的是没有进行优化的版本
1 |
|
在没有用函数指针数值进行优化的例子中有好多地方冗余,其实可以把冗余的部分放在一个函数中,使用的时候直接调用函数即可。
但在每一行都调了不同的函数,所以就需要使用回调函数在重新定义的函数中调用
优化后的代码
1 |
|
8.快速排序函数
在讲数值的时候讲到了一个冒泡排序的算法,但这种方法只能对整型数组进行排序,无法对其他类型进行排序,所以要介绍一种可以对任意类型进行排序的函数 qsort
8.1 qsort
的结构
在学习一个函数时,需要了解这个函数的形参和返回值。
可以通过 cplusplus
网站来查找这个函数的结构
1 | void qsort (void* base, size_t num, size_t size, |
上面的就是 qsort
函数的形参,现在一步一步分析,等后面就可以自己仿照写一个 qsort
函数了。
首先看第一个形参 base
,是一个无类型的指针,因为函数一开始的设计者考虑到使用者可能会使用其他类型的值,所以直接就使用无类型的指针来接收。而这个接收的是需要排序的数组/结构体的地址
再看第二个形参 num
,这个参数的功能是接收数组的长度的,之前在写冒泡排序的时候也有这一个参数。
第三个形参 size
,这个函数的第一个形参接收的是一个无类型的指针,不是指定形式的指针,没办法通过+1来查找下一个数,所以需要用户提交一下这个数组类型的大小。
最后一个形参是一个函数指针,指向的函数的返回值为 int
,两个形参都为无符号的指针。这个是需要用户自己写一个函数然后传进去,这个函数的功能主要是提供一个判断数,用这个判断数来决定是用升序还是降序,而返回的值要么大于1(>1),要么等于1(=1),要么小于1(<1)。
上面的内容可以会容易迷糊,所以举个使用例子
8.2 qsort
的使用(数组)
先拿一个int
型的数组来进行一下排序
1 | int main(){ |
在主函数中调用一下 qsort
函数
1 | int main(){ |
现在就需要传函数的参数了,第一个参数是一个指针,所以需要把数组名传入,第二个是数组的长度,所以需要使用 sizeof
来计算,并传进去,第三个参数是类型的大小,也可以用 sizeof
来计算,所以前3个参数可以这么写
1 | int main(){ |
最难写的是第四个参数,需要使用者自己写一个判断函数,在官方文档中的写法
1 | int cmp(const void* pa, const void* pb){ |
解释一下,因为无类型的指针没有办法取得对应位置的值,所以需要强制类型转换一下,把类型转换成数组的类型进行计算,计算完后需要取到返回值,得到返回值后就可以进行排序了。
完善一下写法
1 | int cmp(const void* pa, const void* pb){ |
在dev++中演示一下运行的结果
如果这个函数只能对整型或者浮点型进行排序的话,就没有必要花时间来说了。
现在来对字符数组进行一下排序
8.3 qsort
的使用(字符数组)
在C语言中,存放字符其实是存放该字符的ASCII码,所以字符也是可以比较大小的,还是按照上面的写法
1 | int cmp(const void* pa, const void* pb){ |
运行的结果
现在来测试一下结构体中的数组的排序
8.4 qsort
的使用(结构体)
结构体中的数组的排列和上面的一样,先创建出一个记录学生消息的结构体 Stu
1 | struct Stu{ |
name
用于存放学生的名字
age
用来存放年龄
之后写主函数
1 | int main(){ |
第一个语句是创建一个结构体类型的数组 stu
并赋值
第二条语句是获取结构体数组的长度。
第三条语句就是调用一下 qsort
函数来进行排序,其中 cmp
函数的内容如下
1 | int cmp(const void* pa, const void* pb){ |
因为传入的是结构体,所以要按照结构体的写法来进行书写,需要对年龄进行排序,就调用 age
,如果需要对名字进行排序就调用 name
1 | int cmp(const void* pa, const void* pb){ |
回到上面,第四条语句是将结果打印出来,方便观察数据是否被修改。
上面的名字只是一个字符,如果按照真实的情况下,名字是一个字符串,如果要对字符串名字进行排序,那该如何写呢?
8.5 qsort
的使用(结构体更改)
现在需要结构体中的 name
存放字符串,所以要对结构体进行一下修改,只需要把它变成一个数组即可
1 | struct Stu{ |
这里需要介绍一个比较字符串大小的一个函数 strcmp
8.5.1 strcmp
比较字符的内核就是比较它们之间的ASCII码值,而比较字符串的办法也是一样的,只不过比较繁琐,不适合在开发中花时间写,所以C语言的工程师很贴心的写好了比较字符串的函数,用户可以直接使用
继续回到上面,现在已经学会了 strcmp
函数后,可以使用三种办法来解决上面的问题
8.5.2 第一种办法
第一种办法就是直接调用 strcmp
函数
1 | struct Stu{ |
因为 strcmp
的返回值是1,-1,0,所以直接就可以返回。
8.5.3 第二种方法
直接使用 -
来获得
1 | struct Stu{ |
这种方法也可以得到排序的结果。
8.5.4 第三种方法
这种方法特别不推荐,这种方法主要是让自己写一个 strcmp
函数来使用,觉得麻烦的可以直接跳过
strcmp
主要是对字符串进行比较,传入的值主要是字符型的数组,所以自己写的 my_strcmp
的形参为
1 | my_strcmp(const char* pa, const char* pb) |
在测试 strcmp
函数的时候,它的返回值只有3种情况,并且都是 int
型的,所以返回值的类型就应该为 int
,再完善一下写法
1 | int my_strcmp(const char* pa, const char* pb) |
现在要对函数内部功能开始书写
要模仿前先要确定一下 strcmp
如何判断哪一条字符串大,判断字符串的大小其实就是单个字符的比较,如果 str1
中有一个位置上的字符比 str2
同位置的字符大,则返回1,如果等于返回0。
还有一点就是需要找到最大的字符串来比较,如果 str1
终止了但 str2
还有字符,就会出现问题,所以需要在函数中添加一下判断长度的代码
1 | int i = 0, num_a = 0, num_b = 0; |
这串代码很容易理解,它的本质就是遍历数组,把数组的长度获取,可以便于使用
有了长度之后就可以对数组进行遍历比值了,比较的代码如下
1 | for(i = 0; i <= ((num_a > num_b)?num_a:num_b); i++){ |
在 for
循环中使用了一下三元运算符来判断最大的长度,这样可以有效的避免短的数组没有长度而结束判断了
在 for
循环中有两个判断条件,一个是大于一个是小于,如果在字符串中找到一个值如果大或者小就直接结束函数。
如果都相等就在外面结束函数。
全部代码:
1 | int my_strcmp(const char* pa, const char* pb){ |
用 strcmp
和我们写的 my_strcmp
进行一下比较
结果一样的,自己写的 my_strcmp
成功了,然后把这个带进去就能得到第三种方法了
8.6 my_qsort
这一节从 qsort
函数来分析并写出一个 my_qsort
函数
回顾学过的排序算法,就只有冒泡好用,所以这里使用冒泡排序作为 my_qsort
的排序方法。
知道使用什么方法进行排序后,要确定函数的形参如何书写。第一个形参可以作为需要比较的内容的指针,但不确定传入的是什么类型的数组的地址,所以可以用 void
类型来接收。
然后再传入数组的长度和步长还有判断数即可
1 | void my_qsort(const void* p, const int sz, const int width, int(*cmp)(const void*, const void*)) |
函数的形参写好了
加上
const
是为了不改变变量的值
现在需要的是完成函数内部的功能,用冒泡排序的办法就要设计2个循环变量,一个是计循环的次数的,另一个是索引位数,所以内部写法就为
1 | void my_qsort(const void* p, const int sz, const int width, int(*cmp)(const void*, const void*)){ |
内容的结构就完成了,现在要加上判断条件,通过判断数来决定,这里写的是 >0
,你自己写也可以写成 <0
,只不过用户写的判断函数就会改动一下。
如果把第j个元素和第j+1个元素带入到判断函数中,如果 >0
就进行交换,否则就不进行什么操作
1 | void my_qsort(const void* p, const int sz, const int width, int(*cmp)(const void*, const void*)){ |
要把判断数带入用户写的判断函数中,因为不知道是什么类型的数,无法用加来查找下一个元素,所以这里就需要让数变成一个字节长度小的类型来进行加运算
这里现在了 char
,因为一个 char
类型只有一个字节长度,用 char
类型加上1再乘类型长度,这样就可以找到下一个元素的位置
进入循环后就要进行交换,交换代码放在一个函数 Spack
中,和之前冒泡一样的写法
1 | void Spack(char* pa, char* pb, int width){ |
因为在前面把 void
类型的全部强制类型转换成 char
了,传入交换函数的类型也为 char
类型的,加1只能移动1位,如果是 int
类型的,加1只能移动一位,但它有4个位,所以这使用循环长度,然后把所有位都交换。
完善一下函数的内容
1 | int Spack(char* pa, char* pb, int width){ |
现在对函数进行一下调试,用 int
型的数组
1 | int cmp(const void* pa, const void* pb){ |
运行的结果
测试一下结构体
1 | struct Stu{ |
运行结果
试试用名字进行排序
9.指针和数组的练习题
9.1 一维数组
9.1.1 第一题
1 | int a[] = {1,2,3,4}; |
在程序中执行的结果
**分析:**第一个 sizeof(a)
语句是输出整个数组的长度,该数组是一个长度为4的数组,每个元素的大小为4为,所以整个数组的长度就为16
sizeof(a + 0)
输出的是8,在学数组的时候知道,数组的首元素是该数组的地址,现在使用地址+1,就是获取地址的长度,在32位机中,地址长为4,64位中,地址长度为8.
sizeof(*a)
这个就是地址的解引用,数组的地址为首元素的地址,所以这里计算的是数组第一个元素的长度,就为4。
sizeof(a + 1)
和上面的a+0一个道理
sizeof(a[1])
这个就是去索引位置
9.1.2 第二题
1 | int a[] = {1,2,3,4}; |
在电脑上执行一下
分析: sizeof(&a)
获得的是数组地址的大小,在之前学过,&数组名获得的是整个数组的地址,所以这里输出的是8(在64位)
sizeof(*&a)
先是取数组整个的地址,然后再用 *
解引用,得到的就是数组第一个元素,然后再输出长度,得到的就是4
其他的都是一样的道理,得到的就是地址的大小
9.2 字符数组
1 | char arr[] = {'a','b','c','d','e'}; |
运行结果
9.3 字符串数组
1 | char arr[] = "abcdef"; |
9.4 字符指针
1 | char* p = "abcdef"; |
9.5 二维数组
1 | int a[3][4] = {0}; |
10、指针练习题
10.1 一维数组
1 | int a[5] = {1,2,3,4,5}; |
10.2 结构体
1 |
|
这道题考察的是指针的运算方法,先看这个结构体的大小为20个字节,指针p是结构体的指针。
第一步先将 p + 0x1
,也就是让指针p跳过一个结构体,跳过20个字节,所以输出的地址就为 0x10014
第二步将结构体指针p强制类型转换为 unsigned long
类型,现在指针p变成了整型p,整型+1,而地址不会改变
第三步将结构体指针p强制类型转换为无符号的整型指针类型,用无符号整型指针+1跳过的就是整个整型指针的长度,而整型指针的长度为4个字节,所以输出的地址为0x10004。
10.3 二维数组
1 |
|
这道题考察的是逗号表达式,真正存放在这二维数组中的格式为:{{1,3},{5,0},{0,0}}
10.4 复杂模式
1 | int main(){ |
这道题考察的是指针互相-和数组指针指向的关系。
三、字符串函数和内存函数
1.模拟实现字符串函数
1.1 strlen
对于查看字符串长度的函数有3中模拟的写法
第一种
1 | size_t my_strlen(const char* pa){ |
第二种
1 | size_t my_strlen(const char* pa){ |
2.内存函数
其实内存函数就是对数组的拷贝,属于深拷贝的范畴,之前已经讲过深拷贝了,这里就不多说了。
四、结构体提高
在之前的学习了一下结构体的基本使用,这一节主要是学习一下结构体更多的用法
1.结构体中的数组赋值
先看下面的代码
1 | struct Person{ |
在一个结构体中存放着一个字符型数组,现在要对它进行赋值只能使用初始化值
1 | int main(){ |
但如果要单独对结构体中的数组进行赋值,使用 =
是会报错的
1 | int main(){ |
所以这里需要使用到字符拷贝函数 strcpy
,在使用的时候一定要引头文件 string.h
1 |
|
这样就可以单独修改结构体中的数组内的元素。
2.结构体中的深浅拷贝
2.1 堆区操作函数
在学习之前,先了解一下在C语言中将数据存放在堆区的几个函数
2.1.1 malloc
这个函数是属于 stdlib.h
头文件的函数,函数原型
1 | void* malloc(unsigned int size); |
在内存中动态的开辟一块size大小的空间,并返回该内存的地址,如果出现错误则会返回NULL。
2.1.2 calloc
该函数所属的头文件也是 stdlib.h
1 | void* calloc(unsigned n, unsigned size); |
在内存中动态分配n个长度为size的内存空间,并返回开辟空间的首地址,如果发生错误,则返回NULL。
2.1.3 free
该函数也是属于 stdlib.h
头文件中的
1 | void free(void* ptr); |
该函数是将指针ptr指向的内存释放出来。
2.2 结构体的浅拷贝
比如下面的内容
1 | struct Person{ |
这种是将p2中的内容逐字逐句的拷贝到p1中
而如果将结构体中的name开辟到堆区,然后再使用 =
进行拷贝,然后使用完再将堆区的内容释放
1 |
|
运行上面的代码会导致程序崩溃,为什么会导致整个情况,原因就是深拷贝和浅拷贝的问题
2.3 结构体的深拷贝
在上一节讲到了直接使用 =
将p1中的内容浅拷贝到p2中,这种是结构体中的内容存放到栈区才能使用这种拷贝方法,但如果是在堆区的内容使用浅拷贝就会导致报错
拿上面的代码来说:
1 |
|
先分配出 sizeof(p1.name)*60
的空间到堆区,执行完语句之后会返回出该堆区的地址,然后将返回的地址放在 p1.name
中,p2也是,只不过存放的是 sizeof(p2.name)*120
大小的堆空间中。
接下来执行 p1 = p2
,是将p2中的内容逐条放入p1中,这里也包括开辟的堆区空间,后面就进入释放堆区内存了,因为p1中的name存放的是p2中name存储的地址,在释放时p1先释放了那块区域,p2后面又释放了那块区域的地址,导致重复释放了。
为了解决这个问题,就需要使用到深拷贝的方法了,这种方法就是先将p1中的name中存放的地址释放,然后再开辟一块空间,将p2 name中存放的值放到那块新开辟的空间,然后把那块空间的地址存放到p1.name中,这样就可以避免重复释放的问题
1 |
|
上面的代码运行后就会达到指定的效果且不会崩。
五、结构体和共用体
1.结构体的嵌套
这里是对结构体的一个补充,在一个结构体中又有一个结构体,这个就叫做结构体的嵌套。
比如说有一个物品它是由零件1、零件2、零件3、零件4组成,但在零件4中又包含着小零件1、小零件2、小零件3,用代码表示出来为:
1 |
|
初始化值的写法为:
1 | struct part_4 { |
如果要给 part_4
中的内容重新赋值是不可以的,只能使用字符串函数 strcpy
但是这个在不同的编译器上可以执行操作
1 |
|
2.链表
在之前学数组的时候知道,在定义数组时需要先固定好数组的长度,这样会面临着一个问题就是如果该数组定义的长度不够存储的数据长度,会导致多出来的内容无法存储。如果存储的数据少于数组的长度,就会导致内存浪费。
这个时候就可以使用链表来解决这些问题。
2.1 链表概述
链表的本质是结构体,它里面包含一个尾指针来指向下一块链表的内容,还包含一块区域来存储内容。
简单的链表示意图:
就像是一堆小朋友手拉着手一样,第一个就是老师做指向。
下面简单创建一个班级的链表,而这个链表的每个节点是学生
1 | struct Student{ |
链表就不用担心长度会超范围了。
2.2 静态链表
静态链表和上面的一样,每个节点后跟着下一个节点的地址,而这个节点的地址需要手动去添加,最后一个节点的地址为NULL是为了避免空指针的产生。
代码如下:
1 | struct Student { |
上面的代码是实现了静态的链表,但拿到第一个节点的地址就能得到整个链表了,如何将静态链表输出出来,就是需要靠另外一个指针来辅助输出出来
代码如下
1 | struct Student { |
实现先创建一个指针用于存储第一条节点的地址,在每次变量完之后该指针又指向下一条节点的地址,直到最后一个节点,最后一个节点没有任何指向,所以地址为 NULL
,这样就实现了静态指针的输出。
2.3 动态链表
动态链表并不是像静态链表一样,每次添加元素就是用声明的方式,非常的麻烦,而动态链表是每次存入数据时使用语句开辟空间,再将数据存放链表中
这里先了解一下分配内存空间的三个函数
1.malloc
这个函数是属于 stdlib.h
头文件的函数,函数原型
1 | void* malloc(unsigned int size); |
在内存中动态的开辟一块size大小的空间,并返回该内存的地址,如果出现错误则会返回NULL。
2.calloc
该函数所属的头文件也是 stdlib.h
1 | void* calloc(unsigned n, unsigned size); |
在内存中动态分配n个长度为size的内存空间,并返回开辟空间的首地址,如果发生错误,则返回NULL。
3.free
该函数也是属于 stdlib.h
头文件中的
1 | void free(void* ptr); |
该函数是将指针ptr指向的内存释放出来。
如何创建动态的链表,其实和前面静态链表的一样,在一个链表中有节点,节点中一部分是存放数据的,另一部分是存放下一个节点的地址,创建动态链表也是依靠的是这个,而和静态不同的是,动态链表是在函数中动态分配一块内存给下一个节点,而不是我们使用声明静态的分配内存
动态链表的写法如下
1 | struct Student { |
Student
是每个节点的结构
而 Create
为创建动态节点,首先需要创建出一个头链,头链固定不动,如果后面的节点要增加和删除只需要改变它前面一个节点中指向下一个节点的地址即可。
这里创建了两个指针,分别是 pNew
和 pEnd
pNew
是创建出内存的指针,当每次在链表中增加一个节点时, pNew
就是为了接受到这一个新节点的地址。
pEnd
是指向增加的节点,避免每次添加节点后又重新遍历一遍链表元素后再在最后添加,所以每一次增加都会用 pEnd
来记录最后一个节点的地址。
输出链表的方法和静态输出的方法一致
1 | void test() { |
2.3.1 链表练习1
新建一个班级类,每一个学生作为一个节点,每个节点的内容有学生名字和学号。
代码如下:
1 |
|
2.4 增加节点
节点的增加其实是将该节点前的指针的地址指向该节点,并让该节点的指针指向原来是一个节点的地址给它重新赋值过去即可。
2.4.1 在头节点增加
在头节点增加节点是一种非常简单的方法传入的参数就只需要传入一个头节点即可。
代码如下
1 | struct Student* insert(struct Student* pHead) { |