第一篇、初识C语言
1、main函数
main函数是程序的入口,一个项目不管创建多少文件,main函数有且只有一个。
2、库函数printf和scanf
printf和scanf都是库函数,全称标准库函数,printf是标准输入函数,scanf是标准输出函数,使用库函数需要包含对应的头文件,printf和scanf库函数的头文件是stdio.h,需要#include引用才可以使用,#include<stdio.h>。
scanf和printf函数使用方法:
int a = 0; //创建变量
scanf("%d",&a);//输入
printf("%d\n",a);//输出
3、占位符
占位符就是printf和scanf使用的,占位符就是替后面的值占个位置,比如printf("%d\n",20); %d是占位符,20是那个值,占位符就是替20占个位置,传参时将20放在占位符所占的位置上。
占位符表:
4、关键字
关键字是C语言中一批保留的名字的符号,比如:int、if、return,这些符号被称为保留字或者关键字。
- 关键字都有特殊的意义,是保留给C语言使用的。
- 程序员自己创建标识符(定义变量名时和#define定义名称时)的时候是不能和关键字重复的。
- 关键字也是不能自己创建的
C语言32个关键字如下:
auto break case char const continue default do double else enum extern float for goto if int long register return short signed sizeof struct switch typedef union unsigned void volatile while static
紫色:内置类型
绿色:循环和分支
橙色:修饰变量
蓝色:自定义类型
5、转义字符
转义字符就是改变意思的字符,通过 ' \ '便可以改变意思的字符就是转义字符。
转义字符列表
- \?:在书写连续多个问号时使用,防止他们被解析成三字母词,在新的编译器上无法验证
- 三字母词就是??)--转换-->],??(--转换-->[
- \':用于表示字符常量'
- \":用于表示字符串内部的双引号
- \\:用于表示反斜杠,防止字符被解释为转义字符
- \a:警报(蜂鸣)
- \b:退格键,光标回退一个字符,但不删除字符
- \f:换页符,光标移到下一页,在现代系统上,这已经反应不出来了,行为改成类似于\v
- \n:换行符
- \r:回车符,光标移到同一行的开头
- \t:制表符,光标移到下一个水平制表位,通常是下一个8的倍数
- \v:垂直分隔符,光标移到下一个垂直制表位,通常是下一行的同一列。
- 下面两种转义可以理解为:字符的8进制或16进制的形式
- \ddd:ddd表示1—3个八进制的数字。 如:\130 表示字符X
- \xdd:dd表示2个十六禁止的数字。 如:\x30 表示字符0
6、字符串和 ' \0 '
字符串就是一组字符组成的字符串,例如"hello world",每一串字符串后面都有结束标志' \0 ',表示字符串到这里就结束了。
7、strlen库函数
strlen也是标准库函数,是计算字符串长度的函数。长度则是字符个数,直到遇到 ' \0 '为止,strlen只会计算结束标志' \0 '之前的字符个数。
使用strlen也需要包含头文件,strlen的头文件是string.h。
8、字符和ASCII码值
每个字符都有一个ASCII码值,比如字符'A'的编码是65,65既可以表示整数65,也可以表示字符'A',而65的二进制就是字符'A'的ASCII码值。
ASCII码表:
9、语句和语句分类
C语言代码是一条一条的语句构成的,C语言的语句可以分为五类:
- 空语句
- 复合语句
- 表达式语句
- 函数调用语句
- 控制语句
第二篇、数据类型和变量
1、数据类型
数据类型是C语言用来表示数据的,C语言有两种类型:内置类型,自定义类型。内置类型是C语言本身就有的类型,自定义类型则是我们自己创建的类型。
内置类型:字符型(char)、整型(short,int,long,longlong)、浮点型(float,double)。
自定义类型:数组、自定义结构(struct,union,enum)。
2、signed和unsigned
signed表示有符号类型,unsigned表示无符号类型,C语言中只有字符型和整型才有这两个概念。signed和unsigned表示的是整型是否有符号,换平常来讲表示正负数。
3、数据类型取值范围
每个数据类型都有取值范围,因为数据类型之间的大小不同,所占内存越大的数据取值范围越广,有符号类型和无符号类型的取值范围相同,但是最大值不同,无符号类型的最大值大于无符号类型,是因为有符号类型二进制的最高位是符号位,符号位只能表示正负,不是数值位,所以要比无符号整型小。
4、变量
存储数据的就是变量,变量分为两种:局部变量、全局变量。
局部变量是在结构内部的变量,全局变量是在结构外部的变量。
全局变量的生命周期是整个程序。
5、算术操作符
C语言为了方便运算,提供了一系列操作符,其中一组操作符叫:算术操作符。分别是:+、-、*、/、%,并且都是双目操作符。
双目操作符就是两端拥有两个操作数,可以进行运算就叫做双目操作符。
注:C语言中 / 是除号,如果要/计算出小数结果,就要让其中一个操作数为浮点数,例如:6 / 4.0结果为1.5,如果是6 / 4结果为1。
%符号是取模符号,就是取两个数相除中间的余数,%的结果是正数还是负数取决于第一个操作数是正数还是负数。
6、赋值操作符和复合赋值
C语言中 = 不是判断符号,而是赋值符号。
复合赋值:+=、-=、*=、/=、%=、&=、|=、^=
7、单目操作符
单目操作符:+、-、++、--。
+和-不是加减符,是表示正负的单目操作符。
++和--是让变量自加1或自减1,分为前置和后置。
8、强制类型转换
(类型)这种括号括起来的就叫做强制类型转换,如上图,将3.14强转为int后就是3。
俗话说,强扭的瓜不甜。不在必要使用强制转换的时候能不使用就不使用。
第三篇、分支和循环
1、C语言的三种结构
C语言有三种结构:顺序结构、选择结构、循环结构。
2、if语句
if语句是分支语句,先判断,判断为真执行if语句,判断为假则结束。
if语句还有一个搭档叫else语句,配合使用的话if语句判断为真就走if,为假就走else。
如果多重判断就可以用else if,else if可以拆分为else语句里又套用了一层if语句。
注:else语句只和最近的if语句进行匹配。
3、关系操作符
C语言用于比较表达式,称为"关系表达式"(relational expression),里面使用的运算符称为"关系运算符"(relational operator),主要有下面6个。
- >大于运算符
- <小于运算符
- >=大于等于运算符
- <=小于等于运算符
- ==相等运算符
- !=不等运算符
一个需要避免的错误是:多个关系操作符不宜连用
a < b < c
因为在判断关系表达式时是从左到右进行判断的,比如先判断a是否小于b,为真返回1,为假返回0,最后和c进行判断的要么就是1要么就是0。
(a < b)< c
4、逻辑操作符&& , || , !
逻辑操作符提供逻辑判断功能,用于构建更复杂的表达式,主要有下面三个操作符。
- !:逻辑反操作符 (改变单个表达式真假)
- &&:与操作符,是并且的意思 (两侧表达式都为真则返回1,一侧为假就返回0)
- | |:或操作符,是或者的意思 (只要有一侧表达式为真就返回1,两侧表达式都为假才返回0)
5、switch语句
switch是一种特殊的if ..else结构,用于判断条件有多个结果的情况。它把多重else if改成更易用、可读性更好的形式。
switch(expression){
case value1: statement
case value2: statement
default: statement
}
- switch后的expression必须是整型表达式
- case后的值必须是整形常量表达式
6、循环语句
循环语句有三条,分别是:while、for、do
循环规则:要有判断,并且判断值每次循环都接近这个判断,直到判断结束停止循环。
7、break和continue
在循环执行的过程中,如果某些状况发生时需要提前终止循环,这是非常常见的现象。C语言中提供了break和continue两个关键字,就是应用到循环中的。
- break的作用是永久的终止循环,只要break被执行,就会直接跳出循环。
- continue的作用是跳过本次循环后面的代码,在for循环和while循环中是有差异的。
8、goto语句
C语言提供了一种非常特别的语法,即使goto语句和跳转标号,goto语句可以实现同一个函数内跳转到设置好的标号处。
goto的格式:
goto again; //goto语句设置
again: //跳转点
//again只是随意起的名字,goto后面还可以是其他名字
9、rand和srand
rand:对种子的基准值通过某种运算生成随机数
srand:改变种子的基准值
time:时间戳函数,实参给一个NULL便直接返回时间戳
第四篇、数组
1、数组的概念
数组是一组相同类型元素的集合;从中我们可以得知两点:
- 数组中存放着1个或者多个数据,但是数组元素个数不能为0。
- 数组存放这多个数据,类型是相同的。
数组分为一维数组和多维数组,多维数组中最常使用的就是二维数组。
2、一维数组
一维数组格式:
char c[10];
int arr[10];
double d[10];
注:拿整型数组举例,数组类型是int[10],int表示这个数组存放的数据是什么类型。
3、二维数组
二维数组格式:
char c[3][4];
int arr[3][4];
double d[3][4];
4、数组在内存中的存储格式
- 在内存中,&数组元素或是&变量是取该元素或是变量所占的内存空间范围内最接近低地址的那块内存块的地址来表示它的地址
- 连续存放的数组元素类型大小是多少,在内存中需要占用的内存块就是多少。
- 每个字节的内存都有地址,每个地址指向的是一个字节的内存块。
- 数组的元素在内存中连续存储的。
- 随着下标的增长,地址也是由低到高的。
5、sizeof
sizeof是计算大小的,以字节为单位。
sizeof的返回类型是size_t(无符号整型类型)。
6、变长数组
C99标准之前创建数组的方式,数组大小是使用常量、常量表达式指定的
int arr1[10];
int arr2[3 + 5];
int arr3[] = { 1, 2, 3, 4 };
这样的语法限制,让我们创建数组就不够灵活,有时候数组大了就浪费空间,数组小了不够用。
在C99中,引入了变长数组(variable-length array,简称VLA)的新特性,允许数组的大小是变量
第五篇、函数
1、标准库和头文件
C语言中规定了C语言的各种语法规则,C语言并不提供库函数;C语言的国际标准ANSI C规定了一些常用的函数标准,被称为标准库,那不同的编译器厂商根据ANSI C提供的库函数标准去实现这一系列函数,这些函数就被称为库函数。
各种编译器的标准库中提供了一系列的库函数,这些库函数根据功能的划分,都在不同的头文件中进行了声明。有数学相关的,有字符相关的,有日期相关的等,每个头文件中都包含了相关的函数和类型等信息。
2、sqrt库函数
举例:sqrt
double sqrt(double x);
//sqrt是函数名
//x是函数的参数,表示调用sqrt函数需要传递一个double类型的值
//double是返回值类型-表示函数计算的结果是double类型的值
功能
compute square root 计算平方根
Returns the square root of x. (返回平方根)
3、库函数
库函数是标准库函数,就是C语言标准的函数。使用库函数之前记得包含相应的头文件。
4、自定义函数
了解了库函数,我们关注度应该聚焦在自定义函数上,自定义函数其实更加重要,也能给程序员写代码更多的创造性。
函数的语法形式:
其实自定义函数和库函数是一样的,形式如下:
ret_type fun_name(形式参数)
{
}
- ret_type是函数返回类型
- fun_name是函数名
- 括号中放的是形式参数
- {}括起来的是函数体
5、实参和形参
#include <stdio.h>
int Add(int x, int y)//括号内部的参数叫做形式参数,简称形参,用来接收实参传过来的值
{
return x+y;
}
int main()
{
int a = 0;
int b = 0;
scanf("%d%d", &a, &b);
int ret = Add(a, b);//这里的函数调用时传的值就是实际参数,简称实参
printf("%d\n", ret);
return 0;
}
实参:
在上面代码中,调用Add函数时传的参数a,b称为实际参数,简称实参。
实际参数就是真实传递给函数的参数。
形参:
在上面代码中,函数名Add后括号中的x和y,称为形式参数,简称形参。
注:形式参数只有在函数被调用的过程中为了存放实参传递过来的值,才向内存申请空间,这个过程就是形参的实例化。
形参和实参的关系:
形参只是在调用时申请一块空间用来存放实参的值,但是形参和实参之间的地址各不相同,对形参的改变不会影响到实参,也可以理解为形参只是实参的一份临时拷贝。
6、return语句
return语句就是返回语句,作用是在函数结束时返回值或直接返回。
注:return返回数据时如果数据的类型与函数返回类型不同则自动将此数据类型隐式转换成函数返回类型。
7、传值调用和传址调用
传值调用:直接将变量空间存储的值作为实参传递给形参
传址调用:将变量的地址传递给形参
8、嵌套调用和链式访问
嵌套调用:在函数内部调用其他函数
链式访问:函数的返回值作为其他函数的参数传参调用
9、函数的声明和定义
函数声明:就是声明函数,告诉系统存在这个函数。
函数定义:函数的代码实现。
建议函数声明放在.h头文件,函数定义放在其他.c文件。
9.3 static和extern
static和extern 都是C语言中的关键字。
static是静态的的意思,可以用来:
- 修饰局部变量
- 修饰全局变量
- 修饰函数
static: 是将修饰的变量或函数从栈区转移到静态区,可以延长修饰过的变量或函数的生命周期。
extern:extern是声明外部符号关键字,如果其他文件有变量想调用变可以套上extern调用。
注:static还有一个功能就是将外部链接属性改成内部链接属性,比如函数或全局变量其他文件都可以随时使用,我们只想让他们供此文件使用就可以使用static改变它们的链接属性,内部链接属性其他文件不可调用。
第六篇、函数递归
递归
递归就是函数自己调用自己。
递归的限制条件
递归在书写的时候,有2个必要条件:
- 递归存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件。
第七篇、操作符详解
1、二进制
每个类型的数值都有二进制表示方法,因为数据都是以二进制的形式在内存中进行存储。
十进制和二进制是怎么表示数值的呢?
十进制表示数值:
二进制表示数值:
注:八进制和十六进制也是一样,权重都是最高值的次方,比如最低位是8的0次方,第二位8的1次方,十六进制也一样。
满位进一:比如二进制每一位最多到1,八进制每一位最多到7,十进制每一位最多到9,十六进制每一位最多到15也就是F,因为最高位需要表示满位进一,比如二进制满二进一,八进制满八进一,十进制满十进一,十六进制满十六进一,后一位重置为0。
2、原码 反码 补码
C语言中每个整数类型的数据都是有二进制的,但是二进制分为三种就是原码反码和补码。
原码:通过十进制转换成二进制就是原码。
反码:符号位不变,其他位按位取反。
补码:反码+1就是补码。
正整数的原码、反码、和补码都相同。
负整数需要将原码转换成补码。
内存中存储的都是补码。
为什么需要补码?
原因:补码能将符号位和数值位同一处理。
CPU中只有加法运算器,需要将负数转换成正补数,正步数就不存在符号位这一说了,就是符号位也当做了数值位,然后与另一个操作数相加得到的二进制再把他转换成原码,转换成原码后第一位仍是符号位。
3、移位操作符
<< 左移操作符
>> 右移操作符
注:移位操作符的操作数只能是整数
移位操作符就是将二进制位向左或右移动。
<< 左移操作符:将二进制位向左移动。
左边寄出去的二进制位丢弃,右边的二进制位补0
>> 右移操作符:将二进制位向右移动。
移位规则:首先右移运算分两种:
逻辑右移:左边补0,右边丢弃。
算术右移:左边用原符号位填充,右边丢弃。
注:右移操作符具体采用逻辑右移还是算术右移是不确定的,这个取决于编译器,但是大部分的编译器采用的是算术右移的。
警告:对于移位操作符,不要移动负数位,这个是标准为定义的。
例如:
int num = 10;
num>>-1 //error
4、位操作符:&、|、^、~
- & 按位与
- | 按位或
- ^ 按位异或
- ~ 按位取反
- 注:它们的操作数必须是整数
& 操作符:两个二进制位都为1结果才为1,一个二进制位为0结果为0。
| 操作符:两个二进制位都为0结果才为0,一个二进制位为1结果为1。
^ 操作符:两个二进制位相同为0相异为1。
~ 操作符:将所有二进制位按位取反。
5、逗号表达式
逗号操作符 - 优先级是最低的
逗号表达式,就是用逗号隔开的多个表达式。
注:逗号表达式,从左向右依次执行。整个表达式的结果是最后一个表达式的结果。
例如:
int a = 10;
int b = 20;
int c = (b > a, a = b - a, b = a + b);//结果为30
//最后一个表达式的结果就是整个逗号表达式的结果
6、下标访问[ ] 、函数调用( )
[ ]操作符作用:数组下标访问。
( )操作符作用:函数调用传参
7、操作符的属性:优先级、结合性
C语言的操作符有2个重要的属性:优先级、结合性,这两个属性一定程度上决定了表达式求值的计算顺序。比如( )的优先级最高,' , ' 逗号表达式的优先级最低。结合性是看左结合还是右结合。
8、表达式求值
表达式求值之前要进行 类型转换 ,当表达式中的值转换到适当的类型,才开始计算。
类型转换分为两种:
- 整形提升
- 算术转换
整形提升
C语言中整型算术运算总是至少以缺省整型(int)类型的精度来进行的。
为了获得这个精度,表达式中的字符和短整型操作数在使用之前被转换为普通整型,这种转换称为整型提升。
算数转换
如果某个操作符的各个操作数属于不同类型,那么除非其中一个操作数的转换为另一个操作数的类型,否则操作就无法进行。下面的层次体系称为寻常算数转换。
1 long double
2 double
3 float
4 unsigned long int
5 long int
6 unsigned int
7 int
如果某个操作数的类型在上面这个列表中排名靠后,那么首先要转换为另一个操作数的类型后执行运算。
第八篇、指针
1、地址和内存
内存中有多个内存块,可以称为内存单元,每个内存单元都有一个地址来表示这个内存单元所在位置。每个内存单元是1字节大小。
为什么每个内存单元需要地址呢?
因为CPU需要地址才能访问内存单元,CPU是靠地址总线来传输地址的。
我们简单理解,32位机器有32根地址总线,每根线只有两种状态,表示0,1【电脉冲有无】,那么一根线就能表示2种含义,2根线能表示4种含义,依次类推。32根地址总线就能表示2^32种含义,每一种含义都代表一个地址。
注:内存和CPU之间有三种联系方式,分别是:地址总线、数据总线和控制总线。
地址总线:CPU访问内存传输地址信息来找到对应的内存空间。
数据总线:对该内存读取或写入操作。
控制总线:控制CPU是从内存中读取数据还是将数据写入内存。
结论:地址的存在就是为了让CPU通过地址信息找到并访问内存。
2、指针变量和地址
&取地址:这里的&不是按位与,而是取地址符,取出的地址便可以存放在指针变量中。
例如:
int a = 10;
int* pa = &a;
*pa = 20;
解引用:
还可以*pa解引用,解引用就是通过指针变量中存储的地址找到地址所指向的空间。也就是通过地址访问地址所指向的内存空间。
指针类型:
当然指针类型不止这么一种,int*说明这个指针是整型指针,拆分开来看,int说明指针指向的空间是整型,*则证明pa是个指针。
重点:
注:取4个字节大小变量的地址取的是4个内存地址中最低的地址。
指针变量的大小:
地址的大小就是指针变量的大小,指针变量就是开辟一块空间存储地址,x86环境也就是32位机器,地址总线是32根,所以地址就是4个字节大小。x64环境也就是64位机器,地址总线是64根,所以地址是8个字节大小,指针变量的大小是随着环境变化而变化。
3、指针类型变量的意义
指针类型取决于指针变量访问内存权限的大小。
指针变量访问权限:
解引用区别:char*类型的指针解引用时只能访问1个字节内存空间,int*类型指针解引用能访问4个字节内存单元空间。
指针+-整数区别:char*类型的指针+1就可以跳过1个内存单元的地址,int*类型的指针+1就跳过4个内存单元的地址。
结论:
- 指针类型是有意义的。
- 指针类型决定了指针在解引用操作时的访问权限,也就是一次解引用访问几个字节的内存单元空间。
- 指针类型决定了指针在+1/-1操作的时候,一次跳过几个字节(指针的步长)
指针类型的作用:
指针类型决定接收哪个类型数组,比如int arr[10],数组名arr是首元素地址,那就直接赋值给int*类型的指针,int*类型指针+1正好跳过一个元素大小。
4、const修饰指针
const修饰指针有三种修饰方法:
const int a = 10;
第一种
const int* pa = &a;
第二种
int* const pa = &a;
第三种
const int* const pa = &a;
const是修饰常量,作用是将变量属性更改成常量。
但是const修饰常量是有管控范围的,例如我把变量a修饰成常量,只是说变量a不能直接更改里面的数据,并不是说这个内存的数据谁都不能改,所以创建一个指针接收这个变量a的地址并找到这块空间照样可以修改。
所以我们必须把指针也修饰成const,根据上面代码我们将const修饰指针分为了三种情况
- 第一种:const修饰在*前面说明*pa解引用不能修改这块空间
- 第二种:const修饰*后面,可以通过*pa解引用修改这块空间,但是不能更改指针变量空间里的地址。
- 第三种:两边都修饰就是你既不能通过解引用修改存储地址指向的那块空间,又不能更改指针变量空间里的地址。
5、指针运算
指针运算有三种:指针+-整数、指针-指针、指针关系运算。
指针+-整数:指针+-整数就是跳过多少个内存单元的地址
指针-指针:得到两个指针之间元素个数,前提是两个指针都是同一块开辟的内存空间地址。
指针关系运算:判断指针大小,指针也是串编码,如果仔细观察指针其实都是十六进制显示,也就是说指针也可以被转换成十进制编码表示,可以把十进制编码看作一个整型值,就比那个整型值大。
6、野指针
概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
野指针成因:指针未初始化、越界访问、指针指向的空间被释放
解决方法:将不用的指针赋值NULL就可以了
NULL就是空,值为0,所有也可以直接给指针赋值一个0,效果和NULL一样。
7、assert断言
assert.h头文件定义了宏assert(),用于在运行时确保程序符合指定条件,如果不符合,就会报错终止运行。这个宏常常被称为 "断言"。
assert(p != NULL);
上面代码在程序运行到这一行语句是,验证变量p是否等于NULL。如果确实不等于NULL,程序继续执行,否则就会终止运行,并且报错误信息提示。
assert和if一样是可以进行判断的,如果为真返回非0,如果为假则返回0。虽然都可以判断,但是它们有一点还是不一样的。就是如果判断为假后的区别反应。
assert和if的判断区别:
如果是if判断为假就走else或者继续执行下一条语句,只是不进入if语句内执行。
如果是assert判断为假会终止程序的运行并在标准错误流stderr中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
总结:assert就是判断,这个判断也可以是a!=0一类的,判断为真则正常,判断为假则在标准错误流stderr写入一条错误信息。
8、数组名的理解
数组名就是首元素的地址。但是有2个例外:
- sizeof(arr)这里的数组名表示的是整个数组,所以sizeof(数组名)计算的是整个数组的大小,单位是4个字节。
- &arr这里的数组名表示的是整个数组,取出的是整个数组的地址,+1或-1可以跳过整个数组
除此之外遇到所有的数组名都是首元素地址。
sizeof(arr):如果arr在这里是首元素地址那结果就是4/8个字节,但是这里的arr表示的是整个数组,所以计算大小是数组所有元素加起来的大小。
&arr:这里的&数组名表示整个数组的地址,访问权限则是整个数组的大小,+1跳过整个数组。
数组下表访问:
还有arr[ i ]是访问数组这个下标位置的元素,但是它还有一种表示方法。
arr[i]==等价于==*(arr+i)
arr[i]只是一种形式,在编译阶段arr[i]会被编译为*(arr+i),所以可以证明[ ]只是操作符。
既然arr[i]等价于*(arr+i),arr[i]的原型就是*(arr+i),加法又是支持交换律的。那我可以将*(arr+i)写成*(i+arr),那是不是也可以写成i[arr]格式呢?
printf("%d ",*(i+arr));
printf("%d ",i[arr]);
答案是可以的
这更加说明了arr[i]或i[arr]只是一种形式,并不是固定的格式必须arr[i]。arr[i]只是一种形式,真正的运算还要转换成*(arr+i)进行运算。但是这里讲i[arr]只是让大家对数组名有更深刻的理解,只是不让大家的思维局限于arr[i],但是写代码时最好不要写成i[arr]这种形式,虽然可以访问,但是很难理解,可读性差。
9、数组传参的本质
注:数组名是首元素地址,但是当sizeof(arr)时数组名就是整个数组,当把arr数组名作为实参传递给形参,形参接收的就不再是数组名了,形参接收的只是一个首元素地址,就是一个地址,所以sizeof(形参)将不再表示整个数组。
10、二级指针
二级指针:一级指针是存储地址的指针,二级指针就是存储一级指针空间地址的指针。
什么意思?一级指针是创建了一个空间来存储地址,但是这个空间也是内存中开辟出来的,也是有地址的,把一级指针空间的取出放在另一个空间,这个空间就是二级指针。
int a = 10;
//一级指针
int* p = &a;
//二级指针
int** pp = &p;
//三级指针
int** * ppp = &pp; *说明ppp是指针变量,int**说明ppp指向的是二级指针
11、指针数组
什么是指针数组呢?
我们可以类比一下:
- 整型数组 - 存放整型的数组 int arr[10];
- 字符数组 - 存放字符的数组 char str[10];
那指针数组就是存放指针的数组,指针数组的元素类型可就多了:int* char* double*的都有。
int*[ ]类型指针数组:
int arr1[] = {1,2,3};
int arr2[] = {4,5,6};
int arr3[] = {7,8,9};
int* parr[] = {arr1,arr2,arr3}//指针数组
12、字符指针
字符指针的使用方法:
char c = 'a';
char* pc = &c;
一般是使用字符指针接收一个字符型的变量的地址。其实字符指针还可以这样使用:
char* p = "hello world";
这里个字符指针p初始化一个字符串,系统会自动开辟一块空间用来存放这个字符串,然后将字符串首地址给指针p。
注:如果再创建一个字符指针同样初始化这个字符串,系统则会将已经存储过的字符串地址拿出来给这个指针,也就是说两个字符指针指向的是同一个字符串地址。
- 字符指针初始化的字符串是常量字符串,不能解引用修改数据。
- 字符指针初始化的字符串只能存在一个,系统不会再开辟一块内存空间存储相同的常量字符串。
12、数组指针
首先要认识到,之前的指针数组是数组,是存放指针的数组。
接下来学习的:数组指针
类比:
字符指针 - 指向字符的指针,存放的是字符的地址 char ch = 'w'; char* pc = &ch;
整型指针 - 指向整型的指针,存放的是整型的地址 int n = 100; int* p = &n;
数组指针 - 指向数组的指针。存放的是数组的地址 int arr[10]; int(*p)[10] = &arr;
int arr[10]; int (*p)[10] = &arr;
注:这里说指向数组的指针不是存储数组首元素地址的指针,而是存储指向整个数组的地址的指针。
int arr[6]; int* p = arr; 数组首元素的地址 int (*p)[6] = &arr; 数组的地址
注:数组指针的[ ]中是不能省略的,数组指针需要知道它指向的数组空间多大,才能获取整个数组大小的访问权限。
数组指针的使用:
二维数组可以看做是存储多个一维数组的数组,也就是说二维数组的一个元素便是一个一维数组,假设我将二维数组的数组名传参那形参该用什么类型来接收,答案是数组指针。因为二维数组的首元素就是整个一维数组的地址,所以用指向数组的指针(数组指针)接收刚刚好。
13、函数指针变量
数组指针 - 是指向数组的指针 - 是存放数组地址的指针
函数指针 - 是指向函数的指针 - 是存放函数地址的指针
函数指针变量的创建
注:函数名就是地址
知道了函数的地址,那函数指针又是什么格式的呢?
函数指针的写法和数组指针十分有九分的相似:
int Add(int x,int y);
因为函数名本身就是地址,所以取不取地址无所谓,下面两种方法都可以
int (*pf)(int x ,int y) = Add;
int (*pf)(int x ,int y) = &Add;
*说明pf是指针,(int x,int y)是指向函数的参数,int是指向函数的返回类型。整体就是函数指针。
但是因为Add本身就是函数的地址,所以不用再额外的&地址,而且这个函数指针还可以改造:
使用方法:
int Add(int x,int y);
int (*pf)(int x ,int y) = Add;
两种调用方法都可以
pf(10,20);
(*pf)(10,20);
因为函数名本身就是地址,所以两种调用方法都可以,如果非要解引用再使用那一定要用括号括起来,先解引用再调用函数,不然就是调用完后对int类型的返回值解引用就会报错。
14、typedef关键字
typedef 是用来类型重命名的,可以将复杂的类型,简单化。
比如,你觉得unsigned int 写起来不方便,如果能写出uint 就方便多了,那么我们可以使用:
typedef unsigned int uint;
//将unsigned int 重命名为 uint
15、函数指针数组
整型指针数组 - 存储整型指针的数组
函数指针数组 - 存储函数指针的数组
如果我实现了4个函数,需要函数指针来调用,难道需要连续创建4个函数指针来接收4个函数吗?
int Add(int x, int y)
{
return x + y;
}
int Sub(int x, int y)
{
return x - y;
}
int Mul(int x, int y)
{
return x * y;
}
int Div(int x, int y)
{
return x / y;
}
int main()
{
int (*pf1)(int, int) = Add;
int (*pf2)(int, int) = Sub;
int (*pf3)(int, int) = Mul;
int (*pf4)(int, int) = Div;
return 0;
}
这样会不会太麻烦了,如果有多个函数就要有多个函数指针来接收吗?有没有什么办法可以将函数集成起来吗?我们可以使用函数指针数组来接收:
int main()
{
int (*arr[4])(int ,int) = {Add,Sub,Mul,Div};
return 0;
}
这就是函数指针数组,还可以理解为存储函数指针的数组。arr[4]说明arr是个数组,数组的元素类型是int(*)(int,int)的函数指针。
注意:使用函数指针数组的前提是函数指针数组的每个元素返回类型、参数个数和参数类型都必须形同,才能在集成到一个数组中。
函数指针数组的使用:
int main()
{
int (*arr[4])(int ,int) = {Add,Sub,Mul,Div};
printf("%d\n", arr[0](10,20));
printf("%d\n", arr[1](10,20));
printf("%d\n", arr[2](10,20));
printf("%d\n", arr[3](10,20));
return 0;
}
16、转移表
函数指针数组的用途:转移表
根据输入的值作为函数指针数组下标访问到的函数中间就像转移一样,所以函数指针数组就叫转移表
转移表虽然精妙,但是还是有一定的局限性存在的,就是转移表的方法需要函数指针数组,而函数指针数组里的元素的返回类型和参数的类型必须相同。比如函数指针数组(int,int)里的int,int,就必须要整型的才可以。但是如果我想进行float类型的运算呢?这个转移表就明显解决不了,需要知道就算是这么巧妙的代码也有局限性的。
17、回调函数
通过函数指针调用的函数就是回调函数。通过函数指针pf调用的Add、Sub、Mul、Div这些函数都被称为回调函数。
如果你把函数的指针 (地址) 作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,被调用的函数就是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
第九篇、字符函数和字符串函数
一、字符函数
1. 字符分类函数
C语言中有一系列的函数是专门做字符分类的,也就是一个字符是属于什么类型的字符的。这些函数的使用都需要包含一个头文件是ctype.h
2、字符转换函数
C语言提供了2个字符转换函数:
int tolower(int c);//将参数传进去的大写字母转小写
int toupper(int c);//将参数传进去的小写字母转大写
有了这个函数我们就可以将上面的代码更新一下,不需要+、-32来改变大小写字母,直接使用转换字符函数即可:
#include <ctype.h>
#include <string.h>
#include <stdio.h>
int main()
{
char str[] = "i Like Beijing!";
size_t len = strlen(str);
size_t i = 0;
for (i = 0; i < len; i++)
{
//不用再额外判断字母是不是小写,tolower判断该字符是小写就转换大写,不是就不改
str[i] = toupper(str[i]);//如果是小写字母就转换为大写
//前提是转换结果需要用一块整型空间来接收,因为传值调用,并不能在函数直接内部修改
}
printf("%s\n", str);
return 0;
}
二、字符串函数
1、strlen
strlen库函数功能:求字符串长度,统计的是结束标志\0之前出现的字符个数
strlen库函数的声明:
size_t strlen(const char* str);
2、strcpy
strcpy库函数功能:将一个字符串拷贝另一个数组
strcpy函数的声明:
char* strcpy(char* destination, const char* source);
strcpy注意事项:
1、strcpy里源字符串必须包含 ' \0 ',因为 ' \0 ' 也会被拷贝到目标空间。
2、strcpy里目标空间必须要有足够大的空间来存储这个拷贝过来的数据。
3、strcat
strcat库函数功能:字符串追加,就是在目标空间的末尾追加上一串源字符串
strcat函数的声明:
char* strcat(char* destination, const char* source);
从arr1末尾的 ' \0 ' 开始,拷贝源字符串arr2,将arr1的末尾追加上arr2
strcat注意事项:
1、目标空间必须有足够大的空间进行追加。
2、目标空间结尾和源字符串结尾都必须有 ' \0 '。
4、strcmp
strcmp库函数功能:用来比较两个字符串的大小关系
strcmp的函数声明:
int strcmp(const char* str1, const char* str2);
注意strcmp比较的不是两个字符串的长度的,而是比较两个字符串中对应位置上的字符,按照字典序比较。
标准规定:
- 第一个字符串大于第二个字符串,则返回大于0的数字
- 第一个字符串等于第二个字符串,则返回0
- 第一个字符串小于第二个字符串,则返回小于0的数字
5、桃园三结义:长度受限制函数strncpy、strncat、strncmp
前面的三个函数strcpy、strcat、strcmp是长度不受限制的字符串函数,他们仨还有长度受限制的函数,分别是:strncpy、strncat、strncmp,和前面的strcpy、strcat、strcmp的功能是相同的,参数上多了一个值,这个值就是限制字符串函数的执行功能长度限制,表面上不同的是str后面多了一个n,干了这碗wine ( 酒 ) 后我们仨就正式结拜为兄弟。
比如我要拷贝"hello world"到一个空间,但是只想拷贝 "hello" 这6个字符,就可以考虑用长度受限制的字符串拷贝函数strncpy。
strncpy函数的声明:
char* strncpy(char* destination, const char* source, size_t num);
strncat函数的声明:
char* strncat(char* destination, const char* source, size_t num);
strncmp函数的声明:
char* strncmp(char* destination, const char* source, size_t num);
它们的功能大概就是:strncpy:限制拷贝字符个数
strncat:限制字符追加个数
strncmp:限制字符串比较字符个数
所以具体函数调用就不再一一介绍了,知道是什么功能限制什么就可以了
6、strstr
strstr库函数功能:在一个字符串中查找另一个字符串,简单概述就是判断第二个字符串是不是第一个字符串的子字符串
strstr的函数声明:
char* strstr(const char* str1, const char* str2);
strstr函数返回str2在str1中第一次出现的位置
如果str2在str1中没有出现,就返回NULL
7、strtok
以后学习计算机网络时,会学到点分十进制表示的IP地址,例如:192.168.101.25,由点分开的十进制就叫点分十进制,IP地址本质是一个整数,不好记,所以才有了点分十进制表示方法
既然IP地址是用 ' . ' 隔开的,那可以将每个隔开的段拿出来吗?
比如:192,168,101,25这四个由 ' . ' 隔开的段。
当然可以。这里就要是用到strtok函数。该函数可以通过分隔符将一个字符串的每个分割段拿出来。
strtok函数的声明:
char* strtok(char* str, const char* sep);
strtok函数功能:
- sep参数指向一个字符串,定义了用作分隔符的字符集合
- 第一个参数指定一个字符串,它包含了0个或者多个有sep字符串中一个或多个分隔符分割的标记
- strtok函数找到str中的下一个标记,并将其用 ' \0 ' 结尾,返回指向这个标记的指针。(注:strtok分割字符串时是会改变传参过来的字符串的,如果不想改变就拷贝一个传参)
- strtok函数的第一个参数不为NULL,函数将找到str中第一个标记,strtok函数将保存它在字符串中的位置
- strtok函数的第一个参数为NULL,函数将在同一个字符串中被保存的位置开始,查找下一个标记
- 如果字符串中不存在更多的标记,则返回NULL指针
简单来说就是第一次调用strtok函数时需要传参传一个需要分割的字符串,他会分隔符位置置为 '\0' 返回已经分割的第一段。但是它一直停留在 ' \0 '的位置,所以下一次调用直接传递NULL就可以继续沿着' \0 '的位置继续向后找分隔符分割成段并返回,直到没有可以分割的段时返回NULL。
注:如果第一次分割字符串后,想继续分割该字符串,调用时可以直接传NULL,因为出了函数不会销毁这个分割后的字符串,一直保存着这个字符串,下一次调用时可以直接传NULL便可继续使用该字符串,是因为该字符串可能被static修饰过,出了作用域不会被销毁。
如果想分割其他字符串,不想分割该字符串。就传其他字符串,不再传递NULL。然后strtok会以刚传的其他字符串为开头,下一次调用传NULL便可继续分割其他的字符串。
8、strerror和perror
strerror库函数功能:返回一个错误信息字符串的起始地址,简单概述就是返回一个错误码所对应的错误信息字符串的起始地址,这个错误码就是我们调用时传递的实参。
strerror函数的声明:
char* strerror(int errnum);
perror库函数的介绍:
还有一个库函数叫perror,和上面的strerror一样,也是打印错误信息的,唯一不同的区别就是返回类型。
perror和strerror的区别:
strerror接收到错误码是找到对应的错误信息的地址并返回,我们想打印就打印,不想打印就暂时存起来。
perror做的就有点绝,它不返回错误信息的起始地址,而是接收到错误码后直接在函数内部打印错误信息。
注:perror不需要穿参,是可以直接获取errno的错误码打印出错误信息。
这就是strerror和perror的区别。
第十篇、内存函数
字符串函数只是针对字符串的函数,而内存函数是针对内存块的,不在乎内存中存储的数据!这就是字符串函数和内存函数的区别。
1、memcpy
memcpy库函数的功能:任意类型数组的拷贝
memcpy的函数声明:
void* memcpy(void* destination, const void* source, size_t num);
destination是目标空间,source是源,size_t num是拷贝字节的个数。
为什么还有输入拷贝字节个数呢?
因为memcpy可以拷贝任意类型的数组,可以是字符,可以是int,也可以是struct自定义类型的,但是前提是要输入要拷贝的字节个数,因为传过去的地址被void类型的指针接收,所以不能得知元素大小。
2、memmove
memmove库函数功能:拷贝任意类型的数组,也可以处理重叠内存拷贝问题
memmove函数的声明:
void* memmove(void* destination, const void* source, size_t num);
可以看到memmove和memcpy的返回类型和参数一模一样,唯一不同的只是memmove函数的实现细节
注:memcpy和memmove同样是拷贝内存函数,但是memmove比memcpy多了一项功能就是memmove可以处理重叠拷贝。
3、memset
memory - 记忆(内存),set - 设置。memset就是内存设置的意思。
memset库函数功能:将参数ptr的前num个字节设置成指定的value值。
memset的函数声明:
void* memset(void* ptr, int value, size_t num);
比如我有一个字符数组,字符串是 " hello world " ,我想把它改成 " hello xxxxx",那我们就可以使用memset函数。
注意:memset是将一个字节进行更改,所以尽量不要更改成整型,因为会出现下面这样的结果。
所以你想让它的每个字节都是1是可以做的到的,但是你想让它每个整型都是1这个是做不到的,memset本身就是以字节为单位进行设置的。前面的memcpy和memmove虽然也是以字节为单位来拷贝的,但是它们两边都是在变化着拷贝的,所以能够拷贝正确答案。而这个需拷贝的源始终都是一个值,这个值是不会变化的,每次拷贝一个字节都从这里面的一个字节拷贝到另一个空间。
4、memcmp
memcmp库函数的功能:和strncmp的功能一样,strncmp是比较两个字符串的,memcmp是比较两个数组内存的
memcmp函数的声明:
int memcmp(void* ptr1, void* ptr2, size_t num);
memcmp返回值:如果ptr1比ptr2大就返回大于0的数字,如果ptr1比ptr2小就返回小于0的数字,如果相等就返回0
第十一篇、数据在内存中的存储
所有数据在内存中存储的都是二进制位,先将数据通过规定的方式转换成二进制然后进行存储。
1、整型在内存中存储
在讲解操作符的时候,我们就讲过了下面的内容:
整数的2进制表示方法有三种,即原码、反码和补码
三种表示方法均有符号位和数值位两部分,符号位都是0表示"正",用1表示"负",而数值位最高位的一位是被当做符号位,剩余的都是数值位。
以上仅限于有符号整数。
正数的原、反、补码都相同。
负整数的三种表示方法各不相同。
原码:直接将数值按照正负数的形式翻译成二进制得到的就是原码
反码:原码的符号位不变,其他位按位取反得到的就是反码
补码:反码+1就是补码
对于整数来说,数据在内存中是以补码的形式进行存储的。
2、大小端字节序
数据都是转换成二进制的,但是二进制也是分高低位的,比如符号位是高位。
所以大小端字节序决定数据是先从高位开始存储还是先从低位开始存储。
大端字节序:高位存低地址,低位存高地址。
小端字节序:高位存高地址,低位存低地址。
大端和小端名字的由来:格列佛游记中的一个故事,两个国家因为一件事情没谈拢,这件事情就是鸡蛋应该从大头向小头剥还是从小头向大头剥,没谈拢两个国家就打了一架。
小端字节序:低位字节序存储到低地址,高位字节序存储到高地址
大端字节序:低位字节序存储到高地址,高位字节序存储到低地址
3、浮点数在内存中存储
根据国际标准 IEEE(电气和电子工程协会) 754,任意一个二进制浮点数V可以表示成下面的形式:
V = (-1)^S * M * 2^E
- (-1)^S 表示符号位,当S=0,V为正数;当S=1,V为负数
- M 表示有效数字,M是大于等于1,小于2的
- 2^E 表示指数位
S:S为1表示浮点数是负数,S为0表示浮点数是正数。
M:有效数字
E:指数
- 注:变量本身有类型检查,在给变量赋值时,变量会检查这个值是不是变量所属类型,如果不是就会转换这个值的类型再存储。
- 如果非要把不同类型的值成功存储在不同类型变量空间里就不要给变量赋值,我们只需用不同类型的指针拿到不同类型变量的地址通过地址解引用访问这块空间,并把不同类型的值存储在这块空间,就可以成功把不同类型的值存放在不同类型的变量里。
- 例:变量是安检,带违禁品(不同类型)的数据过了变量(安检)就会被扣(转换),如果不想被扣(转换)就通过其他渠道(指针访问空间)偷偷摸过去(现实中不可以这么做,要遵纪守法)。
第十二篇、自定义类型(struct结构体、union联合、enum枚举)
一、struct结构体
1、结构体声明
结构体是一些值的集合,这些值称为成员变量,结构的每个成员可以是不同类型的变量。
struct tag
{
member-list;//成员列表,可以有多个成员
}variable-list;//变量列表,可以使用该类型创建多个变量
当我们自定义了一个struct,此时只是声明有这个自定义类型,当我用这个类型创建了一个变量,才是真正创建了这个类型。
注:struct声明就像一张房子建筑蓝图(struct结构),里面还有几个房间(成员变量),当我创建变量时就相当于照着这张蓝图建造了一栋房子,里面的房间就是成员变量,可供其他人(数据)居住。
2、结构体内存对齐
我们已经掌握了结构体的基本使用了。
现在我们深入讨论一个问题:计算结构体的大小
这也是一个特别热门的考点:结构体内存对齐
注:结构体类型的大小是由结构体内存对齐来决定的。
对齐规则:
首先得掌握结构体的对齐规则:
1. 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处
2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处
- 对齐数 = 编译器默认的一个对齐数 与 该成员变量大小的较小值
- VS中默认的值为8
Linux中gcc没有默认对齐数,对齐数就是成员自身的大小
3. 结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍4. 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
4、位段
结构体讲完就得讲讲结构体实现 位段 的能力。
注:位段是基于结构体,位段的出现是为了节省空间
4.1 什么是位段
位段的声明和结构是类似的,有两个不同:
1. 位段的成员必须是 int、unsigned int、或 signed int,在C99 中位段成员的类型也可以选择其他类型。
2. 位段的成员名后边有一个冒号和一个数字
比如:
struct A
{
int _a:2;
int _b:5;
int _c:10;
int _d:30;
}
那冒号 ' : ' 后面的数字是什么意思呢?其实冒号后面的数字是给该成员分配的空间大小,单位是二进制位,比如成员_a后面是:2意思是我给该成员分配2个二进制位来存放数据,1个二进制位是1bit,所以可以简单理解为后面的数字的单位就是bit。
所以成员变量_a:2就是2个bit位,_b:5就是5个bit位,_c:10就是10个bit位,_d:30就是30个bit位。
注:结构体位段不会内存对齐
位段的内存分配:
1. 位段成员可以是int、unsigned int、signed int 或是 char等类型。
2. 位段的空间是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的。
3. 位段涉及很多不确定因素,位段是不跨平台的,注意可移植的程序应该避免使用位段。
5、柔性数组
也许你从来没听说过柔型数组(flexible array)这个概念,但是它确实是存在的。
C99中,结构中最后一个元素允许是未知大小的数组,这就叫做【柔性数组】成员。
柔性数组:
1. 一定在结构体中
2. 一定是最后一个成员
3. 一定是未知大小的数组(柔型数组)
注:并且柔性数组是需要配合动态内存管理来使用的
例如:
typedef struct st_type
{
int i;
int a[];//柔性数组成员
}type_a;
柔性数组的特点:
- 结构中的柔性数组成员前面必须至少要有一个其他成员
- sizeof返回这种结构的大小不包括柔性数组的内存
- 包含柔性数组成员的结构用malloc函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔型数组的预期大小
二、union联合体
联合体的类型结构上和struct结构体很相似,但是在内存开辟确有一定差异,联合体也叫共用体,顾名思义就是共用一块空间,其实联合体的成员是共用一块空间的。
和结构体一样,联合体类型的声明也是这样的:
#include <stdio.h>
union U
{
char c;
int i;
};
int main()
{
union U u = {0};
printf("%d\n",sizeof(u));
return 0;
}
最后计算大小为4个字节,因为共用一块空间,所以只开辟最大类型的空间。
注:联合体和结构体在内存开辟上有一处相同,就是最后联合体的大小必须对齐最大成员的整数倍。
union联合体的应用场景:当有两个或多个相同类型的数据需要一个结构来集成在一起,但是每次使用只使用一个类型的空间,我们可以将这多个类型的全部集成一个联合体,每个类型的地址都是一块空间,相当于共用一块,使用一个类型也保证了其他类型不额外占用多余空间。
三、enum枚举
枚举类型的声明
没枚举顾名思义就是一一列举。
把可能的取值一一列举。
比如我们现实生活中:
一周的星期一到星期日是有限的7天,可以一一列举
性别有:男、女、保密,也可以一一列举
月份有12月,也可以一一列举
三原色,也是可以一一列举
这些数据的表示就可以使用枚举了。
enum Day//星期
{
Mon,
Tues,
Wed,
Thur,
Fri,
Sat,
Sun
};
enum Sex//性别
{
MALE,
FAMALE,
SECRET
};
enum color//颜色
{
RED,
GREEN,
BLUE
};
这里枚举里的常量都是列出的枚举类型的可能取值
这些列出的可能取值被称为:枚举常量
每个枚举里的常量,从第一个默认都是0,依次向下增长的常量集合
简单理解枚举:枚举就像是一个结构内部集成多种常量的标识符,#define每次也只能创建一个标识符常量。
为什么使用枚举呢?
我们可以使用#define定义常量,为什么非要使用枚举?
枚举的优点:
1. 增加代码的可读性和可维护性
2. 和#define定义的标识符比较枚举有类型检查,更加严谨
3. 便于调试,预处理阶段会删除#define定义的符号
4. 使用方便,一次可以定义多个常量
5. 枚举常量是遵循作用域规则的,枚举声明在函数内,只能在函数内使用
第十三篇、动态内存开辟
动态内存开辟:就是方便增加空间的动态开辟,使用动态开辟函数返回一个开辟好空间的地址,通过这个地址便可以访问这块动态开辟的空间。
1、malloc
malloc是用来申请内存的,动态内存开辟的方式有些特殊,开辟的内存空间并不是栈区的空间,而是堆区的空间,所以程序结束时并不会自动销毁并回收该空间,所以就有了free,每次用完该空间就记得使用free将该空间释放掉。不然它将一直占用内存空间。
调用动态内存开辟函数时需要包含头文件#include <stdlib.h>
malloc函数的声明:
void* malloc(size_t size);
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
- 如果开辟成功,则返回一个指向开辟好空间的指针
- 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查
- 返回值的类型是void*,所以malloc函数并不知道开辟空间的类型,具体在使用的时候由使用者自己来决定
- 如果参数size为0,malloc的行为是标准还是未定义的,取决于编译器
既然可以申请到空间并且使用,那还需要释放掉该空间,那怎么释放呢?
malloc申请的空间怎么回收呢?
1. free回收
2.自己不释放的时候,程序结束后,也会由操作系统回收
注:动态内存开辟的函数开辟空间都是在堆区上开辟的,内存分为三个区域:栈区、堆区、静态区
2、free
free的函数声明:
void free(void* ptr);
free是用来释放动态开辟的空间的,只需要将这块空间的起始位置的指针传递给free,free可以通过该地址向后释放这块空间。
free函数就是用来释放动态开辟的内存。
如果参数ptr指向的不是动态开辟的,那free函数的行为是未定义的。
如果参数ptr是NULL指针,则函数什么事都不做
注意:free释放的空间仅限于动态内存开辟的空间,必须是堆区的空间
给free一个指向开辟好的堆区的指针,就可以通过这个指针释放空间。最后不要忘了将指向free释放掉的空间的指针指向NULL,因为它指向的空间已经被free释放,再解引用就是非法访问了,所以要置为NULL。
3、calloc
C语言还提供了一个函数叫calloc,calloc函数也用来动态内存分配,原型如下:
void* calloc(size_t num,size_t size);
calloc函数的注意事项:
如果开辟成功,则返回一个指向开辟好空间的指针
如果开辟失败,则返回一个NULL指针,因此calloc的返回值一定要做检查
返回值的类型是void*,所以calloc函数并不知道开辟空间的类型,具体在使用的时候由使用者自己来决定
可以看见calloc的参数比malloc的参数多了一个,calloc和malloc一样,都是动态内存开辟的,那这多出的一个参数有什么不同呢?calloc和malloc的区别又是什么。
malloc和calloc的区别:
1. 参数区别:malloc的参数size是需要动态开辟的字节大小,calloc的参数1 num是需要开辟的元素个数,参数二 size是每个元素的大小。
2. 功能区别:malloc开辟好空间后什么也不管并直接返回该空间的初始地址,而calloc开辟好空间会将空间里全部初始化为0并返回初始地址。
所以它们除了上面不同外,其他地方基本相同:
int* p = (int*)malloc(10*sizeof(int));
int* p = (int*)calloc(10,sizeof(int));
4、realloc
- realloc函数的出现让动态内存管理更加灵活
- 有时我们会发现过去申请的空间太小了,有时我们又会觉得申请的空间过大了,那为了合理的使用内存,我们一定会对内存的大小做灵活的调整。那realloc函数就可以对动态开辟内存进行扩容
函数原型如下:
void* realloc(void* ptr,size_t size);
- ptr是需要调整的内存地址
- size调整之后新的大小
- 返回值为调整之后的内存起始位置
- 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间
假设malloc开辟的空间不够用了,那就可以使用realloc在原有的空间大小开辟出新的空间大小:
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* ptr = (int)malloc(20);
if (ptr != NULL)
{
int* tmp = (int*)realloc(ptr, 40);//注意realloc开辟空间需要新的指针来接收,不要用原来的指针来接收
}
return 0;
}
realloc在调整内存空间存在两种情况:
1. 原有空间之后有足够大的空间
2. 原有空间之后没有足够大的空间
如果是情况1,后面未分配的空间足够需要开辟的大小,就会在原有的空间的基础上增加开辟空间的大小。
但如果是情况2,后面未分配的空间不够需要开辟的大小,编译器找一个新的空间并会将之前开辟空间里面存储的数据存放进新找到的空间并将原来的空间销毁。
总结:
1. 使用malloc 或 realloc 函数开辟的空间不会被初始化为全0,只有使用calloc函数开辟的空间会被初始化为全0。
2. free函数只能释放动态内存开辟的空间,如果传入其他空间的地址会报错。还有当把一个动态内存的地址传给free释放掉这块空间后,要将指向这块空间的指针置为NULL,以免造成非法访问
3. realloc函数一般是用来扩容空间使用的,但是当传递NULL给realloc函数时,此时的realloc和malloc是等价的,都是直接开辟一块动态内存并返回地址
5、总结C/C++程序内存分配的几个区域
C/C++程序内存分配的几个区域:
- 栈区(stack):在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限,栈区主要存放运行函数而分配的局部变量,函数参数,返回数据,返回地址等。
- 堆区(heap):一般由程序员自己分配释放,若程序员不释放,程序结束时可能由OS回收,分配方式类似链表。
- 数据段(静态区):(static)存放全局变量,静态数据,程序结束后由系统释放。
- 代码段:存放函数体,(类成员函数和全局函数)的二进制代码
- 本篇C语言笔记到了这里也就结束了,我们下一篇笔记再见-
第十四篇:文件操作
1、为什么使用文件
如果没有文件,我们写的程序的数据是存储在电脑的内存中,如果程序退出,内存回收,数据就丢失了,等再次运行程序,是看不到上次程序的数据的,如果要将数据进行持久化的保存,我们可以使用文件。
2、什么是文件?
磁盘(硬盘)上的文件就是文件。
但是程序设计中,我们一般谈两个文件,分别是程序文件、数据文件(从文件的角度来分类的)。
程序文件:
程序文件包括源程序文件(后缀为.c)、目标文件(windows环境后缀为.obj),可执行文件(windows环境后缀为.exe)。
数据文件:
文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件,或者输出内容的文件。
本章讨论的是数据文件。
在以前各篇笔记所处理数据的输入输出都是以终端为对象的,即从终端的键盘输入数据,运行结果显示到显示器上,其实有时候我们会把信息输出到磁盘上,当需要的时候再从磁盘上把数据读取到内存中使用,这里处理的就是磁盘上的文件。
3、二进制文件和文本文件
根据数据的组织形式,数据文件被称为文本文件或者二进制文件。
数据在内存中以二进制的形式存储,如果不加转换的输出到外存的文件中,就是二进制文件。
如果要求在外出上以ASCII的形式存储,则需要再存储前转换,以ASCII字符的形式存储的文件就是文本文件。
一个数据在文件中是怎么存储的呢?
字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以用二进制形式存储。
比如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节),而二进制形式输出,则在磁盘上只占4个字节。
4、标准流
文件操作时我们需要自己打开文件(流),当操作完后需要自己关闭文件(流),那为什么我们从键盘输入数据,向屏幕上输出数据,并没有打开流呢?
那是因为C语言程序在启动的时候,默认打开了3个流:
- stdin - 标准输入流,大多数的环境中从键盘输入,scanf函数就是从标准输入流中读取数据。
- stdout - 标准输出流,大多数环境中输出值显示器界面,printf函数就是将信息输出到标准输出流中。
- stderr - 标准错误流,大多数环境中输出到显示器界面。
这是默认打开了这三个流,我们使用scanf、printf等函数就可以直接进行输入输出操作的。
stdin、stdout、stderr 三个流的类型是:FILE*,通常称为文件指针。
C语言中,就是通过FILE*的文件指针来维护流的各种操作的。
- 重点:终端其实也是文件,只不过是一种特殊的文件,既然是文件我们就需要创建一条可操作这个文件的流。
- FILE类型:FILE其实是文件类型,原理就是一种自定义类型struct结构体里面有各种成员,这些成员可以存储文件信息,所以FILE类型也可以称为文件信息区。我们平常创建了一个文件,需要程序对这个文件进行操作,我们就需要这个FILE来存储这个文件信息,怎么存储呢?这里就需要用到fopen函数。
- fopen库函数:有两个参数,都是字符串,第一个字符串是文件的路径和文件名,有了这个信息系统就可以通过信息找到这个文件并创建一个FILE文件类型把文件的信息存储进来,这就是我们这个文件的文件信息区,第二个参数是决定你对这个文件的操作,读或写。然后文件信息区的创建才算完整,最后fopen函数返回这个文件信息区的地址,我们就需要创建一个FILE*类型的文件指针来存储fopen返回的这个地址,最后这个FILE*类型的文件指针变量就是程序与该文件之间的流,简称:文件流。
总结:终端是一种特殊文件。终端的标准流其实和我们上述的差不多,都是文件信息区存储文件信息并给的文件信息区的地址罢了。只是程序运行时系统自动打开了这个流(标准流),不需要我们自己打开。
5、文件指针变量
缓冲文件系统中,关键的概念是 "文件类型指针" ,简称为 "文件指针"。
每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及文件当前的位置等)。这些信息是保存在一个结构体变量中的,该结构体类型是由系统声明的,取名FILE。
例如:VS2013编译环境提供的stdio.h头文件中有以下的文件类型声明:
struct _iobuf{
char *_ptr;
int _cnt;
char* _base;
int _flag;
int _file;
int _charbuf;
int _bufsiz;
char* tmpfname;
};
typedef struct _ioduf FILE;
不同的c编译器的FILE类型包含的内容不完全相同,但是大同小异。
每当打开一个文件的时候,系统会根据文件的情况自动创建一个FILE类型的变量,并填充其中信息,该结构体类型的变量里存放着我们需要打开的文件的信息,因此被称为文件信息区。使用时不必关心细节。开辟好文件信息区后便会返回该信息区的地址,我们需要FILE*类型的指针来接收这个地址,这个FILE*类型指针就是流,属于文件的流。
一般都是通过FILE指针来维护这个FILE结构变量,这样使用更加方便。
FILE* PF;//文件指针变量
定义pf是一个指向FILE类型的指针变量,可以使pf指向某个文件的文件信息区(是一个结构体变量),通过该文件信息区中的信息就能够访问该文件,也就是说,通过文件指针变量能够间接找到与它关联的文件。
比如:
注:流就是文件类型指针接收文件信息区地址,我们想让程序和哪个外部设备联系就打开(创建)他们之间的流,标准流就是这样来的。
6、文件的打开和关闭
文件在读写之前应该先打开文件,在使用结束之后应该关闭文件。
在编写程序的时候,在打开文件的同时,都会返回一个FILE*的指针变量指向该文件,也相当于建立了指针和文件的关系。
ANSI C规定使用fopen来打开文件,fclose来关闭文件。
//打开文件
FILE* fopen(const char* filename, const char* mode);
//关闭文件
int fclose(FILE* ftream);
fopen的函数声明:参数1:filename是所需的文件名,参数2:mode是打开流的形式,是输入还是输出。返回类型:FILE*是一个文件信息区的地址,通过该地址找到文件信息区访问文件。
fclose的函数声明:参数:ftream是我们打开文件时用来接收fopen返回值是创建的变量,将这个变量所存储的地址传参过去就可以回收文件信息区所占用的空间,就是关闭文件
fopen函数的参数2mode的打开形式是什么意思呢?怎么表示打开形式呢?
mode表示文件的打开模式,下面都是文件的打开模式:
注:fopen也是会打开失败的,如果打开失败,则返回空指针NULL。打开成功,则返回开辟好后的文件信息区的地址,所以使用前一定要判断一下。
6、文件的两种路径
文件的打开方式:
文件打开有两种路径,一种是相对路径,一种是绝对路径
相对路径:文件与程序文件在同一个大文件夹下,只需填写文件少量信息再些许操作便可以打开文件就是相对路径。
绝对路径:文件和程序文件不在同一路径,需要填写路径全部信息。
总结:文件路径也分为两个,分别是绝对路径和相对路径。
绝对路径:是在文件和程序文件位置不同时需要填写完整的路径来访问。
相对路径:是和程序文件在同一个文件里的,可能不一级文件,但是位置是有关联的,被称为相对路径
7、文件顺序读写
程序和文件之间流创建好了,我们如何通过流来操作文件呢?
答案是读写函数
以上所有函数声明:
//字符读写函数
int fputc(int character, FILE* stream);
int fgetc(FILE* stream);
int fputs(const char* str, FILE* stream);
char* fgets(char* str, int num, FILE* stream);
int fprintf(FILE* stream,const char* format,...);
int fscanf(FILE* stream, const char* format,...);
//二进制读写函数
size_t fwrite(const void* ptr, size_t size, size_t count, FILE* stream);
size_t fread(void* ptr, size_t size, size_t count, FILE* stream);
注:以上的函数只有填写文件流就可以把数据读写到文件,当然也可以填写标准流,通过标准流便可以在终端上进行读写,这更加证明了终端也是文件。
6、文件的随机读写
什么是文件的随机读写?文件的随机读写就是定位到我们想要的位置开始向后读写,从开头向后读写就是顺序读写。定位位置向后读写就是随机读写。
fseek
int fseek(FILE* stream, long int offset, int origin);
fseek函数:参数1就是stream文件的流。参数2offset就是偏移量,是某个位置开始的向后的偏移量处的位置开始向后读写。而参数三origin就是决定这某个位置。
参数3:origin有三种位置:
是从这些位置开始向后计算偏移量的位置,从计算好偏移量的位置开始向后读取。
文件里是存在文件指针的,正常情况下调用一次后该文件指针会向后指向,下一次调用是从后面继续向后访问。顺序读写函数是这样的。而随机读写函数是可以随机改变文件指针的指向,让文件指针改变位置从而进行读取或写入。
注:
1. 文件指针并不是我们熟知的C语言指针,而是一个表示文件位置的指针。
2. 偏移量为负数是向前偏移,偏移量为整数是向后偏移。
3. 不管文件指针的位置如何改变,文件都是自动的从前向后访问
ftell
ftell的函数声明:
long int ftell(FILE* stream);
如果我们不知道当前的文件初始位置与文件指针之间的偏移量是多少时我们就可以使用ftell库函数,这个函数会计算好文件指针的偏移量并返回。
rewind
让文件指针的位置回到文件的起始位置
比如我随意用fseek来设置文件指针的位置导致乱了套,这时我们就可以使用rewind来让文件指针回到起始位置,功能比较简单,容易理解。
void rewind(FILE* stream);
7、文件读取结束的判定
牢记:在文件读取过程中,不能用 feof 函数的返回值直接来判断文件是否结束。
feof 的作用是:当文件读取结束的时候,判断是读取结束的原因是否是:遇到文件尾结束。
文件读取结束有两种原因:
1. 文件遇到末尾了
2. 文件读取错误了
8、文件缓冲区
ANSIC 标准规定采用 "缓冲文件系统" 处理数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块 "文件缓冲区" ,从内存中向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上,如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译器系统决定的。
第十五篇:文件操作
1、翻译环境和运行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行机器指令(二进制指令)
第2种是执行环境,它用于实际执行代码
2、翻译环境
那翻译环境是怎么将源代码转换为可执行的机器指令的呢?这里我们就得展开讲解一下翻译环境所做的事情。
其实翻译环境是由编译和链接两个大的过程组成的,而编译又可以分解成:预处理(预编译)、编译、汇编三个过程。
一个C语言的项目中可能有多个.c文件一起构建,那么多个.c文件如何生成可执行程序呢?
- 多个.c文件单独经过编译处理生产对应的目标文件(.obj)
- 注:在Windows环境下的目标文件的后缀是.obj,Linux环境下目标文件的后缀是.o
- 多个目标文件(.obj)和链接库一起经过链接器的处理生成最终的可执行程序
- 链接库是指运行时库(它是支持程序运行的基本函数集合)或者第三方库
- 什么是链接库?
在学习C语言时我们经常会用到库函数,比如printf、scanf,这些函数肯定不是凭空出现的,像这样的库函数是被编译成一个一个的链接库,这些函数都包含在这个链接库中也就是第三方库,是C编译器厂商自己提供的库来供我们使用。在我们的C程序中会用到库函数,但是必须经过链接器目标文件和链接库一起链接才能调用该库函数。
举个例子:比如有一条河,两个人想见面,一个人(程序)在河的这边,而另一个人(库函数实现)在河的那边,那两人想见面(调用)是不是必须搭一座桥,而这座桥就是链接器。
其实还可以把编译器的编译展开成3个过程,那就变成了下面的过程:
Linux环境下:
3、编译
1、预处理(预编译)
在预处理阶段,源文件和头文件会被处理成为.i位后缀的文件。
在gcc环境下想观察一下,对test.c文件预处理后的.i文件,命令如下:
gcc -E test.c -o test.i
预处理阶段主要处理那些源文件中#开始的预处理指令,比如:#include,#define 处理规则如下:
- 将所有的#define删除,并展开所有宏定义。
- 处理所有的条件编译指令,如:#if、#ifdef、#elif、#else、#endif。
- 处理#include预处理指令,将包含的头文件的内容插入到该预编译指令的位置。这个过程是递归进行的,也就是说被包含的头文件也可能包含其他文件
- 删除所有注释
- 添加行号和文件名标识,方便后续编译器生成调试信息等。
- 或保留所有的#pragma的编译器指令,编译器后续会使用
经过预处理后的.i文件中不再包含宏定义,因为宏已经被展开。并且包含的头文件都被插入到.i文件中,所以当我们无法知道宏定义或者头文件是否包含正确的时候,可以查看预处理后的.i文件来确认。
2、编译
编译过程就是将预处理后的文件进行一系列:词法分析、语法分析、语义分析、符号汇总(链接阶段会讲一下符号汇总有什么用)及优化,生成相应的汇编指令。简单来说编译过程就是将C语言代码转换成汇编代码。
编译过程的命令如下:
gcc -S test.i -o test.s
对下面代码进行编译的时候,会怎么做呢?假设有下面的代码:
array[index] = (index + 4) * (2 + 6);
词法分析
将源代码程序输入扫描器,扫描器的任务就是简单的进行词法分析,把代码中的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等)。
上面代码进行词法分析后得到了16个记号:
语法分析
接下来语法分析器,将对扫描产生的记号进行语法分析,从而产生语法树。这些语法树是以表达式为节点的树
语义分析
由语义分析器来完成语义分析,即对表达是的语法层面分析,编译器所能做的分析是语义的静态分析,静态语义分析通常包括声明和类型匹配,类型的转换等。这个阶段会报告错误的语法信息。
符号汇总
这里的符号指的是变量名和函数名,就是找到变量名和函数名把它们汇总起来。
3、汇编
汇编器是将汇编代码转变(翻译)为可执行的二进制指令,每一个汇编语言几乎都对应一条机器指令。就是根据汇编指令和机器指令的对照表一一的进行翻译,也不做指令优化。
注:这个过程还会形成符号表,是根据编译过程的符号汇总生成符号表的。
汇编的命令如下:
gcc -c test.s -o test.o
4、链接
链接是一个复杂的过程,链接的时候需要把一堆文件链接在一起才生成可执行程序。
链接过程主要包括:地址和空间分配,符号决议和重定位等这些步骤。
链接解决的是一个项目中多文件、多模块之间互相调用的问题。
链接主要就是处理不同文件之间的相互调用,比如:
add.c
int g_val = 2023;
int Add(int x, int y)
{
return x + y;
}
test.c
extern int Add(int x,int y);
extern int g_val;
int main()
{
printf("%d\n",g_val);
printf("%d\n",Add(2, 3));
return 0;
}
这两个文件直接是如何链接的才可以相互调用的呢?
注意:这两个文件会生产目标文件:add.obj、test.obj,在生产目标文件之前的编译过程中会对两个文件进行符号汇总,然后在汇编过程中又会形成符号表。比如add.c文件在编译过程中会进行符号汇总:g_val、Add,test.c在编译过程中进行符号汇总:g_val、Add、main,下一步在汇编过程中每个文件汇总出的符号是会形成符号表的,符号表中每个符号都有对应的地址。
例如:add.obj符号表
test.obj符号表
注:以上地址是自己填上去的,真正的地址不是这样,只是举个例子使用
test.c里的符号Add和g_val由于是外部声明符号,并不知道符号真实地址,所以形成符号表时就给个无效地址。
链接过程中这些符号表是要进行合并的,多个目标文件都是一个项目的,没必要那么多符号表,所以只需将多个文件的符号表合成一个就够了。
add.obj和test.obj经过链接合并成的符号表:
因为合并时找到了符号本身的有效地址,多以合并时将无效地址替换掉了,最终两个文件的符号表合并在了一起,运行时便可以通过该符号表的地址找到对应符号并调用。
而合并符号表过程中将test.obj符号表中Add符号的无效地址或g_val符号的无效地址替换掉就叫做符号的决议和重定位。
总结:
多个文件之间相互调用首先需要在编译阶段进行符号汇总,然后汇编阶段将汇总出的符号形成符号表,符号表中的每个符号都分配有对应地址。最后在链接阶段将多个目标文件的符号表进行符号表合并,至此多个文件的符号都有了联系,一个文件如果想调用另一个文件的函数就可以通过符号表的地址找到该函数并调用。
5、运行环境
1. 程序必须载入内存中,在有操作系统的环境中,一般这个由操作系统完成,程序的载入必须要手工安排,也可能是通过可执行代码置入只读内存完成。
2. 程序的指向便开始,接着便调用main函数。
3. 开始执行程序代码,这个时候程序将使用一个运行时堆栈(stack(函数栈帧)),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留它们的值。
4. 终止程序,正常终止main函数;也有可能是意外终止。
第十六篇:预处理
1、预定义符号
C语言设置了一些预定义符号,可以直接使用。预定义符号也是在预处理期间处理的。
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1, 否则未定义
2、#define 定义常量
基本语法:
#define name stuff
举个例子:
#define MAX 1000
#define reg register //为register这个关键字,创建一个简短的名字
#define do_forerer for(;;) //定义一个死循环的for,使用这个标识符时会一直死循环
#define CASE break;case //在写case语句的时候启动把break写上
//如果定义的stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续航符)
#define DEBUG_PRINT printf("file:%s\tline:%d\t \
date:%s\ttime:%s\n", \
__FILE__, __LINE__, \
__DATE__, __TIME__)
总结:#define定义标识符的后面的可以是常量、字符、浮点数、字符串、关键字或一段代码等...
3、#define定义宏
define不止可以定义常量,还可以定义宏。
#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
下面是宏的申明方式:
#define name(parament-list) stuff
其中的parament-list是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意:
参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
那宏怎么使用呢?举个例子:
#include <stdio.h>
#define SQAURE(X) X*X//假设我要计算一个数的平方而使用define定义一个宏
int main()
{
int a = 5;
printf("%d\n", SQAURE(a));//传一个参数过去当经过预处理阶段时会替换成我们定义的表达式
return 0;
}
这样来看是不是感觉宏和函数的使用方式有一些相似。
注意:宏并不是函数,给函数传表达式函数会先计算表达式结果再接收,宏是直接把表达式套用在宏体里。
总结:
1、宏的参数是如果是表达式,不会计算的。和函数相反,函数是先将表达式参数进行运算,将运算结果作为参数传参。
2、宏是直接将参数原封不动的替换到宏定义的表达式中的。
宏的参数是不参与计算的,当我们给宏的参数传递一个表达式时,并不是将表达式计算结果进行计算,而是在预处理阶段直接将表达式参数替换到宏定义的表达式,然后再替换到调用宏的位置。
宏和函数的对比:
宏通常被应用于执行简单的运算。
比如在两个数中找出较大的一个时,写成下面的宏,更有优势一些。
#define MAX(a, b) ((a)>(b)?(a):(b))
那为什么不用函数来完成这个任务呢?
原因有二:
1. 用于调用函数和从函数返回的代码可能比实际指向这个小型计算工作所需要的时间更多。所以宏比函数在程序的规模和所读方面更胜一筹。
2. 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏可以适用于整型、长整型、浮点型等可以用于>来比较的类型。宏是类型无关的。
和函数相比宏的劣势:
1. 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
2. 宏是没法调试的
3. 宏由于类型无关,也就不够严谨。
4. 宏可能会带来运算符优先级的问题,导致容易出现错误。
看到这里感觉函数和宏之间各有千秋,函数有函数的好处,宏有宏的好处,那宏有没有什么事函数做不到的呢?当然有。
宏有时候可以做到函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
#define MALLOC(num, type) \
(type*)malloc(num * sizeof(type))
...
//使用
int* a = MALLOC(10,int);//类型作为参数
//预处理器替换之后
int* a = (int*)malloc(10 * sizeof(int));
宏和函数的一个对比:
那什么时候该有宏,什么时候该有函数呢?
- 如果计算逻辑比较简单就可以使用宏。
- 如果计算逻辑比较复杂就可以使用函数。
4、#和##
1. #运算符
#既不是#include或#define中的#,又不是+、-、*、/中的运算符。#是预处理中的一种运算符。
#运算符将宏的一个参数转换为字符串字面量,它仅允许出现在带参数的宏的替换列表中,#运算符所执行的操作可以理解为"字符串化"。
2. ## 运算符
## 可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符,## 被称为记号粘合这样的连接必须产生一个合法的标识符,否则器结果就是未定义的。
注:这两个符号都是宏内部符号
5、#undef
这条指令又能与移除一个#define的标识符定义或宏定义
#undef NAME
//如果现存的一个名字需要被重新定义,那么它的就名字首先要被移除
#undef的使用:
#define M 100
int main()
{
int a = M;//a = 100
//当想使用M这个标识符名字重新定义
#undef M
//移除标识符M的定义
#define M 200
int b = M;//b = 200
printf("a=%d b=%d\n", a, b);
return 0;
}
6、条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
注:条件编译后的只能是常量或常量表达式来进行判断,不能使用变量来进行判断。
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
满足条件,就编译
不满足条件,就放弃编译
常见的条件编译指令:
1.条件编译
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值
如:
#define __DEBUG__ 1
#if __DEBUG__
//...
#endif
2.多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
4.嵌套指令
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
条件编译和 分支语句i f 的区别:
1. 条件判断:条件编译判断部分只能是常量表达式,例如:#define标识符常量。
2. 分支处理:条件编如果判断为真,就只保留该分支的语句,其他分支的语句则在预处理阶段删除。而if语句判断为真,就走这一条分支,但是其他分支的语句还保留。
注意:条件编译最后一定要放一条#endif表示结束。
7、头文件包含
头文件包含分为本地包含和库文件包含。
本地文件包含:
#include "filename.h"
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。
如果找不到就提示编译错误。
库文件包含:
#include <filename.h>
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
那是不是可以说,对于库文件也可以使用" "的形式包含?
#include "stdio.h"
答案是肯定的,可以,但是这样查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。
如果直接这样写,test.c文件中将test.h包含5次,那么test.h文件的内容将会被拷贝5份在test.c中。
如果test.h文件比较大,这样预处理后代码会剧增。如果工程比较大,有公共使用的头文件,大家
都能使用,如何解决头文件被重复引入的问题呢?答案:条件编译
#ifndef __TEST_H__
#define __TEST_H__
//头文件内容
#endif //__TEST_H__
或者
或者
#pragma once
就可以避免头文件的重复引入。
注:
推荐《高质量C/C++编程指南》中附录的考试试卷(很重要).
笔试题:
1. 头文件中的 ifndef / define /endif是干什么用的?
答:是用来避免重复头文件重复包含的,ifndef判断标识符是否定义,如果未定义就继续向下编译,知道endif为止。中间使用define定义ifndef所判断的标识符,下一次再包含头文件属于重复包含但是因为第一次包含时顺便定义了该标识符,所以第二次包含时不会通过ifndef,ifndef会在预处理阶段将头文件一下内容删除,不会再被编译进包含该头文件的文件里。
2. #include <filename.h> 和 #include "filename.h"有什么区别?
答:区别是<>所包含的头文件只寻找1次,而" "包含的头文件寻找2次。<>说明包含的头文件是标准库中的头文件,便会直接去标准库中寻找,找不到就编译错误,并不会额外花费时间去本地文件路径找。" "说明包含的头文件是本地文件,会先去本地文件路径下寻找,如果未找到就去标准库找,找了2次。