C语言从入门到进阶(15万字总结)
前言:
《C语言从入门到进阶》这本书可是作者呕心沥血之作,建议零售价1元,当然这里开个玩笑。
本篇博客可是作者之前写的所有C语言笔记博客的集结,本篇博客不止有知识点,还有一部分代码练习。 有人可能会问,作者不会是cv战士吧!作者在这里回答大家,有cv战士的成分,但不完全是。我是将之前博客冗余的部分删除。有句话叫取其精华,去其糟粕当嘛!当然作者除了删除冗余部分还会修改一小部分,因为之前写博客的技术还不太成熟,当然现在也不太成熟。所以还是要靠大家的支持作者才有十分的动力去创作,所以在这里要感谢大家的支持,也感谢每一位能进来看一下的读者。那么废话不多说,我们现在就开始。
注:右下角也有目录,可以通过右下角的目录跳到对应的知识点。
目录:
目录
编辑
第一章:初识C语言
1、C语言是什么?
2、第一个C语言程序
3、main函数
4、库函数
4.1 头文件详解
5、关键字介绍
6、字符和ASCII编码
7、字符串和 ' \0 '
8、转义字符
9、语句和语句分类
12.1 空语句
12.2 表达式语句
12.3 函数调用语句
12.4 复合语句
12.5 控制语句
13、注释是什么?为什么写注释?
13.1注释的2种形式
13.2注释会被替换
第二章:数据类型和变量
1、数据类型的介绍
1.1 字符类型
1.2 整型
1.3 浮点型
1.4 布尔类型
2、signed和unsigned
3、数据类型的取值范围
4、变量
4.1 变量的创建
4.2 变量的分类
5、算术操作符:+、-、*、/、%
5.1 + 和 -
5.2 * 和 /
5.3 %操作符
6、赋值操作符:=和复合赋值
6.1 连续赋值
6.3 复合赋值
7、单目操作符
7.2 +和-
8、强制类型转换
9、scanf 和 printf
9.1 printf
9.1.1 基本用法
9.1.2 占位符
9.1.3 占位符列举
9.1.4 输出格式
9.2 scanf
9.2.1 基本用法
1、C语言的三种结构
2、if语句
2.1 if
2.2 else
2.3 分支中的多条语句
2.4 嵌套if
2.5 练习:打印出年龄阶段
2.6 悬空else问题
2、关系操作符
3、逻辑操作符&&,| |,!
3.1 逻辑取反操作符!
3.2 与操作符&&
3.3 或操作符| |
3.4 练习:闰年的判断
3.5 短路
4、switch语句
4.1 switch
4.2 switch中的break
4.3 练习:打印对应日期
4.4 switch中的default
5、while循环
5.1 if 和while的对比
5.2 while的执行流程
5.3 while循环的实践
5.4 练习:打印值的每一位
6、for循环
6.1 语法形式
6.2 for循环的执行流程
6.3 for循环的实践
6.4 while循环和for循环的对比
6.5 练习
7、do-while循环
7.1 语法形式
7.2 do while循环流程
7.3 练习
8、break和continue语句
8.1 break
8.2 continue
9、循环的嵌套
9.2 练习2:打印乘法口诀表
10、goto语句
11、猜数字游戏
11.1 随机数生成
11.1.1 rand
11.1.2 srand
11.1.3 time
编辑编辑
11.1.4 设置随机数的范围
11.2 猜数字游戏的实现
1、数组的概念
2、一维数组的创建和初始化
2.1 数组创建
2.2 数组初始化
2.3 数组的类型
3、一维数组的使用
3.1 数组的下标
3.2 数组的输入
4、一维数组在内存中的存储
5、sizeof计算数组元素个数
6、二维数组的创建
6.1 二维数组的概念
6.2 二维数组的创建
7、二维数组的初始化
7.1 不完全初始化
7.2 完全初始化
7.3 按照行初始化
7.4 初始化省略行,但是不能省略列
8、二维数组的使用
8.1 二维数组的下标
8.2 二维数组的输入输出
9、二维数组在内存中存储
10、变长数组
11、数组代码练习
第五章:函数
1、函数的概念
2、库函数
2.1 标准库和头文件
2.2 库函数的使用方法
2.2.1 功能
2.2.2 头文件包含
2.2.3 实践
2.2.4 库函数文档一般格式
3、自定义函数
3.1 函数的语法形式
3.2 函数举例
4、实参和形参
4.1 实参
4.2 形参
4.3 形参和实参的关系
5、return语句
6、数组做函数参数
7、传值调用和传址调用
7.1 传值调用
7.2 传址调用
8、嵌套调用和链式访问
8.1 嵌套调用
8.2 链式访问
9、函数的声明和定义
9.1 单个文件的函数声明和定义
9.2 多个文件的函数声明和定义
10、static和extern
10.1 extern外部声明
10.2 static静态修饰
10.2.1 static修饰局部变量
10.2.2 static修饰全局变量
10.2.3 static修饰函数
结论:static的两种使用方法
第六章:函数递归
1、递归是什么?
2、递归的限制条件
3、递归举例
3.1 举例1:求n的阶乘
3.1.1 分析和代码实现
3.1.2 运行结果:
3.2 举例2:顺序打印一个整数的每一位
3.2.1 分析和代码实现
3.2.2 画图推演
4、递归与迭代
第七章:操作符
1、二进制
1.1 2进制转10进制
1.2 10进制转2进制
1.4 2进制转8进制
1.4 2进制转16进制
2、原码、反码、补码
3、移位操作符
3.1 左移操作符
3.2 右移操作符
4、位操作符:&、|、^、~
一道变态的面试题:
练习:求出一个整数的二进制里有多少位是1
练习3:判断当前整数是不是2^n
按位取反操作符 ~
练习:改变整数二进制中的位数
5、逗号表达式
6、下标访问[ ]、函数调用()
6.1 下表访问[ ]
6.2 函数调用()
7、操作符的属性:优先级、结合性
7.1 优先级
7.2 结合性
8、表达式求值
8.1 整形提升
编辑
8.2 算数转换
第八章:深入理解指针
深入理解指针(1)
1、内存和地址
1.1 内存
1.2 如何理解编址
2、指针变量和地址
2.1 取地址操作符(&)
2.2 指针变量和解引用操作符(*)
2.3 指针变量的大小
3、指针变量类型的意义
3.1 指针的解引用
3.2 指针+-整数
4、const修饰指针
4.1 const修饰变量
5、指针运算
5.1 指针+-整数
5.2 指针-指针
5.3 指针的关系运算
6、野指针
6.1 野指针成因
6.2 如何规避野指针
6.2.1 指针初始化
6.2.2 小心指针越界访问
6.2.3 指针变量不再使用时,及时置为NULL,指针使用之前检查有效性
6.2.4 避免返回局部变量的地址
7、assert断言
8、指针的使用和传址调用
8.1 传址调用
8.2 strlen的模拟实现
深入理解指针(2)
1、数组名的理解
2、数组传参的本质
3、冒泡排序
4、二级指针
5、指针数组
6、指针数组模拟二维数组
深入理解指针(3)
1、字符指针
2、数组指针变量
3、二维数组传参的本质
4、函数指针变量
4.1 函数指针变量的创建
4.2 函数指针变量的使用
4.3 两端有趣的代码
4.3.1 typedef关键字
5、函数指针数组
6、转移表
7、回调函数
8、什么是qsort函数
9、qsort的函数声明和头文件包含
10、qsort函数的调用
11、qsort函数的模拟实现
11.1 冒泡排序
11.2 模拟实现
第九章:字符函数和字符串函数
一、字符函数
1、字符分类函数
代码练习:将字符串中的小写字母转大写,其他字符不变
2、字符转换函数
二、字符串函数
3、strlen的使用和模拟实现
4、strcpy的使用和模拟实现
5、strcat的使用和模拟实现
6、strcmp的使用和模拟实现
7、桃园三结义:长度受限制函数strncpy、strncat、strncmp
8、strstr的使用和模拟实现
9、strtok的使用
10、strerror的使用
第十章:内存函数
1、memcpy的使用和模拟实现
2、memmove的使用和模拟实现
3、memset的使用和模拟实现
4、memcmp的使用和模拟实现
第十一章:数据在内存中存储
1、整数在内存中的存储
2、大小端字节序和字节序判断
2.1 什么是大小端?
2.2 为什么有大小端?
2.3 练习
3、浮点数在内存中的存储
3.1 练习
3.2 浮点数的存储
3.2.1 浮点数存储过程
3.2.2 浮点数取出过程
第十二章:自定义类型(结构体)
1、结构体类型的声明
1.1 结构的创建
1.1.1 结构的声明
1.1.2 结构体类型的变量
1.1.3 结构的初始化
1.2 结构的特殊声明
1.3 结构的自引用
1.3.1 typedef类型重命名
2、结构体内存对齐
2.1 对齐规则
例1:
例2:
2.2 为什么存在内存对齐
2.3 修改默认对齐数
3、结构体传参
4、位段
4.1 什么是位段
4.2 位段的内存分配
4.3 位段的跨平台问题
4.5 位段使用的注意事项
第十三章:自定义类型(联合union 、枚举enum)
1、联合体
1.1 联合体类型的声明
1.2 联合体的特点
1.3 相同成员的结构体和联合体对比
1.4 联合体大小的计算
1.5 联合体的应用场景
联合体练习:
2、枚举类型
2.1 枚举类型的声明
2.2 枚举类型的优点
2.3 枚举类型的使用
第十四章:动态内存管理
1、为什么要有动态内存分配
2、malloc和free
2.1 malloc
2.2 free
3、calloc和realloc
3.1 calloc
3.2 realloc
4、常见的动态内存错误
4.1 对NULL指针的解引用操作
4.2 对动态开辟的空间越界访问
4.3 对非动态开辟内存使用free释放
4.4 使用free释放一块动态开辟内存的一部分
4.5 对同一块动态内存多次释放
4.6 动态开辟内存忘记释放(内存泄漏)
5、柔性数组
5.1 柔性数组的特点:
5.2 柔性数组的使用
第十五章:文件操作
1、为什么使用文件?
2、什么是文件?
2.1 程序文件
2.2 数据文件
2.3 文件名
3、二进制文件和文本文件
4、文件的打开和关闭
4.1 流和标准流
4.1.1 流
4.1.2 标准流
4.2 文件指针
4.3 文件的打开和关闭
4.4 文件指针的概念
5、文件的顺序读写
5.1 顺序读写函数介绍
5.1.1 fputc的使用
5.1.2 fgetc的使用
5.1.3 fputs的使用
5.1.4 fgets的使用
5.1.5 fprintf的使用
5.1.6 fscanf的使用
5.1.7 fwrite的使用
5.1.8 fread的使用
5.2 对比一组函数:
6、文件的随机读写
6.1 fseek
6.2 ftell
6.3 rewind
7、文件读取结束的判定
7.1 被错误使用的feof
7.2 ferror
8、文件缓冲区
第十六章:编译和链接
1、翻译环境和运行环境
2、翻译环境
2.1 预处理(预编译)
2.2 编译
2.2.1 词法分析
2.2.2 语法分析
2.2.3 语义分析
2.3 汇编
2.4 链接
3、运行环境
第十七章:预处理
1、预定义符号
2、#define 定义常量
3、#define定义宏
4、带有副作用的宏参数
5、宏的替换规则
6、宏和函数的对比
7、#和##
7.1 #运算符
7.2 ## 运算符
8、命名约定
9、#undef
10、命令行定义
11、条件编译
11.1 条件编译
11.2 多分支条件编译
11.3 判断是否被定义
12、头文件的包含
12.1 头文件被包含方式
12.1.1 本地文件包含
12.1.2 库文件包含
12.2 嵌套文件包含
13、其他预处理指令
14、offsetof模拟实现
第一章:初识C语言
1、C语言是什么?
人与人交流用的是自然语言,例如:汉语、英语、日语。
那人与计算机之间该怎么交流呢?使用计算机语言。
计算机语言有上千种,例如:c / c++ / Java / Python / Go 等等... 其中就包括我们熟知的C语言。
计算机语言和自然语言一样拥有多种不同的语言,我们可以使用各种语言与其他人交流,计算机语言也一样。
所以C语言是众多计算机语言中的其中之一种语言。
2、第一个C语言程序
#include //头文件
int main()
{
printf("hello world\n");
return 0;
}
在VS2022上运行代码的快捷键:ctrl+F5 (直接将代码编译和链接生成可执行程序并把结果输出在屏幕上,简称为运行)
3、main函数
每个C语言程序不管有多少行代码,都是从main函数开始执行的,main函数是程序的入口,所以main被称为:主函数,main前面的int表示main函数执行结束时需要返回一个整形类型的值。所以main函数最后写return 0; 前后呼应。
- main函数是程序的入口
- main函数有且只有一个
- 一个项目里不管有多少.c文件,但是main函数只能有一个(main函数是程序的入口)
4、库函数
库函数就是标准库的函数,由编译器厂商实现。
4.1 头文件详解
printf是个库函数,库函数需要包含相应的头文件
#include
头文件解析:
头文件的#include是预处理,是用来包含头文件的。这个""括号里的的stdio.h是头文件的文件名,.h后缀代表该文件是头文件,stdio是英文 (standard(标准) input(输入) output(输出)) 的缩写,全名“standard input output(标准输入输出)”,所以每个被调用的标准输入输出函数都会通过该头文件里对应的函数声明到标准库中找到函数的定义并使用。#include里的““表示是标准库的头文件。#include“stdio.h”表示本地头文件,就是个人创建的头文件。如果调用一个函数会先到本地路径去找头文件,如果未找到,便会自己到标准库里去找。
标准库头文件不止#include这一个,还有多个类型的库函数需要包含的头文件,比如math.h,该头文件里定义的是所有关于math(数学)函数的声明。关于输入输出的函数会声明在一个头文件中,关于数学的函数会声明在另一个头文件中。所以想调用一个库函数,必须包含对应的头文件,才能在对应函数的头文件找到该函数的声明并调用。
4.2 什么是库函数?库函数是怎么来的呢?
库函数是由C语言标准规定的一些函数,是由不同的编译器厂商提供的库函数。比如printf就是库函数。C语言规定一些函数的标准,例如需要什么函数,函数的功能是什么。所以C语言本身是不提供函数只提供函数的实现标准的,我们所使用的的那些库函数都是由C语言的编译器厂商根据C语言所提供的标准实现的。
比如我们熟知的VS,它的库函数是由微软提供的。Xcode是由苹果提供的。他们就是根据C语言规定的函数标准去实现这些函数放在编译器中供用户使用。
这些函数比较多,所以这些函数会集成在一起,被称为标准库,这些函数就是库函数(这些函数在其他编译器不一定支持)。
虽然每个编译器的厂商他们的函数实现的功能和使用方式一模一样,但是函数的实现细节可能略有差异。
5、关键字介绍
关键字是C语言中一批保留的名字的符号,比如:int、if、return,这些符号被称为保留字或者关键字。
- 关键字都有特殊的意义,是保留给C语言使用的。
- 程序员自己创建标识符(定义变量名时和#define定义名称时)的时候是不能和关键字重复的。
- 关键字也是不能自己创建的
例如:
#define goto 200 //#define定义时使用关键字作为名称 int main() { int const = 0; //变量名是关键字 return 0; } //这些都是不被允许的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
以上32个关键字是使用次数较多的常用关键字
注:在C99标准中加入里inline、restrict、_Boot、_Comploex、_Imaginary等关键字。
6、字符和ASCII编码
键盘中可以敲出各种符号,例如:' a '、' A '、' # '、' @ '、' 4 ' 等这些被称为字符。每个字符都有一个ASCIi编码。那为什么要有ASCII编码这个概念?
大家都知道字符是以二进制的形式存入内存中,我自己给每个字符创建了一个编码(每一个字符都设计一个二进制序列,这个叫做编码,例如:0001-' a '、0002-' b ') 我可以通过我设计的编码来使用字符,但是有一个缺陷就是这个编码对应的字符只有我自己知道,别人也有一套自己给字符设计的编码,在于别人通信时造成了信息不对等,为了解决这个问题,后来美国国际标准学会(ANSI) 出台了一个标准ASCII编码,C语言中的字符就遵循了ASCII编码的方式。
ASCII里的字符都是以0--127的十进制的数字表示,也叫编号。以编号的二进制存入内存中叫做编码,共128个字符。
ASCII码表:
如果细心观察就会发现ASCII码表里的字符都是英文字符,这是因为ASCII码表是美国人发明的,所以都是英文字符。所以当C语言适用于亚洲和欧洲时,发现一些语言符号不能简单的用ASCII编码的0-127来表示,所以后来人们又在ASCII码表的基础上又扩展了128个字符,ASCII码表后面还有一个扩展表,扩展表中规定了亚洲地区的字号编码128--255,共有256个字符。
但是C语言又在亚洲各个地区开放导致256个字符编码都不够用,后来又衍生出了各种各样的编码,比如国内汉语用的比较多的编码是GB2312
我们不需要记住所有的ASCII码表中的数字,使用时差看就可以,不过我们最好能掌握几组特殊数据。
- 字符A-Z的ASCII码值范围65-90
- 字符a-z的ASCII码值范围97-122
- 对应的大小写字母(a和A)的ASCII码值的差值是32
- 数字字符0-9的ASCII码值范围48-57
- 换行 '\n' 的ASCII码值是:10
打印ASCII里所有字符的代码
#include int main() { int i = 0; for(i=32;i],??(--转换-->[- \':用于表示字符常量'
- \":用于表示字符串内部的双引号
- \\:用于表示反斜杠,防止字符被解释为转义字符
- \a:警报(蜂鸣)
- \b:退格键,光标回退一个字符,但不删除字符
- \f:换页符,光标移到下一页,在现代系统上,这已经反应不出来了,行为改成类似于\v
- \n:换行符
- \r:回车符,光标移到同一行的开头
- \t:制表符,光标移到下一个水平制表位,通常是下一个8的倍数
- \v:垂直分隔符,光标移到下一个垂直制表位,通常是下一行的同一列。
下面两种转义可以理解为:字符的8进制或16进制的形式
- \ddd:ddd表示1—3个八进制的数字。 如:\130 表示字符X
- \xdd:dd表示2个十六禁止的数字。 如:\x30 表示字符0
strlen库函数介绍
先给大家介绍一下strlen库函数,strlen全名string length(字符串长度),顾名思义这个库函数就是求字符串长度的库函数,需要包含对应的头文件#include ,比如我有一串字符串"abcdef",让strlen求一下这个字符串长度。
#include #include int main() { int len = strlen("abcdef"); //创建整型变量len,来接收strlen返回的字符长度 printf("%d\n",len); //打印结果为6 return 0; }strlen函数的原理就是遇到一个字符就+1,直到遇到 '\0'结束标志为止,所以只会计入' \0 '之前的字符个数。
strlen库函数的返回类型是size_t(无符号整型),因为strlen不可能接收到一个长度为负数的字符串,所以返回类型是绝对的,也就是size_t。严格意义上来讲用int(整型)类型的变量len来接收size_t类型的值是不准确的,我也可以用size_t类型的变量来接收strlen的返回值,例如:size_t len = strlen("abcdef"); 但是也可以用int类型变量来接收strlen的返回值。
下面给一段代码,来算字符长度:
#include #include int main() { int len = strlen("c:\test\class111\123.c"); printf("%d\n",len); //结果是多少 return 0; }把一个文件路径当做字符串放进去,求一下这个文件路径的长度,这个字符串长度结果为17。为什么是17呢?里面不是共有22字符吗?这是因为\与字符发生了转义,变为了转义字符。比如上面' \ '与' t '发生了转义,变为了' \t ',它此时就变为了转义字符,被算作一个字符。里面的' \ ' 和' c '结合后虽然不是转义字符,但是会被strlen识别为转义字符,所以会变成' \c ',不是转义字符所以\去除,最后就是' c '(所以如果想让' \ '作为一个普通存入字符串中,就需要' \\ ',注意,这不是注释,而是转义字符,作用就是让' \ '变为一个普通的字符)。后面的'\123'是转义字符,后面是三个八进制位数,它会转换成ASCII码表中对应这个八进制的字符,首先将八进制123转换为十进制数就是83,83作为ASCII码值对应的字符就是' S ',所以'\123'会被替换成' S '。最后的结果也就是17。
以上就是关于strlen的介绍和转义字符功能讲解。
9、语句和语句分类
C语言代码是一条一条的语句构成的,C语言的语句可以分为五类:
- 空语句
- 复合语句
- 表达式语句
- 函数调用语句
- 控制语句
12.1 空语句
空语句是最简单的,一个分号就是一条语句,是空语句
#include int main() { ;//空语句 return 0; }空语句一般出现的地方是:这里需要一条语句,但是这个语句不需要做任何事,都可以写一个空语句。
12.2 表达式语句
表达式语句就是在表达式后面加个分号。如下所示:
#include int main() { int a = 20; int b = 0; b = a + 5; //表达式语句 return 0; }12.3 函数调用语句
函数调用的时候,也会加上分号,就是函数调用语句。
#include int Add(int x,int y) //自定义函数 { return x + y; } int main() { printf("haha\n");//库函数调用也属于函数调用语句 int ret = Add(10,20);//函数调用语句 return 0; }12.4 复合语句
一个括号内有多条语句就是复合语句。
#include int print(int arr[],int sz) //自定义函数的大括号中的代码也构成符合语句 { int i = 0; for(i=0;i 16 bit - int大小为4 byte -> 32 bit
- long大小为4 byte -> 32 bit
- long long大小为8 byte -> 64 bit
- float大小为4 byte -> 32 bit
- double大小为8 byte -> 64 bit
因为每个类型在内存所占字节大小不同,所以取值范围也会各不相同。每个字节是8个bit位,每个bit位代表一个二进制位,一个二进制位有两种状态,一种是1,一种是0。二进制位越多代表的数值范围就越广。
4、变量
4.1 变量的创建
了解清楚了类型,我们使用类型做什么呢?类型是用来创建变量的。
什么是变量?C语言中经常变化的值称为变量,不变的值称为常量。
比如:身高、体重这些数值就是变量,圆周率这种就是常量。
变量创建语法形式是这样的:
data_type name; | | //数据类型 //变量名比如:
char str; int num; double dub; //这种前面是类型后面是变量名就称为变量
如果在创建变量的同时给变量一个初识值,就称为变量初始化,例如:
char str = 'a'; //初始化 int num = 100; double dub = 3.14;
先创建一个变量,后面再给变量一个值的过程叫做个变量赋值(变量名可以简称为变量)。
int a; a = 100;//赋值
如果double类型的值初始化给float类型变量是会报错,这个值可能发生截断。如果想让这个值成功初始化给这个float类型的变量就在这个值的后面加上一个f,例如:
float scort = 98.51; //会报错,数据可能会发生截断 float scort = 98.51f; //这样才能将double类型的值存入float类型变量中
4.2 变量的分类
- 全局变量:在大括号外部创建的变量就是全局变量
全局变量的使用范围更广,整个工程中想使用,都是有办法的,所以安全性不高
- 局部变量:在大括号内部创建的变量就是局部变量
局部变量的使用范围是比较局限,只能在自己所在局部范围内使用
#incude int a = 10; //全局变量 int main() { int a = 20; //局部变量 printf("%d\n",a); //打印结果是多少 return 0; }看上面的代码,打印的结果是10还是20?答案是:20。为什么?
上面两个变量名相同的变量a,除了名称相同,所开辟空间和地址都是各不相同的。
注:全局变量和局部变量名称如果一样的话局部变量优先,但是不建议起一样的变量名,以免造成混乱。
int main() { int a = 0; { int b = 10; printf("%d\n",b); //打印:10 } printf("%d\n",b);//会报错 return 0; }因为变量b是在大括号内部创建的,所以局部范围暂时可以使用变量b,但是出了大括号变量b的空间就销毁(返还给操作系统)了,再去调用就会报错,所以局部变量只能在局部范围使用。
全局变量和局部变量在内存中存储在哪里呢?
一般我们在学习C/C++语言的时候我们会关注内存中的三个区域:栈区、堆区、静态区
- 局部变量是放在内存的栈区
- 全局变量是放在内存的静态区
- 堆区是用来动态内存管理的(malloc、calloc、realloc后期会介绍)
5、算术操作符:+、-、*、/、%
在写代码的时候,一定会涉及到计算。
C语言为了方便运算,提供了一系列操作符,其中一组操作符叫:算术操作符。分别是:+、-、*、/、%,并且都是双目操作符。
双目操作符就是两端拥有两个操作数,可以进行运算就叫做双目操作符。
注:操作符也被叫做:运算符,是不同的翻译,意思是一样的。
5.1 + 和 -
+和-用来完成加法和减法的运算
#include int main() { int x = 10+20; int y = 20-10; printf("%d\n",x); printf("%d\n",y); return 0; }5.2 * 和 /
/操作符两端其中至少要有一个操作数是小数,结果也就为小数。比如printf("%lf",3/2);结果为1,如果想让它的打印出小数,就要printf("%lf\n",3/2.0);一端操作数为小数结果就可以是小数。
#include int main() { int a = 2*10; double b = 7/2.0; printf("%d\n",a); 结果20 printf("%d\n",7/2);结果为3 printf("%lf\n",b); 结果3.5 return 0; }5.3 %操作符
%符是取模的意思,取的是两个数相除后的余数,比如printf("%d\n",30%7);因为30整除7商4余2,取余数,结果就是2。也就是两个数的余数。需要注意的是,%操作符两端的操作数必须都是整数
#include int main() { int a = 30%7; printf("%d\n",a); //结果为2 return 0; }负数取模的规则是,结果的正负号有第一个运算数的正负号决定。
#include int main() { printf("%d\n",11%-5); //1 printf("%d\n",-11%-5); //-1 printf("%d\n",-11%5); //-1 return 0; }上面示例中,第一个运算数(11或-11)决定结果是正数还是负数
6、赋值操作符:=和复合赋值
在变量创建时给变量了一个初始值叫做初始化,而赋值是在创建好变量后,再给变量了一个值,这叫赋值。
int a = 100;//初始化 a = 200;//赋值
赋值操作符=是随时可以给变量赋值的操作符。
6.1 连续赋值
赋值操作符也可以连续赋值:
int a = 10; int b = 20; int c = 30; c = a = b+20;//连续赋值,从右向左依次赋值
C语言虽然支持连续赋值,但是写出的代码不好观察,建议是还是拆开来写,这样方便代码观察细节,例如:
#include int main() { int a = 10; int b = 20; int c = 30; a = b+20; c = a; return 0; }这样写在代码调试时方便我们观察。
6.3 复合赋值
一般变量可以通过自加或自减来进行运算,例如:
int main() { int a = 10; a = a+10; //自加 a = a-10; //自减 return 0; }但是我们还可以用更简单方便的方式让变量自加或自减,就是复合赋值:
int main() { int a = 10; a += 10; //复合赋值自加 a -= 10; //复合赋值自减 return 0; }这样来看你们觉得a=a+10;和a+=10哪个更方便?当然复合操作符不止+=、-=这两种。例如:
+= -= *= /= %= >>= = b a 12返回1,12>20返回假。
常在if语句或while循环判断真假的语句中使用。
比如:
a如果小于b返回1,判断为真,就执行if语句。
注:“==”是相等运算符,在生活中,我们使用=来判断这个数等不等于另一个数,但是在C语言中=号为赋值操作符,为了能够分清楚赋值和判断,C语言规定==为判断。
关于关系操作符的使用需要避免的那些错误:
注意,在使用相等运算符==时,建议将常量例如整数3放到前面,将变量放到后面进行判断,3==a;为什么?如果将变量放到a放到前面,常量3放到后面进行比较并不会有什么问题,a==3;但是如果哪一次不小心将相等运算符写成赋值运算符时a=3进行判断,a被赋值成了3,3为真就进入语句,a不仅被赋值为3,关键是这样编译器也不会报错,认为你这就是正常的给变量赋值再进行判断
如果想要解决这个问题就将常量写在前面进行判断,就算不小心将==写成赋值=,也会报错,因为编译器会认为你是在给常量赋值,常量3就是一个整数,怎么能够给整数赋值呢?这不荒唐嘛。
另一个需要避免的错误是:多个关系操作符不宜连用
a
因为在判断关系表达式时是从左到右进行判断的,比如先判断a是否小于b,为真返回1,为假返回0,最后和c进行判断的要么就是1要么就是0。
(a
pimg height="243" src="https://img-blog.csdnimg.cn/direct/4d90ff0967364c229a95f36d772945d5.png" width="394" //p p比如我要随机找一个元素5,那就可以通过行和列来锁定,通过下标先找到第二个数组元素arr[1],此时二维数组arr拿到了第二个元素,arr[1]就是一个一维数组,再通过当前的一维数组找到5,就使用列的下标再访问一次数组元素也就是arr[1][3],arr[1][3]此时就是元素5。/p p注:每一行列的下标,都是从0开始的/p h5 id="8.2%20%E4%BA%8C%E7%BB%B4%E6%95%B0%E7%BB%84%E7%9A%84%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA"8.2 二维数组的输入输出/h5 pre class="brush:python;toolbar:false"#include stdio.h int main() { int arr[3][5] = { { 1, 2, 3, 4, 5 }, { 2, 3, 4, 5, 6 }, { 3, 4, 5, 6, 7 } }; int i, j; for (i = 0; i print(1) +printf(2) ==>printf(1)比如上面这段代码,是先判断a{1,2},{3,4},{5,6}};//三行,第一行:1,2,0,0,0 第二行:3,4,0,0,0 第三行:5,6,0,0,0{1,2,3,4,5},{2,3,4,5,6},{3,4,5,6,7}};
函数递归实现:
#include void print(int n) { if (n > 9) print(n / 10); printf("%d ", n % 10); } int main() { int n = 0; scanf("%d", &n); print(n); return 0; }运行结果:
3.2.2 画图推演
4、递归与迭代
递归是一种很好的编程技巧,但是很多技巧一样,也可能是被误用的,就像举例1一样,看到推导公式,很容易就被写成递归的形式
int Fact(int n) { if(n >-1 //error4、位操作符:&、|、^、~
位操作符有:
- & 按位与
- | 按位或
- ^ 按位异或
- 注:它们的操作数必须是整数
看下面代码:
#include int main() { int a = 5; int b = -6; int c = a & b; printf("%d\n", c); return 0; }运行结果:
a&b按位与后的结果给变量c,打印变量c的结果为什么是0,来看一下&按位与 的规则。
假设给两个整数变量,a为5,b为-6
它们的二进制表示分别是:
5的二进制:00000000 00000000 00000000 00000101
-6的二进制:
原码:10000000 00000000 00000000 00000110
反码:11111111 11111111 11111111 11111001
补码:11111111 11111111 11111111 11111010
由于负数在内存中存储的是补码,所以任何有关负数的操作都是补码来操作。
&按位与规则:两个相同位数整数的二进制,对应的每一位都有对比,如果两个整数在二进制中当前数位为有一个是0,&结果就是0,如果两个都是1,&结果就为1。
5:00000000 00000000 00000000 00000101
-6:11111111 11111111 11111111 11111010
&按位与后
结果为0:00000000 00000000 00000000 00000000
仔细观察就会发现这两个整数二进制位正好都不相同,所以&按位与后全部为0。
再把上面的代码拿下来,改成两个数|按位或那结果会是什么:
#include int main() { int a = 5; int b = -6; int c = a | b; printf("%d\n", c); return 0; }运行结果:
如果说&按位与是一个为0就是0,两个位都是1才为1的话,那|按位或就恰恰和它相反,|按位或是一个为1就是1,两个都是0才为0。
|按位或规则:两个相同位数整数的二进制,对应的每一位都要对比,如果两个整数在二进制中当前数位为有一个是1,|结果就是1,如果两个都是0,|结果才为0。
5:00000000 00000000 00000000 00000101
-6:11111111 11111111 11111111 11111010
|按位或后
结果为-1: 11111111 11111111 11111111 11111111
正好每一位都不相同并且每一位都有一个位数1
^按位异或则比较特殊,就是不管二进制位上相同的是位数0还是位数1。两个二进制位比较相同为0,相异为1。
还是上面的代码:
#include int main() { int a = 5; int b = -6; int c = a ^ b; printf("%d\n", c); return 0; }运行结果:
为什么还是-1?看下面讲解:
^按位异或规则:两个相同位数整数的二进制,对应的每一位都要对比,如果两个整数在二进制中的位数对比相同为0,相异为1。
5:00000000 00000000 00000000 00000101
-6:11111111 11111111 11111111 11111010
^按位异或后
结果为-1: 11111111 11111111 11111111 11111111
因为5和-6的二进制正好每一位都不相同
一道变态的面试题:
不创建第3个变量交换第2个变量的值:
方法1:
#include int main() { int a = 10; int b = 20; a = a + b;//a == 30 b = a - b;//b = 30-20 = 10 a = a - b;//a = 30-10 = 20 printf("a=%d b=%d\n",a,b); return 0; }运行结果:
方法1有个明显的缺陷,就是当两个数字特别大,大到相加以后变量存不下的时候,就会发生错误答案。
方法2:
#include int main() { int a = 10; int b = 20; a = a ^ b;//先取它们之间^后的操作数 b = a ^ b;//此时a为操作数,操作数^20拿到10的值 a = a ^ b;//此时a还是操作数,b存放10,操作数^10的值后拿到20的值 printf("a=%d b=%d\n",a,b); return 0; }运行结果:
但是这种异或操作是有局限性的:
- 只作用于整数交换
- 代码可读性差
- 代码执行的效率也是低于创建第三个变量的交换方法的
练习:求出一个整数的二进制里有多少位是1
例如:输入15 输出:4
方法1:整数取模法
int main() { int n = 0; scanf("%d", &n); int count = 0;//用来计数 while (n) { if (n % 2 == 1)//判断,如果当前整数取模2等于1,那整数此时的最后一位就是1 { count++; } n /= 2;//除去整数二进制的最后一位 } printf("count = %d\n", n); return 0; }输出结果:
方法2:位移按位与
int main() { int n = 0; scanf("%d", &n); int count = 0; int i = 0; for (i = 0; i > i) & 1)//每次让整数n向右移动i位并&1,计算当前移动的位数是不是1 { count++; } } printf("count=%d\n", count); return 0; }代码解析:方法2就是利用&按位与的特性,如果一个位为0 &后就为0,两个都是1才为1,所以让整数n的每一位与1的二进制最后1位&按位与,如果n最后一位是1就为1,count就加1,如果n是0&按位与后就是0,count不变。
方法3:奇妙的n&n-1法
int main() { int n = 0; scanf("%d", &n); int count = 0; while (n) { n = n & (n - 1); count++; } printf("count=%d\n", count); return 0; }代码解析:为什么不断地让给n赋值n&(n-1)最后能够计算出二进制里有多少个1,举个例子:
仔细观察不难发现没当n&n-1时拿到的就是n的二进制最后面的1去除的二进制。也就是说每次n&n-1也就是每次让n的二进制位去除一个1,n每次-1就是将n的最后面1的那一位置为0,后面的二进制位就全置为1。从而导致n与n-1二进制里n最后面的1的那一位向后开始基本上都不能与n-1相同,所以按位与后就将那个位置到后面的二进制位全部置为0,也就是n的二进制去除一个1。
有了上面的方法,那我们可不可以这样呢?
练习3:判断当前整数是不是2^n
int main() { int n = 0; while (scanf("%d", &n) == 1) { if (n & (n - 1) == 0) { printf("yes\n"); } else { printf("no\n"); } } return 0; }输出结果:
如果好好想一想,二进制的每一位都是2^n,所以2^n整数在二进制中只有1位,不可能再有第二位,所以我们就可以用n&(n-1)公式将我输入的数的二进制判断一次,如果一次判断为就0,那绝对就是2^n,因为只有一位1,输出yes。但是如果判断结果不是0就输出no。
按位取反操作符 ~
~是按位取反操作符,是将一个整数二进制中的每一位都取反,如果是1就取反为0,如果是0就取反为1。
取反操作符有什么用处呢?
练习:改变整数二进制中的位数
int main() { int n = 15; n = n | (1 1MB --x1024--> 1GB --x1024-->1TB --x1024--> 1PB1个bit位可以存放1个二进制位(1 / 0),1个byte(字节)是8个bit位也就是说可以存储8个二进制位。这8个二进制位至少可以表示一个char类型的数据,一个内存单元正好可以存储一个char类型的数据。
也可以将每个内存单元简单理解为一个宿舍,有8个学生,每个学生就是1个bit位。
总结:数据在内存中是以二进制的形式存储,方便CPU拿取内存中的二进制指令进行运算,因为计算机只能识别二进制指令。
生活中我们把门牌号叫地址,在计算机中我们把内存单元编号也称为地址。C语言中给地址起了新的名字叫:指针。所以我们可以理解为:内存单元编号==地址==指针总结:
- 在计算机中为了方便管理内存,内存会被划分为以字节为单位的内存空间,也就是说一个内存单元的大小是一个字节
- 为了方便找到这个内存单元,会给每个内存单元一个编号,就像生活中每个房间的门牌号
- 有了内存单元的编号,就可以快速的找到内存单元
1.2 如何理解编址
CPU访问内存中某个字节空间,必须知道这个字节空间在内存中的什么位置,而因为内存中的字节很多,所以需要给内存进行编址(就如同宿舍很多,需要给宿舍编号一样)
计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。
钢琴、吉他上面没有写 “都瑞咪发嗦啦” 这样的信息,但是演奏者照样能够准确的找到每个琴弦上音调的位置,这是为何?因为制造商已经在乐器硬件层面设计好了,并且所有的演奏者都知道。本质是一种约定出来的共识!
总结:简单理解就是每个内存单元都有一个地址编号,但是内存单元编号本身并不是也开辟一块内存空间存储起来的,内存单元编号它本身就是某块内存空间的地址,是绑定的,约定好的,所以并不需要额外的内存单元来存储另一个单元的地址信息。
注:内存和CPU之间有三种联系方式,分别是:地址总线、数据总线和控制总线。
首先,必须理解,计算机内是有很多硬件单元,而硬件单元是要互相协同工作的。所谓的协同,至少相互之间要能够进行数据传递。
但是硬件和硬件之间是相互独立的,那么如何通信呢?答案很简单,用 “线” 连起来。
而CPU和内存之间也是有大量的数据交互的,所以,两者必须用线连接起来,我们现在需要了解一种线,叫地址总线。
我们简单理解,32位机器有32根地址总线,每根线只有两种状态,表示0,1【电脉冲有无】,那么一根线就能表示2种含义,2根线能表示4种含义,依次类推。32根地址总线就能表示2^32种含义,每一种含义都代表一个地址。
地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据通过数据总线传入CPU内寄存器
总结:CPU通过地址总线传输的一个地址信息给内存,在内存上,找到该地址对应的数据。再通过数据总线将内存单元里的数据传输给CPU中的寄存器,该寄存器保存数据,所以相反,CPU也可以通过地址总线传输地址让计算机在内存中找到这个地址并将数据通过数据总线写入这个地址。
控制总线:就是控制CPU是从内存中读取数据还是将数据写入内存。
以上就是CPU和内存之间怎样联系的具体步骤。
通过以上知识点,我们需要知道每个地址也是有单位的,虽然内存单元地址刚开始不用内存单元来存储,但如果我们想要获取这个地址,通过这个地址访问对应的内存单元时就需要创建指针变量(后面会讲)。这个指针变量就是在内存中开辟了一块4个字节的空间来存储这个地址。所以可以得知内存单元的编号(地址)是4个字节的。但是也不一定是固定4个字节的,如果是64位机器地址大小就是8字节,但是我们平常用的都是32位机器,所以地址是4字节。
总结:每个地址单位是4个字节,每个地址所关联的内存单元是1个字节。
2、指针变量和地址
2.1 取地址操作符(&)
理解了内存和地址的关系,我们再回到C语言,在C语言中创建变量其实就是向内存申请空间,比如:
看上图,变量a是int类型需要向内存申请4个字节空间的地址来存储数据10,数据10的被拆分为4个字节存储到内存中,变量a的地址是从4个字节地址中选择较小的那个字节的地址来表示变量a的地址,拿数据时CPU可以通过这个地址向后再访问3个字节的内存单元就可以取出数据10。
比如,在上述代码就是创建了整型变量a,内存中申请4个字节,用于存放整数10,其中每个字节都有地址,上图4个字节的地址从低到高分别是
1 0x0093F80C 2 0x0093F80D 3 0x0093F80E 4 0x0093F80F
表示变量a的地址就是最低的地址:0x0093F80C
看到这里可能有人疑问了,就是内存中每个内存单元存储的不是二进制形式的数据吗?为什么上图的内存中存储的是16进制。在这里声明一下,在内存中数据是以2进制的形式存储的,但是显示时是16进制显示的,方便观察。
总结:当一个变量需要开辟的内存单元多于一个字节时,就取这些开辟好的内存单元的地址中的低地址来表示变量的地址,也就是属于这个变量内存空间的最低地址。
2.2 指针变量和解引用操作符(*)
注:指针变量才是学习指针最重要的核心。
通过上面的代码中打印&a取出的地址可以发现地址也是一个值,如果地址是一个值那是不是就可以创建一个变量来存储这个地址呢?答案是可以的,我们可以通过指针变量来存储这个地址,指针变量就可以通过这个地址找到这个地址的内存单元并读取或修改这块空间里的值,比如:
通过上图代码可以得知创建一个指针变量pa来接收&a取出的地址,pa和&a是等价的,这就是指针变量。认真的来讲:pa的类型是int*,int说明这个指针变量所指向对象是int类型的,*说明这个pa是指针变量。
这个就是最基础的指针变量,整型指针变量,可以简称为整型指针。pa因为是存放指针的变量,所以叫做指针变量。
重点:我们看到的地址都是int类型的值表示的,当你对一个变量a取地址时编译器会根据变量a的类型来决定&a地址的指针类型。在指针空间中就是将这个int*类型的地址拆分开放在每个字节的内存空间。但是当你拥有一个int类型的地址时,比如:0x0012ff40时,你想直接访问这个地址指向的内存空间。就可以将它强制类型转换为指针类型。就可以根据指针的类型访问多大的空间。
假如此时我有一个变量:
1 char ch = 'w'; 2 //接收&ch的指针变量是什么?
接收&ch的指针变量是什么,看上面的指针变量有定义:int说明这个指针变量所指向对象是int类型的,*说明这个pa是指针变量。*可以证明这个变量是指针变量,所以*必不可少,那就剩类型需要更改了,这个&ch地址所指向的对象是char类型的,所以对应的指针变量就是:
1 char ch = 'w'; 2 char* pc = &ch;//指针变量
这个是指向字符的指针变量,简称字符指针。
看到这里是不是就明白当面对不同类型的变量时,该用什么指针类型变量来接收这个地址了吧。
比如遇到double类型的变量时,就用double*类型的指针来接收double类型变量的地址。
例如:
1 double d; 2 double* pd = &d;
总结:指针变量就是用来存放地址的,存放在指针变量中的值,都会被当成地址使用。
但是用指针变量拿到地址有什么用呢?比如我在一个宿舍,我将宿舍的门牌号告诉好兄弟,我的好兄弟可以通过这个门牌号找到我给我送点东西,或是来找我玩。相同的,指针也是这个道理,如果想改变这个空间的值或访问这个空间的值,就给指针变量这个空间的地址,指针就可以通过这个地址找到这个空间并修改这个空间所存储的值。
这里的*是解引用操作符或者叫间接访问操作符,*pa可以直接通过pa中的地址找到地址指向的变量a的内存空间,给*pa赋值20变量等价于变量a赋值了20,所以说*pa等价于变量a。*pa是直接通过地址找到的变量a的内存空间。
1 *pa == a; 2 (*pa = 20) == (a = 20);
但是可能就有人会想,解引用*pa改变a那不是多此一举吗?其实指针访问变量空间的应用场景并不是这里,而是函数传参,想一想,函数传参形参是实参的一份临时拷贝,改变形参不会影响实参。如果我想写一个函数,交换两个变量的值,怎么办?答案是传地址,通过地址可以直接访问到变量的空间并修改:
#include void swap(int* x, int* y) { int s = *x; *x = *y; *y = s; } int main() { int x = 0; int y = 0; scanf("%d%d", &x, &y); printf("交换前:x=%d y=%d\n", x, y); swap(&x, &y); printf("交换后:x=%d y=%d\n", x, y); return 0; }可以看到确实通过函数交换了两个变量的值,函数调用时实参传递地址,形参由指针接收这个地址,指针形参通过这个地址可以访问到变量的空间,相当于让实参和形参有了连接,而不是拷贝。这就是传址调用。
2.3 指针变量的大小
指针变量并不会因为类型而决定它的大小,比如int*类型的指针变量是4个字节,那char*类型的指针变量是1个字节吗?double* 类型的指针变量是8个字节吗?当然不是,指针变量说白了就是开辟一块空间存储地址,地址固定大小就是4/8个字节,是根据环境来指定的,地址一般是由32个或64个0/1组成的二进制序列组成的地址。指针变量就是开辟地址大小的空间来存放地址,所以指针变量要么是4个字节,要么就是8个字节。32位机器(x64)就是4字节,64位机器(x86)就是8字节:
注:一个指针变量存放的地址就是CPU通过地址线将某变量的地址存放在指针变量所在内存空间不同环境地址总线数量不同,所以地址大小也就不同。
32位机器(x86)环境运行:
64位机器(x64)环境运行:
32位机器(32位平台)下的地址总线是32根,地址线上传输过来的电信号转换成数字信号后,得到32个0/1组成的二进制序列就是地址(64位机器就是64根地址线,地址线数量不同,表示地址二进制序列的大小也就不同)。
有句俗话就是:不要在门缝里看人,把人看扁了。这句话在当前场景就是不要在门缝里看指针,把指针看扁了。什么意思?就是不要看一个指针变量是int*,大小是4个字节。就以为另一个char*指针变量的大小就是1个字节。不管指针变量的类型一不一样,指针大小就是取决于地址的大小,和类型无关。
总结:
- 32位平台下地址是32个bit位,指针变量大小是4个字节。
- 64位平台下地址是64个bit位,指针变量大小是8个字节。
- 注意指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的。
3、指针变量类型的意义
指针变量的大小是取决于地址在当前平台的大小,而不是取决于指针类型的。那指针类型真的只是简单的表示指针变量所指向的数据是什么什么类型的吗?有没有其他特殊的意义呢?答案是有的。
3.1 指针的解引用
先看下面两段代码在内存中调试的结果:
可以看到指针类型的不同导致了解引用时访问的内存单元大小不同,int*指针变量解引用时访问了4个字节的内存空间并将4个空间存储的值都修改为0。而char*指针变量解引用时只访问了低地址的那一个字节,解引用赋值时也只改动了一个字节。可以得知指针类型决定指针变量解引用时访问几个内存单元。
指针类型的里的类型int、char、double本来是表示指针变量指向的空间存储的什么类型的数据。所以解引用时访问多大内存空间也是类型决定的。类型的大小就决定了解引用访问空间的大小。
指针类型存储的是一个类型的地址,地址始终是指向一个字节的内存单元,如果是int*的指针变量,解引用时的访问权限是4个字节也就是4个内存单元,是会从当前的地址再向后访问几个地址的空间拿到4个内存单元大小的空间。
注意:指针的访问权限还是我们自己给的,如果把一个整型变量的地址给一个char*类型的指针变量,这个指针变量解引用只能访问到一个字节。所以我们在写程序时应该尽量使用对应类型的指针变量来接收该类型的地址。
3.2 指针+-整数
先看下面代码:
#include 1 int main() 2 { 3 int n = 0x11223344; 4 int* p = &n; 5 char* pc = &n; 6 //指针p和p+1的地址 7 printf("p = %p\n", p); 8 printf("p+1 = %p\n", p + 1); 9 //指针pc和pc+1的地址 10 printf("pc = %p\n", pc); 11 printf("pc+1 = %p\n", pc + 1); 12 return 0; 13 }运行结果:
可以看到int*指针类型的p+1后地址+4,也就是跳过了4个内存单元的地址,char*指针pc+1后地址就+1,地址只跳过了1个内存单元的地址。指针类型变量指向的数据类型多大+1或-1跳过的空间大小就有多大。
指针类型除了决定解引用时访问内存单元大小,还可以决定指针变量+1跳过几个字节的空间。比如char*类型的指针变量+1拿到一个字节后的地址。int*类型的指针变量+1拿到跳过4个字节空间的地址。因为不同类型的指针变量需要跳过当前类型指针指向数据所占的空间去到下一个存储数据的地址。
指针类型的设计:
为什么这样设计指针类型?就是根据数据的类型大小,取出存储数据空间的地址用对应的数据类型解引用或+1、-1的操作能够刚好访问到这个大小的空间,或跳过这个数据所占内存的空间,如果当前指针指向的是double类型的数据,所占内存8个字节,那指针+1只能跳过一个字节需要+8次,不是很麻烦吗?为了方便+1能够刚好跳过这个指针指向数据的内存大小来到下一个元素的地址访问下一个元素,就给指针类型设计了指针类型+1或-1跳过内存空间的大小正好是指针指向数据类型的大小,double*类型的指针变量只需要+1就可以跳过double类型大小的8个字节的空间。
结论:
- 指针类型是有意义的。
- 指针类型决定了指针在解引用操作时的访问权限,也就是一次解引用访问几个字节的内存单元空间。
- 比如:char*类型的指针解引用时访问1个字节,int*类型的指针解引用时访问4个字节
- 指针类型决定了指针在+1/-1操作的时候,一次跳过几个字节(指针的步长)
可以发现指针类型决定的解引用正好拿取指针所
指向的数据类型大小,不多拿也不少拿。只要访问到指针指向的那个数据所占内存大小就可以了。+1/-1操作也能刚好跳过类型大小的字节空间的地址。
还需要注意的是,地址的访问权限不一定都是创建指针变量时给的。
比如有一个int类型的变量a,&a的地址本身就是int*类型的,&a+1也是跳过4个字节的,既然你取的是int变量的地址,那地址的类型自然就是int*的类型,不需要再额外定义int*的指针变量去给它int*类型的访问权限。
学到了上面的指针,知道了指针类型的作用,那怎么使用呢?
如果有一个整型数组arr,你想访问它里面的元素,该怎么访问呢?
方法一:数组下标的访问,例如:
int arr[] = {1,2,3,4,5,6,7,8,9,10}; arr[6]、arr[3]、arr[9]方法二:指针访问,例如:
int arr[] = {1,2,3,4,5,6,7,8,9,10}; int* parr = arr;//使用int*指针来接收 *(parr+6)、*(parr+3)、*(parr+9)*(parr+6)等价于arr[6]的,所以指针可以通过指针类型的特性去访问数组中的每个元素,在函数调用时传数组名时形参可以创建一个指针变量来接收数组名。
因为数组名是首元素的地址,本身就是地址,所以可以直接使用指针变量来接收该地址。
这里就需要给大家讲一下数组名本身就是首元素的地址,数组名是地址,所以是不能直接给数组名赋值的,只能改变这个地址所指向的空间的元素。
int arr1[] = {1,2,3,4,5,6,7,8,9,10}; int arr2[] = {1,2,3,4,5}; arr1 = arr2;//错误的,地址不能被赋值 arr1[0] = arr2[0];//正确的,可以通过解引用该地址访问空间并赋值 arr1==&arr1[0];//数组名是等价于首元素地址的4、const修饰指针
4.1 const修饰变量
const是C语言中的一个关键字,也叫保留字。const的作用是将const修饰的变量改为常量属性,下次给这个变量赋值但是因为是常量属性所以不能改,改了就会报错。
给一个代码:
#include int main() { const int n = 10; n = 20; printf("%d\n", n); return 0; }运行后:
确实将变量n改变成了常量属性,无法直接赋值。
但是当你把这个const修饰过的变量的地址给一个指针,通过指针改变它可以发现真的能够改变:
#include int main() { const int n = 10; int* p = &n; *p = 20; printf("n = %d\n", n); return 0; }运行结果:
我已经把n修饰为常量属性了,n不能改了,但是指针还可以改,相当于饶了一圈又将变量改了,指针并不在const的修饰范围。
举个例子:前一年很火的电视剧狂飙,里面的高启强心狠手辣,是个黑恶势力,经常
人,并且不是他亲自动手。比如变量n就是高启强,他想
一个对他不利的人,const就看做公安局。公安局一直盯着高启强,高启强不敢有大动作。所以就告诉老默,想吃鱼了。老默明白了。指针变量p就是老默。高启强不方便搞定这个人,但是老默可以,老默并不在公安局的监视范围,所以可以轻松完成
人如果不想让限制这个变量不想被任何方法修改,怎么办?可以把指针也用const修饰。让老默也受到公安局的监视不就可以了
#include int main() { const int n = 10; const int* p = &n; *p = 20; printf("n = %d\n", n); return 0; }运行后:
将指针也修饰const后,指针也不能修改这个变量了,只能访问,不能修改。
const修饰指针其实有两种修饰方法,一种是const放在*左边,另一种是const放在*右边。
假设有两个变量和一个指针变量:
int n = 10; int m = 20; int* p = &n;
1.const放在*左边:
const int* p = &n; *p = 30;//会报错 p = &m;//不会报错
如果const放在*左边修饰的就是*p,指针指向的内容不能被修改了,但是指针变量本身是可以修改的。
int const *p = &n;等价于 const int* p = &n;
2.const放在*右边:
int* const p = &n; *p = 30;//不会报错 p = &m;//会报错
const放在*右边直接修饰的是变量p,限制着指针变量本身。所以改变指针变量p地址指向是会报错的,但是可以修改指针指向的内容。
如果既不想让指针变量p改变地址指向,也不想让指针变量p改变p所指向的空间里存储的值,就左右各修饰一个const:
const int* const p = &n; *p = 30;//会报错 p = &m;//会报错
5、指针运算
指针的基本运算有三种,分别是:
- 指针+-整数
- 指针-指针
- 指针的关系运算
5.1 指针+-整数
因为数组在内存中是连续存放的,只要知道第一个元素的地址,顺藤摸瓜就能找到后面的所有元素
1 int arr[10] = {1,2,3,4,5,6,7,8,9,10};可以看到数组在内存中是连续存放的,是由低到高依次存储,大家观察一下,每个元素的地址与下一个元素的地址相差4个字节,这是因为数组的每个元素需要4个字节的内存单元来存储元素,所以每个元素的地址相差4个字节。
从这里我们得知了数组在内存中确实是连续存放的,我们是不是可以用指针访问整个数组的所有元素呢?答案是可以的:
#include int main() { int arr[] = { 1,2,3,4,5,6,7,8,9,10 }; int sz = sizeof(arr) / sizeof(arr[0]);//求出数组元素个数 int* p = arr;//等价于int* p = &arr[0]; int i = 0; for (i = 0; i运行结果:
注意:使用指针遍历的前提是元素必须是连续存放的。
5.2 指针-指针
指针减去指针就是两个地址相减,得到的就是两个地址之间的元素的个数,如果是int*的指针,是以4个字节为一个元素单位计算的,如果是char*类型的指针是以1个字节为一个元素单位计算的。
指针-指针 (地址-地址) 的前提是两个指针指向同一块开辟好的数组空间,这就是语法规则。
所以不能这样:
运行结果是错误的,所以一定要遵循语法规则。
可以用指针减去指针做什么呢?
练习:指针-指针来模拟strlen库函数,求出字符串的长度:
#include int my_strlen(char* str) { char* str1 = str;//创建一个新的指针来接收这个地址 while (*str1 != '\0')//用新指针不停的遍历找到'\0' { str1++; } return str1 - str;//新指针('\0'的地址)减去形参指针(第一个字符的地址) } int main() { char str[] = "hello world"; int len = my_strlen(str); printf("%d\n", len); return 0; }运行结果:
总结:
- 指针-指针必须指向同一块空间,可以相互运算。因为如果是&arr[0]+9就是&arr[9],arr[9]-arr[0]就是9了,指针减指针也是看两个指针的类型求出它们之间的元素个数。
- 准确来说指针-指针求出的是以元素大小为单位的绝对值
- 指针-指针不能是两个不同变量空间的地址相减,1.如果类型不同不确定是用哪个类型来表示元素个数的元素。2.就算类型一样两个地址相减也没有什么意义,答案也不对,因为两块不同的空间中间会有未开辟的内存空间隔开,谁知道未开辟的内存空间里有多少元素个数。
5.3 指针的关系运算
所谓的指针关系运算,就是指针和指针(地址和地址)之间比较大小。高地址比低地址大,低地址比高地址小。
可以使用指针关系运算,判断一个指针是否小于另一个指针,如果小于则打印这个指针对应的数组元素。前提是要找到数组最后一个元素地址的下一个地址,再不停的进行比较,如果小于这个地址就访问地址指向的空间打印空间里的数据。
#include int main() { int arr[] = { 1,2,3,4,5,6,7,8,9,10 }; int sz = sizeof(arr) / sizeof(arr[0]);//求出元素个数 int* p = arr;//获取首元素地址 while (p运行结果:
6、野指针
概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
6.1 野指针成因
1.指针未初始化
int main() { int* p;//局部变量,在创建的时候内存中存储的是随机值 *p = 20;//这时候给p随机值当做地址访问就是非法访问 return 0; }在内存中的一块空间,你需要申请才能使用。像上面的未初始化的野指针,局部变量自动赋值为随机数,把随机数当成地址,这个地址指向的这块空间还未申请开辟,不属于当前的程序的内存空间。通过这个随机数地址访问指向的空间并赋值就是非法访问。
2.越界访问
int main() { int arr[10] = {0}; int* p = &arr[0]; int i = 0; for(i=0;i arr[j + 1]) { int s = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = s; flag = 0;//设置为需要排序 } } if (flag == 1)//假设一趟下来没有任何排序的值,说明已经不再需要排序,跳出循环排序 { break; } } for (i = 0; i运行结果:
11.2 模拟实现
了解了冒泡排序后我们就可以以冒泡排序来实现qsort函数了,我们自定义模拟实现的qsort函数就以bubble_sort为函数名,接下来就是bubble_sort自定义函数的实现:
void reverse(char* buf1, char* buf2, int width) { int i = 0; for (i = 0; i达到某种特定条件回调我们传参的函数,就是回调函数,以上con就是回调函数。
但是该函数最精巧的不是回调函数,而是(char*)base+j*width和(char*)base+(j+1)*width,因为该函数的特点就是可以排序每个类型的数组,既然是这样,必须要用void*的指针base来接收地址。再将地址强转成char*类型,每次+j*width刚好跳过这些元素的大小来到某个元素的位置并调换。这就是void*指针和每个元素的大小在该函数中的作用。
然后我们就调用我们模拟实现的函数并打印:
int con(const void* e1, const void* e2)//该函数是自己实现的,要排序什么类型数组,就将地址强转成什么类型 { return *(int*)e1 - *(int*)e2;//qsort内部判断该函数返回值如果大于0就交换,如果等于或小于则不交换 }//如果e1大于e2,e1-e2一定返回大于0的数字,e1大于e2就交换 void print_f(int* arr, int sz) { int i = 0; for (i = 0; i打印结果:
第九章:字符函数和字符串函数
在编程的过程中,我们经常要处理字符和字符串,为了方便操作字符和字符串,C语言标准库中提供了一系列库函数,接下来我们就学习一下这些函数。
一、字符函数
1、字符分类函数
C语言中有一系列的函数是专门做字符分类的,也就是一个字符是属于什么类型的字符的。这些函数的使用都需要包含一个头文件是ctype.h
函数 如果他的参数符合下列条件就返回真 iscntrl 任何控制字符 isspace 空白字符:空格、换页、换行、回车、制表符 isdigit 十进制数字0-9 isxdigit 十六进制数字,包含所有十进制数组,小写字母a-f,大写字母A-F islower 小写字母a-z isupper 大写字母A-Z isalpha 字母a-z或A-Z isalnum 字母或数字,a-z,A-Z,0-9 ispunct 标点符号,任何不属于数字或者字母的圆形字符(可打印) isgraph 任何图形字符 isprint 任何可打印字符,包括图形字符和空白字符 给一段代码让大家清楚的看到字符分类函数的用法和返回值,以islower为例:
#include int main() { int ret = islower('a'); printf("%d\n", ret); ret = islower('A'); printf("%d\n", ret); return 0; }打印结果:
islower是分类小写字母的函数,当我们给它传一个小写字母时返回非0的值,传一个大写字母判断不是小写字母则返回0。
其他的函数和此函数的使用形式是一模一样的,可以根据以上例子进行使用。
代码练习:将字符串中的小写字母转大写,其他字符不变
#include #include #include int main() { char str[] = "i Like Beijing!"; size_t len = strlen(str); size_t i = 0; for (i = 0; i2、字符转换函数
C语言提供了2个字符转换函数:
int tolower(int c);//将参数传进去的大写字母转小写 int toupper(int c);//将参数传进去的小写字母转大写
有了这个函数我们就可以将上面的代码更新一下,不需要+、-32来改变大小写字母,直接使用转换字符函数即可:
#include #include #include int main() { char str[] = "i Like Beijing!"; size_t len = strlen(str); size_t i = 0; for (i = 0; i二、字符串函数
3、strlen的使用和模拟实现
strlen库函数功能:求字符串长度,统计的是结束标志\0之前出现的字符个数
strlen库函数的声明:
size_t strlen(const char* str);
strlen函数的调用:
#include #include int main() { char str[] = "abcdef"; size_t len = strlen(str);//调用strlen函数,返回值用size_t的变量来接收 printf("%zd\n", len);//打印size_t类型的返回值(字符串长度) return 0; }strlen注意事项:
1、strlen所计算的字符串结尾必须有结束标志 ' \0 '。
2、必须给strlen传递字符串地址,strlen需要通过地址向后访问找到 ' \0 ' 为止,只传递字符会报错。
strlen函数的模拟实现:
仿照strlen的函数参数,返回类型,功能写一个类似的函数
方法1:遍历判断
#include #include size_t my_strlen(const char* str) { assert(str != NULL); size_t count = 0;//计数器,统计字符串的长度 while (*str++ != '\0')//后置++,先使用,当解引用以后str++指向下一个元素 { count++;//不是'\0'就让计数器++一次 } return count; } int main() { char str[] = "hello world"; size_t len = my_strlen(str); printf("%zd\n", len); return 0; }方法2:指针 - 指针
#include #include size_t my_strlen(const char* str) { assert(str != NULL); char* start = str; while (*start != '\0') { start++; } return start - str;//\0的地址 - 首字符的地址得到地址直接的元素个数 } int main() { char str[] = "hello world"; size_t len = my_strlen(str); printf("%zd\n", len); return 0; }方法3:函数递归
#include #incldue size_t my_strlen(const char* str) { assert(str != NULL); if (*str != '\0') return 1 + my_strlen(str + 1); else return 0; } int main() { char str[] = "hello world"; size_t len = my_strlen(str); printf("%zd\n", len); return 0; }4、strcpy的使用和模拟实现
strcpy库函数功能:将一个字符串拷贝另一个数组
strcpy函数的声明:
char* strcpy(char* destination, const char* source);
strcpy函数的调用:
#include #include int main() { char arr1[] = "hello world"; char arr2[20] = { 0 }; strcpy(arr2, arr1); printf("%s\n", arr2); return 0; }strcpy注意事项:
1、strcpy里源字符串必须包含 ' \0 ',因为 ' \0 ' 也会被拷贝到目标空间。
2、strcpy里目标空间必须要有足够大的空间来存储这个拷贝过来的数据。
strcpy函数的模拟实现:
仿照strcpy的函数参数,功能写一个类似的函数
#include char* my_strcpy(char* dest, const char* src) { char* ret = dest; assert(dest && src); while (*dest++ = *src++)//边判断,边赋值 { ; } return ret; } int main() { char arr1[] = "hello world"; char arr2[20] = { 0 }; my_strcpy(arr2, arr1); printf("%s\n", arr2); return 0; }5、strcat的使用和模拟实现
strcat库函数功能:字符串追加,就是在目标空间的末尾追加上一串源字符串
strcat函数的声明:
char* strcat(char* destination, const char* source);
strcat函数的调用:
#include #include int main() { char arr1[20] = "hello"; char arr2[] = "world"; strcat(arr1, arr2); printf("%s\n", arr2); return 0; }从arr1末尾的 ' \0 ' 开始,拷贝源字符串arr2,将arr1的末尾追加上arr2
strcat注意事项:
1、目标空间必须有足够大的空间进行追加。
2、目标空间结尾和源字符串结尾都必须有 ' \0 '。
strcat函数的模拟实现:
仿照strcat的函数参数,功能写一个类似的函数
#include char* my_strcat(char* dest, const char* src) { assert(dest && src); char* ret = dest; while (*dest != '\0')//先找到目标空间的'\0',方便追加 { dest++; } while (*dest++ = *src++)//从dest的'\0'位置开始追加 { ; } return ret; } int main() { char arr1[20] = "hello "; char arr2[] = "world"; my_strcat(arr1, arr2); printf("%s\n", arr1); return 0; }6、strcmp的使用和模拟实现
strcmp库函数功能:用来比较两个字符串的大小关系
strcmp的函数声明:
int strcmp(const char* str1, const char* str2);
注意strcmp比较的不是两个字符串的长度的,而是比较两个字符串中对应位置上的字符,按照字典序比较。
标准规定:
- 第一个字符串大于第二个字符串,则返回大于0的数字
- 第一个字符串等于第二个字符串,则返回0
- 第一个字符串小于第二个字符串,则返回小于0的数字
- 那么如何判断两个字符串?比较两个字符串中对应位置字符的ASCII码值的大小
strcmp函数的调用:
#include #include int main() { int ret1 = strcmp("abcdef", "abq"); int ret2 = strcmp("abcdef", "abcdef"); int ret3 = strcmp("abq", "abcdef"); printf("%d %d %d", ret1, ret2, ret3);//打印-1 0 1 return 0; }strcmp函数的模拟实现:
#include int my_strcmp(const char* str1, const char* str2) { assert(str1 && str2); while (*str1 == *str2) { if (*str1 == '\0') return 0; str1++; str2++; } return *str1 - *str2; } int main() { int ret = my_strcmp("abcdef", "abc"); printf("%d", ret); return 0; }7、桃园三结义:长度受限制函数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:限制字符串比较字符个数
所以具体函数调用就不再一一介绍了,知道是什么功能限制什么就可以了
8、strstr的使用和模拟实现
strstr库函数功能:在一个字符串中查找另一个字符串,简单概述就是判断第二个字符串是不是第一个字符串的子字符串
strstr的函数声明:
char* strstr(const char* str1, const char* str2);
strstr函数返回str2在str1中第一次出现的位置
如果str2在str1中没有出现,就返回NULL
strstr函数的调用:
#include #include int main() { char str1[] = "abbcde"; char str2[] = "bc"; char* s = strstr(str1, str2); if (s == NULL) { printf("str2不是str1的子串\n"); } else { printf("%s\n", s); } return 0; }strstr函数的模拟实现:
仿照strlen的函数参数,返回类型,功能写一个类似的函数
方法1:暴力求解
#include #include #include const char* my_strstr(const char* str1, const char* str2) { assert(str1 && str2); const char* s1 = NULL; const char* s2 = NULL; const char* start = s1; if(*str2=='\0') return str1; while (*start) { s1 = start;//重置位置 s2 = str2;//重置位置 while (*s1==*s2 && *s1!='\0')//如果相等就++继续判断下一个 { s1++; s2++; } if (*s2 == '\0')//结束循环后判断是不是因为*s2为\0结束的 { return start;//返回刚开始的判断位置 } start++;//下一个位置继续对比判断 } return NULL; } int main() { char str1[] = "abbbcde"; char str2[] = "bbc"; const char* s = my_strstr(str1, str2); if (s == NULL) { printf("str2不是str1的子串\n"); } else { printf("%s\n", s); } return 0; }方法2:KMP算法-效率高,但是难度大,难以理解
有兴趣可以自己去了解一下。
9、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便可继续分割其他的字符串。
strtok函数的调用
#include #include int main() { char arr[] = "linlu1024@yeah.net"; char buf[30] = { 0 }; strcpy(buf, arr); char* p = "@."; char* s = NULL; for (s = strtok(buf, p); s != NULL; s = strtok(NULL, p))//根据strtok的特性去遍历 { printf("%s\n", s);//遍历打印字符串的分段 } return 0; }10、strerror的使用
strerror库函数功能:返回一个错误信息字符串的起始地址,简单概述就是返回一个错误码所对应的错误信息字符串的起始地址,这个错误码就是我们调用时传递的实参。
strerror函数的声明:
char* strerror(int errnum);
strerror函数的调用:
#incldue #include int main() { int i = 0; for (i = 0; i data[i]); } printf("\n%d\n", s->num); } int main() { struct S s = { {1,2,3,4,5,6,7,8,9,10},20 }; print2(&s);//传递结构体变量的地址 return 0; }'->'是结构体指针解引用操作符,正常结构体使用 ' . ' 来访问成员,而结构体指针可以直接使用 '->'来访问成员
stu->num ==等价于== *(stu).num
上面两种传参方式哪种更好?
答案是:首选传址调用。
原因:
1. 函数传参的时候,参数是需要压栈的,会有时间和空间上的系统开销。
2. 如果传递一个结构体对象的时候,结构体过大,参数压栈的系统开销比较大,所以会导致性能下。
结论:结构体传参的时候,要传结构体的地址。
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位。
注:结构体位段不会内存对齐
知道了位段信息,我们就可以根据该信息算出上面的结构体A的大小,最后算出一共是47个bit位,大概是6个字节。如果不使用位段4个整型的变量也是16个字节。但是结果真的是6个字节吗?我们可以使用sizeof运算一下。
#include struct A { int _a:2; int _b:5; int _c:10; int _d:30; }; int main() { printf("%d\n",sizeof(struct A)); return 0; }运算结果:
我们算出的位段总共加起来差不多6个字节,那为什么结果是8个字节呢?
这就要看位段的内存分配方式了,经过第一个成员位段在开辟空间时首先不管成员位段后面的空间,而是看成员的类型,是int类型就先开辟一个4个字节,32个二进制位的空间。开辟好后就开始看第一个成员变量_a位段的数字,首先是2bit,可以存放。接下来是看_b和_c后面的数字,还是可以在所开辟的空间范围之内申请空间。此时已经占用了17个bit位了,但是_d是30,剩下的空间不够申请30个bit了,所以又要开辟一块空间,怎么开辟呢?就是要看_d的类型,是整型所以又开辟了32个bit,又开辟了4个字节,最后分配给_d30个bit。所以最后结果是8个字节。可以看到位段可能会浪费一些空间,但是相对结构体位段的空间节省较好一些。
注意:位段后面分配的位数大小是不能超出自身类型的大小的,比如char类型的变量不能分配9个bit,int类型不能分配33个bit。
4.2 位段的内存分配
1. 位段成员可以是int、unsigned int、signed int 或是 char等类型。
2. 位段的空间是按照需要以4个字节(int)或者1个字节(char)的方式来开辟的。
3. 位段涉及很多不确定因素,位段是不跨平台的,注意可移植的程序应该避免使用位段。
为了大家能够更深刻的理解位段,特举了下面代码例子:
struct S { char a:3; char b:4; char c:5; char d:4; }; int main() { struct S s = {0}; s.a = 10; s.b = 12; s.c = 3; s.d = 4; printf("%d\n",sizeof(s)); return 0; }该位段大小为3个字节,为什么是三个字节呢?那这些值在内存中如何存储的呢?可以根据下图来分析。
4.3 位段的跨平台问题
1. int 位段被当成有符号数还是无符号数是不确定的。
2. 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机器会出问题)。
3. 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
4. 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位是,是舍弃剩余的位还是利用,这是不确定的。
总结:跟结构相比,位段可以达到同样的效果,并且可以很好地节省空间,但是有跨平台的问题存在。
4.5 位段使用的注意事项
位段的几个成员共有同一个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的,内存中每个字节分配一个地址。一个字节内部的bit位是没有地址的。
所以不能对位段的成员使用&操作符,这样不能使用scanf直接给位段的成员输入值,只能是先输入放在一个变量中,然后赋值给位段成员。
第十三章:自定义类型(联合union 、枚举enum)
1、联合体
1.1 联合体类型的声明
像结构体一样,联合体也是由一个或多个成员构成,这些成员可以是不同的类型。
但是编译器只为最大的成员分配足够的内存空间,联合体的特点是所有成员共用一块内存空间,所以联合体也叫:共用体
struct是结构体类型前缀,union是联合体类型前缀。
和结构体一样,联合体类型的声明也是这样的:
#include union U { char c; int i; }; int main() { union U u = {0}; printf("%d\n",sizeof(u)); return 0; }结果为4个字节;不对啊!一个int类型成员是4字节,还有一个char类型的成员,加起来怎么说也得有5个字节,为什么只有4个字节呢?这就是联合体的特点。
看下面的代码:
#include union U { char c; int i; }; int main() { union U u = { 0 }; printf("%p\n", &u); printf("%p\n", &(u.i)); printf("%p\n", &(u.c)); return 0; }三个地址还是一样,我们可以来分析一下为什么。如果三个地址一样可以说明这个联合体变量只有一块4个字节的空间,所以联合体变量本身的地址就是这块空间的首字节地址,然后就是2个成员,2个成员的地址相同说明什么,说明它们共用一块空间,这就是联合体的特点。
1.2 联合体的特点
联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)
知道了联合体的特点,那来看一下以下代码会打印什么:
#include union U { char c; int i; }; int main() { union U u = { 0 }; u.i = 0x11223344; u.c = 0x55; printf("%#x\n", u.i); return 0; }运行结果:
1.3 相同成员的结构体和联合体对比
我们再对比一下相同成员的结构体和联合体的内存布局情况。
struct S { char c; int i; }; struct S s = {0};union Un { char c; int i; }; union Un un = {0};1.4 联合体大小的计算
联合体的大小至少是最大成员的大小。
当最大成员的大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。
联合体的最终大小也是要对齐到最大对齐数的整数倍的,既然知道了联合体大小的计算,那来计算一下这个代码的结果:
#include union Un1 { char c[5]; int i; }; union Un2 { short c[7]; int i; }; int main() { printf("%d\n", sizeof(union Un1)); printf("%d\n", sizeof(union Un2)); return 0; }最大对齐数还是4,因为成员i是最大对齐数,千万不要以为数组的整体大小才算对齐数,其实数组的对齐数就是数组每个成员类型的大小。
1.5 联合体的应用场景
知道了联合体是什么,什么特点以及怎么使用,那联合体的应用场景是什么?
先举个例子:比如我想写一个游戏,需要有一个架构来保存角色的不同职业信息。那有人可能会写出这样的代码:
struct Game { //角色基础信息 char name[20];//名字 char sex[5];//性别 enum color c;//角色头发颜色 //剑士 int l1;//攻击 struct K k;//剑士技能 //刺客 int j1;//机敏 struct C c;//刺客技能 };当我选择剑士时,只使用给剑士数据开辟的空间,当我选择刺客时,只使用给刺客数据开辟的空间。虽然只选择一个职业时,只给一个职业的内存存入数据。由于是结构体,另一个未选择的职业也是有开辟空间的。这就导致了开辟了多余的空间但却空着不使用,从而造成了空间浪费。这时候联合体union就派上了用处,如果只想给架构中一部分变量的内存存入数据,并保证另一部分不占用空余的空间就使用联合体。相当于两个不同角色职业的数据可以存储在同一个内存空间,但并不是两个一块存储,而是有一方需要存入数据时保证另一方不占用多余空间,而使用另一方存入数据时保证这一方不会占用多余的空间,这就是联合体的作用
struct Game { //角色基础信息 char name[20];//名字 char sex[5];//性别 enum color;//角色头发颜色 //职业数据 union{ //如果在内部创建只使用一次,创建一次内部可以无限调用,所以可以在结构体内部创建匿名联合体或结构体 //剑士 struct{ int l1;//攻击 struct K Sdm;//剑士技能 }Swordsman; //刺客 struct{ int j1;//机敏 struct C Asin;//刺客技能 }assassin; }un; };这下应该知道union联合体的作用了吧!
union联合体的应用场景:当有两个或多个相同类型的数据需要一个结构来集成在一起,但是每次使用只使用一个类型的空间,我们可以将这多个类型的全部集成一个联合体,每个类型的地址都是一块空间,相当于共用一块,使用一个类型也保证了其他类型不额外占用多余空间。
联合体练习:
我们也可以通过联合体来判断当前场景为大端还是小端:
#include union Un { int i; char c; };//因为是共用4个字节,并且两个成员的地址都是首字节低地址处 int main() { union Un un = { 0 }; un.i = 1;//将里面的i赋值为1,小端会将1的低位字节放在低地址处,大端会将低位字节放在高地址处 if (un.c == 1)//成员c本身就是这块空间的低地址,只需要判断低地址处的是1还是0 { printf("小端\n"); } else { printf("大端\n"); } return 0; }2、枚举类型
2.1 枚举类型的声明
没枚举顾名思义就是一一列举。
把可能的取值一一列举。
比如我们现实生活中:
一周的星期一到星期日是有限的7天,可以一一列举
性别有:男、女、保密,也可以一一列举
月份有12月,也可以一一列举
三原色,也是可以一一列举
这些数据的表示就可以使用枚举了。
enum Day//星期 { Mon, Tues, Wed, Thur, Fri, Sat, Sun }; enum Sex//性别 { MALE, FAMALE, SECRET }; enum color//颜色 { RED, GREEN, BLUE };这里枚举里的常量都是列出的枚举类型的可能取值
这些列出的可能取值被称为:枚举常量
每个枚举里的常量,从第一个默认都是0,依次向下增长的常量集合。
#include enum Day//星期 { Mon, Tues, Wed, Thur, Fri, Sat, Sun }; int main() { printf("%d %d %d %d %d %d %d\n", Mon, Tues, Wed, Thur, Fri, Sat, Sun); return 0; }运行结果:
从这里我们可以看出,枚举和(联合、结构体)的格式是不相同的,枚举里的不是成员,而是标识符常量,定义了这些标识符我们就可以直接使用该标识符来打印对应的常量,不用再额外创建该枚举类型变量再访问该标识符。所以简单来说枚举类型就是一堆标识符常量的集合类型。
如果不想默认从0开始打印我们就可以更改第一个标识符赋值一个值,后面的标识符的值则是该值依次增长所得到的值。
#include enum Day//星期 { Mon=5, Tues, Wed, Thur, Fri, Sat, Sun }; int main() { printf("%d %d %d %d %d %d %d\n", Mon, Tues, Wed, Thur, Fri, Sat, Sun); return 0; }运行结果:
注意:只有在声明枚举常量时里面的标识符可以被赋予一个初始值,但是声明好后在去给枚举里的标识符常量赋值是会报错的,原因是该标识符是常量,不能被更改。
enum Day//星期 { Mon=5, Tues, Wed, Thur, Fri, Sat, Sun }; int main() { Mon = 10;//error return 0; }2.2 枚举类型的优点
为什么使用枚举呢?
我们可以使用#define定义常量,为什么非要使用枚举?
枚举的优点:
1. 增加代码的可读性和可维护性
2. 和#define定义的标识符比较枚举有类型检查,更加严谨
3. 便于调试,预处理阶段会删除#define定义的符号
4. 使用方便,一次可以定义多个常量
5. 枚举常量是遵循作用域规则的,枚举声明在函数内,只能在函数内使用
2.3 枚举类型的使用
enum Color { RED = 1, GREEN = 2, BLUE = 3 }; enum Color clr = GREEN;//使用枚举常量给枚举变量赋值那是否可以拿整数给枚举变量赋值呢?在C语言中是可以的,但是在C++是不行的,C++的类型检查比较严格。
第十四章:动态内存管理
1、为什么要有动态内存分配
我们已经掌握的内存开辟方式有:
创建变量 char c = 0; int a = 0; int arr[10] = {0};但是上述的开辟空间的方式有两个特点:
- 空间开辟大小是固定的
- 数组在声明的时候,必须指定数组的长度,数组空间一旦确定了大小不能调整
但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在程序运行的时候才知道,那数组的编译时开辟空间的方式就不能满足了
当数组创建好后空间大小是不能调整的,一旦创建好数组后数组空间的大小就是固定的,所以才引入了动态内存分配,刚开始可以分配10个整型元素的空间,当这10个空间不够用了,我们就可以继续申请扩容空间容量,继续使用。
C语言引入了动态内存开辟,让程序员自己可以申请和释放空间,就比较灵活了。
2、malloc和free
malloc是用来申请内存的,动态内存开辟的方式有些特殊,开辟的内存空间并不是栈区的空间,而是堆区的空间,所以程序结束时并不会自动销毁并回收该空间,所以就有了free,每次用完该空间就记得使用free将该空间释放掉。不然它将一直占用内存空间。
调用动态内存开辟函数时需要包含头文件#include
2.1 malloc
malloc函数的声明:
void* malloc(size_t size);
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
- 如果开辟成功,则返回一个指向开辟好空间的指针
- 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查
- 返回值的类型是void*,所以malloc函数并不知道开辟空间的类型,具体在使用的时候由使用者自己来决定
- 如果参数size为0,malloc的行为是标准还是未定义的,取决于编译器
malloc函数的使用:
#include int main() { //申请10个整型的空间 - 40个字节 int* p = (int*)malloc(10 * sizeof(int)); if (p == NULL)//判断 { perror("malloc"); return 1; } int i = 0; for (i = 0; i既然可以申请到空间并且使用,那还需要释放掉该空间,那怎么释放呢?
malloc申请的空间怎么回收呢?
1. free回收
2.自己不释放的时候,程序结束后,也会由操作系统回收
注:动态内存开辟的函数开辟空间都是在堆区上开辟的,内存是分为三个区域:栈区、堆区、静态区
2.2 free
free的函数声明:
void free(void* ptr);
free是用来释放动态开辟的空间的,只需要将这块空间的起始位置的指针传递给free,free可以通过该地址向后释放这块空间。
free函数就是用来释放动态开辟的内存。
- 如果参数ptr指向的不是动态开辟的,那free函数的行为是未定义的。
- 如果参数ptr是NULL指针,则函数什么事都不做
注意:free释放的空间仅限于动态内存开辟的空间,必须是堆区的空间
free函数的使用:
#include #include int main() { //申请10个整型的空间 - 40个字节 int* p = (int*)malloc(10 * sizeof(int)); if (p == NULL)//判断 { perror("malloc"); return 1; } int i = 0; for (i = 0; i给free一个指向开辟好的堆区的指针,就可以通过这个指针释放空间。最后不要忘了将指向free释放掉的空间的指针指向NULL,因为它指向的空间已经被free释放,再解引用就是非法访问了,所以要置为NULL。
3、calloc和realloc
3.1 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));
我们可以打印一下试试calloc开辟的空间是否初始化为全0:
#include #include int main() { int* p = (int)calloc(10, sizeof(int)); if (p == NULL) { perror("calloc"); return 1; } int i = 0; for (i = 0; i运行结果:
3.2 realloc
- realloc函数的出现让动态内存管理更加灵活
- 有时我们会发现过去申请的空间太小了,有时我们又会觉得申请的空间过大了,那为了合理的使用内存,我们一定会对内存的大小做灵活的调整。那realloc函数就可以对动态开辟内存进行扩容
函数原型如下:
void* realloc(void* ptr,size_t size);
- ptr是需要调整的内存地址
- size调整之后新的大小
- 返回值为调整之后的内存起始位置
- 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到新的空间
假设malloc开辟的空间不够用了,那就可以使用realloc在原有的空间大小开辟出新的空间大小:
#include #include int main() { int* ptr = (int)malloc(20); if (ptr != NULL) { int* tmp = (int*)realloc(ptr, 40);//注意realloc开辟空间需要新的指针来接收,不要用原来的指针来接收 } return 0; }realloc在调整内存空间存在两种情况:
1. 原有空间之后有足够大的空间
2. 原有空间之后没有足够大的空间
如果是情况1,后面未分配的空间足够需要开辟的大小,就会在原有的空间的基础上增加开辟空间的大小。
但如果是情况2,后面未分配的空间不够需要开辟的大小,编译器找一个新的空间并会将之前开辟空间里面存储的数据存放进新找到的空间并将原来的空间销毁。
那我们可以用原来接收malloc返回值的指针,来接收realloc新开辟的空间地址吗?
#include int main() { int* p = (int*)malloc(5*sizeof(int)); if(p==NULL) { perror("malloc"); return 1; } int* p = (int*)realloc(p,10*sizeof(int)); return 0; }当然不能,如果p来接收新开辟的空间地址,是接收了。但realloc可能也会开辟失败返回NULL,如果用了p来接收,不但没有接收到新开辟空间的地址,而且NULL还弄丢了之前开辟空间的地址。
解决方法:可以再创建一个指针变量,当指针变量接收realloc的返回值时,判断是否是NULL,不是就说明开辟成功了,可以赋值给p。
#include int main() { int* ptr = (int)malloc(20); if (ptr == NULL) { perror("malloc"); return 1; } int* tmp = (int*)realloc(ptr, 40); if (tmp != NULL) { ptr = tmp; tmp = NULL; } else { perror("realloc"); return 1; } return 0; }注:realloc函数不仅仅是扩容来使用的,也可以将realloc当malloc使用
realloc函数的第一个参数是一块动态开辟内存的地址,然后通过这个地址继续给这块动态开辟的空间来扩容。但是realloc不仅仅是扩容来使用的,也可以将realloc当malloc使用,比如第一个参数什么地址都不传就传递一个空指针NULL就可以了,realloc函数接收到NULL,就已经不是接收地址在地址指向的空间后面继续开辟,而是会自动在堆区找一块内存空间开辟并返回该空间的地址,大小还是有第二个参数来决定。
#include int main() { int* p = (int*)realloc(NULL,20); 等价于 int* p = (int*)malloc(20); return 0; }总结:
1. 使用malloc 或 realloc 函数开辟的空间不会被初始化为全0,只有使用calloc函数开辟的空间会被初始化为全0。
2. free函数只能释放动态内存开辟的空间,如果传入其他空间的地址会报错。还有当把一个动态内存的地址传给free释放掉这块空间后,要将指向这块空间的指针置为NULL,以免造成非法访问
3. realloc函数一般是用来扩容空间使用的,但是当传递NULL给realloc函数时,此时的realloc和malloc是等价的,都是直接开辟一块动态内存并返回地址
4、常见的动态内存错误
4.1 对NULL指针的解引用操作
不管是malloc、calloc还是realloc这些函数在 开辟 / 调整 空间失败的时候,会返回NULL,而我们并未判断并解引用则会导致NULL指针解引用操作的错误出现
#include int main() { int* p = (int*)malloc(20);//有可能返回空指针 int i = 0; for (i = 0; i解决方法:每次开辟或调整完空间后判断指针接收到的是不是NULL,提前进行判断并解决
#include int main() { int* p = (int*)malloc(20); if(p==NULL)//开辟完后进行判断 { return 1;//提前结束程序 } int i = 0; for (i = 0; i4.2 对动态开辟的空间越界访问
动态开辟的空间也是有使用范围的,和数组一样,当越界访问时就会报错
#include int main() { int* p = (int*)malloc(20);//只申请了5个整型大小的空间 if(p==NULL) { return 1; } int i = 0; for (i = 0; i解决方法:使用时注意尽量避免越界访问就可以了
4.3 对非动态开辟内存使用free释放
#include int main() { int a = 10; int* p = &a; free(p);//error reutrn 0; }解决方法:使用free时注意只能传动态开辟的地址就可以了
4.4 使用free释放一块动态开辟内存的一部分
#include int main() { int* p = (int*)malloc(20); if(p==NULL) { return 1; } p += 1;//拿到跳过一个整型大小的地址 free(p); p = NULL; return 0; }解决方法:尽量不要改变p地址的指向,如果要改变,提前创建一个指针指向该块动态内存空间的起始位置就可以了
4.5 对同一块动态内存多次释放
#include int main() { int* p = (int*)malloc(20); if (p == NULL) { return 1; } free(p); free(p);//重复释放 return 0; }解决方法:释放完后给指向这块空间地址的指针置为NULL,下一次free这个指针时什么也不会发生
#include int main() { int* p = (int*)malloc(20); if (p == NULL) { return 1; } free(p); p = NULL; free(p); return 0; }4.6 动态开辟内存忘记释放(内存泄漏)
#include int main() { int* p = (int*)malloc(20); if(p==NULL) { return 1; } int i = 0; for (i = 0; i解决方法:你申请的动态内存当不再使用时记得使用free释放该空间
5、柔性数组
也许你从来没听说过柔型数组(flexible array)这个概念,但是它确实是存在的。
C99中,结构中最后一个元素允许是未知大小的数组,这就叫做【柔性数组】成员。
柔性数组:
1. 一定在结构体中
2. 一定是最后一个成员
3. 一定是未知大小的数组(柔型数组)
注:并且柔性数组是需要配合动态内存管理来使用的
例如:
typedef struct st_type { int i; int a[];//柔性数组成员 }type_a;5.1 柔性数组的特点:
- 结构中的柔性数组成员前面必须至少要有一个其他成员
- sizeof返回这种结构的大小不包括柔性数组的内存
- 包含柔性数组成员的结构用malloc函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔型数组的预期大小
例如:
#include struct st_type { int i; int a[];//柔性数组成员 }; int main() { printf("%d\n",sizeof(struct st_type));//打印结果为:4 return 0; }5.2 柔性数组的使用
#include #include #include struct st_type { int i; int a[]; }; int main() { struct st_type* p = (struct st_type*)malloc(sizeof(struct st_type) + 10 * sizeof(int)); if (p == NULL) { perror("malloc"); return; } p->i = 100; int i = 0; for (i = 0; i a[i] = i + 1; } //我们觉得给柔性数组10个整型空间不够怎么办?我们可以使用realloc增容 struct st_type* ptr = (struct st_type*)realloc(p, sizeof(struct st_type) + 15 * sizeof(int)); if (ptr != NULL) { p = ptr; ptr = NULL; } else { perror("realloc"); return 1; } //使用 // ... //释放 free(p); p = NULL; return 0; }第十五章:文件操作
1、为什么使用文件?
如果没有文件,我们写的程序的数据是存储在电脑的内存中,如果程序退出,内存回收,数据就丢失了,等再次运行程序,是看不到上次程序的数据的,如果要将数据进行持久化的保存,我们可以使用文件。
2、什么是文件?
磁盘(硬盘)上的文件就是文件。
但是程序设计中,我们一般谈两个文件,分别是程序文件、数据文件(从文件的角度来分类的)。
2.1 程序文件
程序文件包括源程序文件(后缀为.c)、目标文件(windows环境后缀为.obj),可执行文件(windows环境后缀为.exe)。
2.2 数据文件
文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件,或者输出内容的文件。
本章讨论的是数据文件。
在以前各篇笔记所处理数据的输入输出都是以终端为对象的,即从终端的键盘输入数据,运行结果显示到显示器上,其实有时候我们会把信息输出到磁盘上,当需要的时候再从磁盘上把数据读取到内存中使用,这里处理的就是磁盘上的文件。
2.3 文件名
一个文件要有唯一的文件表示,以便用户识别和引用。
文件名包含3部分:文件路径+文件主干+文件后缀
例如:c:\code\test.txt
为了方便起见,文件标识常被称为文件名。
3、二进制文件和文本文件
根据数据的组织形式,数据文件被称为文本文件或者二进制文件。
数据在内存中以二进制的形式存储,如果不加转换的输出到外存的文件中,就是二进制文件。
如果要求在外出上以ASCII的形式存储,则需要再存储前转换,以ASCII字符的形式存储的文件就是文本文件。
一个数据在文件中是怎么存储的呢?
字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以用二进制形式存储。
比如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节),而二进制形式输出,则在磁盘上只占4个字节。
代码栗子:
#include int main() { int a = 10000; FILE* pf = fopen("test.txt", "wb");//打开文件 fwrite(&a, 4, 1, pf);//二进制的形式写到文件中 fclose(pf);//关闭文件 pf = NULL; return 0; }4、文件的打开和关闭
4.1 流和标准流
4.1.1 流
程序的数据是要输出到各种外部设备,也需要从外部设备获取数据,不同的外部设备的输入输出操作各不相同,为了方便程序员对各种设备进行方便的操作,我们抽象出了流的概念,我们可以把流想象成流淌着字符的河。C程序真的文件、画面、键盘灯的数据输入输出操作都是通过流操作的。
一般情况下,我们要想向流里写数据,或者从流里读数据,都是要打开流,然后操作。
4.1.2 标准流
文件操作时我们需要自己打开文件(流),当操作完后需要自己关闭文件(流),那为什么我们从键盘输入数据,向屏幕上输出数据,并没有打开流呢?
那是因为C语言程序在启动的时候,默认打开了3个流:
- stdin - 标准输入流,大多数的环境中从键盘输入,scanf函数就是从标准输入流中读取数据。
- stdout - 标准输出流,大多数环境中输出值显示器界面,printf函数就是将信息输出到标准输出流中。
- stderr - 标准错误流,大多数环境中输出到显示器界面。
这是默认打开了这三个流,我们使用scanf、printf等函数就可以直接进行输入输出操作的。
stdin、stdout、stderr 三个流的类型是:FILE*,通常称为文件指针。
C语言中,就是通过FILE*的文件指针来维护流的各种操作的。
4.2 文件指针
缓冲文件系统中,关键的概念是 "文件类型指针" ,简称为 "文件指针"。
每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及文件当前的位置等)。这些信息是保存在一个结构体变量中的,该结构体类型是由系统声明的,取名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指向某个文件的文件信息区(是一个结构体变量),通过该文件信息区中的信息就能够访问该文件,也就是说,通过文件指针变量能够间接找到与它关联的文件。
比如:
4.3 文件的打开和关闭
文件在读写之前应该先打开文件,在使用结束之后应该关闭文件。
在编写程序的时候,在打开文件的同时,都会返回一个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表示文件的打开模式,下面都是文件的打开模式:
文件使用方式 含义 如果指定文件不存在 "r"(只读) 为了输入数据,打开一个已经存在的文本文件 出错 "w"(只写) 为了输出数据,打开一个文本文件 建立一个新的文件 "a"(追加) 向文本文件尾部添加数据 建立一个新的文件 "rb"(只读) 为了输入数据,打开一个二进制文件 出错 "wb"(只写) 为了输入文件,打开一个二进制文件 建立一个新文件 "ab"(追加) 向一个二进制文件尾部添加数据 建立一个新的文件 "r+"(读写)
为了读和写,打开一个文本文件 出错 "w+"(读写) 为了读和写,建立一个新的文本文件 建立一个新的文件 "a+"(追加) 打开一个文本文件,在文件尾部进行读写 建立一个新的文件 "rb+"(读写) 为了读和写,打开一个二进制文件 出错 "wb+"(读写)
"ab+"(追加)
为了读和写,建立一个新的二进制文件
打开一个二进制文件,在文件尾部进行读和写
建立一个新的文件
建立一个新的文件
注:fopen也是会打开失败的,如果打开失败,则返回空指针NULL。打开成功,则返回开辟好后的文件信息区的地址,所以使用前一定要判断一下。
然后就是fclose函数,它是用来关闭文件的,当我们指向文件信息区的FILE*的指针变量pf传进去,关闭好文件后一定要记得将pf置为NULL,因为我们虽然使用fclose函数释放了文件信息区,将文件信息区所占的内存还给操作系统了。但是指针变量pf始终是指向这块内存的,如果解引用访问使用这块内存就是非法访问了,所以当我们关闭文件后就把pf置为NULL。
注:如果以只读" w " 或" wb " 的形式打开文件,如果这个文件本身有数据,则会被清空,因为需要从头写入文件,所以要谨慎的使用只读的形式。
文件的打开方式:
文件打开有两种路径,一种是相对路径,一种是绝对路径
相对路径:
' . '表示当前路径,".."表示上一级路径
如果我们要打开的文件和程序所在的文件在一个路径下的话可以直接使用文件名打开,例如:
FILE* pf = fopen("test.txt","r");因为没有路径表示编译器便会自动在程序文件相同路径的位置找该文件。
如果该程序文件在许多级文件内存储,如果我们要打开的文件也在这个多级文件中,但是在程序文件所在文件的上一级的上一级的位置,我们可以这样访问,例如:
FILE* pf = fopen(".\\..\\..\\test.txt","r");一个‘ . ‘表示当前路径,两个 ".." 表示上一级路径。
还是将test.txt存放在当前数据文件所在的文件的上一级的上一级的位置,只不过我在这个位置又新建了一个文件夹叫hehe,然后我将test.txt放入这个hehe文件夹中,我们有什么方法可以访问呢:
FILE* pf = fopen(".\\..\\..\\hehe\\test.txt","r");".\\..\\..\\hehe\\test.txt"意思就是在当前路径 ' . ' 的上一级 " .. " 的上一级" .. " 路径下的文件夹"hehe"里的文件"test.txt"。
绝对路径:
必须填写文件对应的路径,通过这个路径来找到对应的文件
但当我们想要打开其他路径的文件比如桌面上的文件时,我们就需要额外的输入路径,让编译器通过该路径找到对应的文件,例如:
FILE* pf = fopen("C:\\Users\\zpeng\\Desktop\\test.txt","w"); //绝对路径在文件名前面添加一条路径,就可以根据这个路径找到对应文件。
场景1:当需要打开的文件和当前程序文件都是一个路径时,比如程序文件的项目是需要创建在一个文件夹中的,如果存在同一个文件夹,则不用填写路径。
场景2:当需要打开的文件和程序文件不在同一个文件夹,则需要在文件名前面添加上路径。
总结:文件路径也分为两个,分别是绝对路径和相对路径。
绝对路径:是在文件和程序文件位置不同时需要填写完整的路径来访问。
相对路径:是和程序文件在同一个文件里的,可能不一级文件,但是位置是有关联的,被称为相对路径
4.4 文件指针的概念
这里要说一下文件是有文件指针的,文件指针决定读取或写入的操作时从哪个位置开始的,如果程序开始运行并且使用过一次函数来访问当前文件信息区的文件了,文件指针就会发生改变,因为文件指针需要访问下一个位置的数据。
假设文件信息区的地址由变量pf来接收,那它的文件指针始终都不会重新开始,方便下一次调用文件访问函数可以从当前位置继续向后访问,所以没访问一次,文件指针会自动向后指向。除非是程序结束、使用rewind函数 或者是 又创建了一个文件信息区,否则当前pf关联的文件的文件指针始终都不会重新指向起始位置。
5、文件的顺序读写
5.1 顺序读写函数介绍
函数名 功能 适用于 fgetc 字符输入函数 所有输入流 fputc 字符输出函数 所有输出流 fgets 文本行输入函数 所有输入流 fputs 文本行输出函数 所有输出流 fscanf 格式化输入函数 所有输入流 fprintf 格式化输出函数 所有输出流 fread 二进制输入 文件 fwrite 二进制输出 文件 以上第三列表格适用于:所有输入流、所有输出流、文件,意思是每个对应函数的参数里有一个FILE*类型的指针变量参数,也就是流,所以都要有对应的流。所有输入流包括:标准输入流、文件流,所有输出流包括:标准输出流、文件流,二进制文件读写函数只能传文件流。我们也可以使用以上适用于标准输出流的函数数据通过标准输出流输出到屏幕上去,也可以使用以上适用于标准输入流的函数将我们从键盘输入的数据通过标准输入流读取出来,所以要记住,这些函数不仅仅是作用于文件的读取和写入。
以上所有函数的声明:
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);
5.1.1 fputc的使用
fputc的声明:
int fputc(int character, FILE* stream);
fputc函数:参数1:character是需要输出的字符。参数2:stream是FILE*类型的指针,可以是标准输出流或者是对应文件的流。
fputc函数的功能:通过参数2的指向的文件信息区里的信息访问文件,并将参数1的字符输出到当前文件,一次只能写一个字符。
fputc函数的使用:
#include #include int main() { FILE* pf = fopen("test.txt", "w");//打开文件 if (pf == NULL) { perror("fopen"); return; } char str[] = "hello world"; ine len = strlen(str); int i = 0; for(i = 0; i那我们也可以通过该函数将字符输出到屏幕上,就像printf一样:
#include int main() { fputc('a',stdout);经过标准输出流直接将字符'a'输出到屏幕上 return 0; }所以这里也就证明了FILE*类型的指针变量接收的文件信息区的地址是文件的流,顺序读写函数的参数FILE* stream是流,至于什么的流就看自己想怎么操作。
5.1.2 fgetc的使用
fgetc的声明:
int fgetc(FILE* stream);
fgect函数:参数:stream不用说就是流,但仅限于所有输入流,或文件的流,因为fgetc需要从输入流中获取数据。
fgetc函数的功能:将对应的输入流传参过去,getc会读取输入流中的字符,标准输入流是需要我们来输入字符,文件流是fgetc自己读取文件中的字符。
fgetc函数的使用:
#include int main() { FILE* pf = fopen("test.txt", "r");//打开文件 if (pf == NULL) { perror("fopen"); return; } char c = 0; while(c = fgetc(pf) != EOF)//会不断地向文件后读取数据 { printf("%c",c); } fclose(pf);//关闭文件 pf = NULL; return 0; }那我们也可以通过该函数读取我们键盘输入的字符,就像scanf一样:
#include int main() { char c = fgetc(stdin); printf("%c\n", c); return 0; }int c = fgetc(stdin); 等价于 int c = getchar();
到这里相信大家也都知道了这些函数可以通过标准输入流来获取我们键盘输入的数据或标准输出流将数据输出到屏幕上,那么下面的函数就不用在举这个例子了。
5.1.3 fputs的使用
fputs的声明:
int fputs(const char* str, FILE* stream);
fputs函数:参数1:str是需要输出的字符串,参数2:stream是FILE*类型的指针,可以是标准输出流或者是对应文件的流。
fputs函数的功能:将字符串根据输出流输出到对应的位置
fputs函数的使用:
#include int main() { FILE* pf = fopen("test.txt", "w"); if (pf == NULL) { perror("fopen"); return; } char str[] = "hello world"; fputs(str, pf); fclose(pf); pf = NULL; return 0; }5.1.4 fgets的使用
fgets的声明:
char* fgets(char* str, int num, FILE* stream);
fgets函数:参数1:str是存储fgets从输入流读取的数据空间的地址,参数2:num是需要拷贝从输入流读取的字符的个数,参数3:stream是FILE*类型的指针,可以是标准输入流或者是对应文件的流。
fgets函数的功能:从参数3的输入流中读取num个字符拷贝到str。
如果fgets读取失败会返回一个空指针NULL,所以我们使用该函数时也可以判断一下有没有读取成功。
fgets的使用:
#include int main() { FILE* pf = fopen("test.txt", "r"); if (pf == NULL) { perror("fopen"); return; } char* str = (char*)malloc(10 * sizeof(char)); fgets(str, 10, pf); printf("%s\n", str); fclose(pf); pf = NULL; return 0; }fgets不管读取多少个字符,最后一定会额外拷贝一个结束字符' \0 ' 放入str中。
5.1.5 fprintf的使用
fprintf是格式化函数,printf也是格式化函数
fprintf函数的声明:
int fprintf(FILE* stream,const char* format,...);
fprintf和printf有什么区别,我们再看一下printf函数声明:
int printf(const char* format,...);
我们可以发现printf和fprintf之间就差一个参数stream,stream就是流,我们可以将stream的参数修改为文件流,后面的参数就和printf一样,printf本身的输出流是标准输出流stdout,输出到屏幕上的,所以我们就将文件想象成正常使用printf将数据输出到屏幕,其他参数就和printf一样。
如果这样的话,那fprintf可以做到和printf等价:
int main() { char c = 'a'; int a = 10; char str[] = "hello world"; printf("%c %d %s",c,a,str); 等价于 fprintf(stdout,"%c %d %s",c,a,str); return 0; }fprintf的使用:
#include struct S { int n; float f; char arr[20]; }; int main() { struct S s = { 100, 3.14f, "zhangsan" }; FILE* pf = fopen("test.txt", "w"); if (pf == NULL) { perror("fopen"); return; } fprintf(pf, "%d %f %s", s.n, s.f, s.arr); fclose(pf); pf = NULL; return 0; }5.1.6 fscanf的使用
fscanf和scanf的参数也是相似的,就像fprintf和printf一样:
int fscanf(FILE* stream, const char* format,...); int scanf(const char* format,...);
fscanf的使用:
#include struct S { int n; float f; char arr[20]; }; int main() { struct S c = { 0 }; FILE* pf = fopen("test.txt", "r"); if (pf == NULL) { perror("fopen"); return; } fscanf(pf, "%d %f %s", &(c.n), &(c.f), c.arr);//输出到变量c中 printf("%d %f %s", c.n, c.f, c.arr); fclose(pf); pf = NULL; return 0; }5.1.7 fwrite的使用
fwrite函数声明:
size_t fwrite(const void* ptr, size_t size, size_t count, FILE* stream);
fwrite函数:参数1:ptr是一个const void* 的指针,是可以处理任意类型的数据的地址,不管是整型、浮点型还是结构体类型的地址都可以接收。参数2:size是类型大小,单位是字节。参数3:count是类型变量的个数。参数4:stream必须是文件的流,不能是其他流。
fwrite函数功能:通过参数1的指针将指针指向的count个数量的size类型大小的二进制数据输出到stream流。简单来说就是将数据在内存中的二进制数据传输进流。它的流只能是文件,不能是其他流,例如标准输出流。
#include struct S { int n; float f; char arr[20]; }; int main() { struct S s = { 200, 3.14f, "zhangsan" }; FILE* pf = fopen("C:\\Users\\linlu\\Desktop\\test.txt", "wb");//以二进制写的形式打开文件 if (pf == NULL) { perror("fopen"); return; } // //使用 fwrite(&s, sizeof(struct S), 1, pf);//以二进制的形式写入文件 // //关闭文件 fclose(pf); pf = NULL; return 0; }5.1.8 fread的使用
fread函数声明:
size_t fread(void* ptr, size_t size, size_t count, FILE* stream);
可以看到fread的函数声明和fwrite的函数声明是极其相似的。
fread函数和fwrite函数的区别:不同的就是前面那个void*的指针,fwrite是const修饰的,因为只是想读取它指向的空间里的数据并不想更改,所以使用了const。而fread是需要一个指针,通过这个指针指向的空间来接收读取的值,所以不能是const修饰。
fread函数:参数1:ptr是一个void* 的指针,是可以处理任意类型的数据的地址,不管是整型、浮点型还是结构体类型的地址都可以接收。参数2:size是类型大小,单位是字节。参数3:count是类型变量的个数。参数4:stream必须是文件的流,不能是其他流。
fread函数功能:通过seteam文件流将文件中的count个数量的size类型大小的二进制数据输入到ptr中。简单来说就是将文件中的二进制数据输入到ptr空间。它的流只能是文件,不能是其他流,例如标准输出流。
#include struct S { int n; float f; char arr[20]; }; int main() { struct S s = { 200, 3.14f, "zhangsan" }; FILE* pf = fopen("C:\\Users\\linlu\\Desktop\\test.txt", "rb");//打开文件 if (pf == NULL) { perror("fopen"); return; } // //使用 struct S c = { 0 }; fread(&c, sizeof(struct S), 1, pf);//将文件中二进制的数据读取出来 printf("%d %f %s", c.n, c.f, c.arr); // //关闭文件 fclose(pf); pf = NULL; return 0; }5.2 对比一组函数:
scanf / fscanf / sscanf
printf / fprintf / sprintf
- scanf - 针对标准输入流(stdin)的格式化输入函数
- printf - 针对标准输出流(stdout)的格式化输出函数
- fscanf - 针对所有输入流的格式化输入函数
- fprintf - 针对所有输出流的格式化输出函数
那sscanf和sprintf两个函数是干什么的呢?
sprintf的函数声明:
int sprintf(char* str, const char* format,...)
可以从参数上发现sprintf就比printf多了一个char*类型的参数,那具体功能是什么?
sprintf函数功能:将格式化数据输出到字符串中
sprintf和printf的区别:printf是将格式化数据输出到标准输出流也就是屏幕上,sprintf则是将格式化数据输出到一个字符串里
#include struct S { int n; float f; char arr[20]; }; int main() { struct S s = { 200, 3.14f, "zhangsan" }; char arr[30] = { 0 }; sprintf(arr, "%d %f %s", s.n, s.f, s.arr);//将格式化数据输出到字符串arr printf("%s\n", arr);//打印arr接收到的格式化数据 return 0; }既然可以使用sprintf函数将格式化数据输出到字符串中,那我们是否可以使用sscanf函数将字符串中的格式化数据提取出来呢?答案是可以的。
sscanf函数声明:
int sscanf(char* str, const char* format,...);
sscanf函数功能:将字符串中的格式化数据读取出来
sscanf和scanf的区别:scanf是将格式化数据输入到标准输入流也就是屏幕上,sscanf则是将格式化数据从字符串里读取出来。
#inlcude struct S { int n; float f; char arr[20]; }; int main() { //将格式化的数据输出到字符串数组arr中 struct S s = { 200, 3.14f, "zhangsan" }; char arr[30] = { 0 }; sprintf(arr, "%d %.2f %s", s.n, s.f, s.arr);//将格式化数据输出到字符串arr printf("%s\n", arr); //从arr这个字符串中读取出格式化的数据 struct S c = { 0 }; sscanf(arr, "%d %f %s", &c.n, &c.f, c.arr); printf("%d %f %s", c.n, c.f, c.arr); return 0; }6、文件的随机读写
什么是文件的随机读写?文件的随机读写就是定位到我们想要的位置开始向后读写,从开头向后读写就是顺序读写。定位位置向后读写就是随机读写。
6.1 fseek
int fseek(FILE* stream, long int offset, int origin);
fseek函数:参数1就是stream文件的流。参数2offset就是偏移量,是某个位置开始的向后的偏移量处的位置开始向后读写。而参数三origin就是决定这某个位置。
参数3:origin有三种位置:
Contstant Reference position SEEK_SET Beginning of file (文件的起始位置) SEEK_CUR Current position of the file pointer(文件指针的当前位置) SEEK_END End of file(从文件的末尾位置向前偏移) 是从这些位置开始向后计算偏移量的位置,从计算好偏移量的位置开始向后读取。
例子:
#include int main() { FILE* pf = fopen("C:\\Users\\linlu\\Desktop\\test.txt", "r"); if (pf == NULL) { perror("fopen"); return; } // //使用 fseek(pf, 6, SEEK_SET);//文件指针位置:起始位置向后偏移6个偏移量位置 int ch = fgetc(pf);//读取当前文件指针位置的字符 printf("%c", ch); fseek(pf, -3, SEEK_END);//文件指针位置:文件末尾向前偏移3个偏移量位置 int ch = fgetc(pf);//读取当前文件指针位置的字符 printf("%c", ch); fseek(pf, 5, SEEK_CUR);//文件指针位置:当前文件指针位置向后偏移5个偏移量位置 int ch = fgetc(pf);//读取当前文件指针位置的字符 printf("%c", ch); // //关闭文件 fclose(pf); pf = NULL; return 0; }文件里是存在文件指针的,正常情况下调用一次后该文件指针会向后指向,下一次调用是从后面继续向后访问。顺序读写函数是这样的。而随机读写函数是可以随机改变文件指针的指向,让文件指针改变位置从而进行读取或写入。
注:
1. 文件指针并不是我们熟知的C语言指针,而是一个表示文件位置的指针。
2. 偏移量为负数是向前偏移,偏移量为整数是向后偏移。
3. 不管文件指针的位置如何改变,文件都是自动的从前向后访问
6.2 ftell
ftell的函数声明:
long int ftell(FILE* stream);
如果我们不知道当前的文件初始位置与文件指针之间的偏移量是多少时我们就可以使用ftell库函数,这个函数会计算好文件指针的偏移量并返回。
例子:
#include int main() { FILE* pf = fopen("C:\\Users\\linlu\\Desktop\\test.txt", "r"); if (pf == NULL) { perror("fopen"); return; } // fseek(pf, -3, SEEK_END);//文件指针位置:文件末尾向前偏移3个偏移量位置 int ch = fgetc(pf);//读取当前文件指针位置的字符 printf("%c\n", ch); int ret = stell(pf);//计算当前偏移量 printf("%d\n",ret); // //关闭文件 fclose(pf); pf = NULL; return 0; }6.3 rewind
让文件指针的位置回到文件的起始位置
比如我随意用fseek来设置文件指针的位置导致乱了套,这时我们就可以使用rewind来让文件指针回到起始位置,功能比较简单,容易理解。
void rewind(FILE* stream);
例子:
#include int main() { FILE* pf = fopen("C:\\Users\\linlu\\Desktop\\test.txt", "r"); if (pf == NULL) { perror("fopen"); return; } // fseek(pf, -3, SEEK_END);//文件指针位置:文件末尾向前偏移3个偏移量位置 int ch = fgetc(pf);//读取当前文件指针位置的字符 printf("%c\n", ch); //不知道当前文件指针的位置就重置 rewind(pf);//重置文件指针位置 int ch = fgetc(pf);//读取起始位置字符 printf("%c\n",ch); // //关闭文件 fclose(pf); pf = NULL; return 0; }7、文件读取结束的判定
7.1 被错误使用的feof
牢记:在文件读取过程中,不能用 feof 函数的返回值直接来判断文件是否结束。
feof 的作用是:当文件读取结束的时候,判断是读取结束的原因是否是:遇到文件尾结束。
文件读取结束有两种原因:
1. 文件遇到末尾了
2. 文件读取错误了
1. 文本文件读取是否结束,判断返回值是否为EOF(fgetc的错误),或者是NULL(gets的错误)
例如:
- fgetc判断是否为EOF
- fgets判断是否问NULL
2. 二进制文件的读取结束判断,判断返回值是否小于实际要读的个数。
例如:
- fread判断返回值是否小于实际要读的个数
注:fread的返回值是读取到的元素的个数。
7.2 ferror
feof是判断文件是否是因为读取到文件末尾而结束的,而ferror则是判断是否是因为读取失败而结束的,如果读取失败结束就返回1.
int ferror(FILE* stream);
文本文件读取结束判断:
#include #include int main() { int ch = 0; FILE* pf = fopen("C:\\Users\\linlu\\Desktop\\test.txt", "r"); if (pf == NULL) { perror("fopen"); return; } // while (ch = fgetc(pf) != EOF) { printf("%c ", ch); } printf("\n"); //判断是什么原因结束的 if (ferror(pf))//判断是否是读取失败导致结束的 { puts("1/0 error when reading"); } else if (feof(pf))//判断是否是读取到文件末尾结束的 { printf("End of file reached successfully"); } // //关闭文件 fclose(pf); pf = NULL; return 0; }二进制文件的例子:
#include int main() { double a[5] = { 1.0, 2.0, 3.0, 4.0, 5.0 }; FILE* pf = fopen("test.bin", "wb");//以输出二进制的形式打开 fwrite(a, sizeof *a, 5, pf); fclose(pf); // double b[5]; pf = fopen("test.bin", "rb");//以读取二进制的形式打开 size_t ret_code = fread(b, sizeof *b, 5, pf); if (ret_code == 5){ puts("Array read successfully,contents: "); for (int n = 0; n8、文件缓冲区
ANSIC 标准规定采用 "缓冲文件系统" 处理数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序中每一个正在使用的文件开辟一块 "文件缓冲区" ,从内存中向磁盘输出数据会先送到内存中的缓冲区,装满缓冲区后才一起送到磁盘上,如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译器系统决定的。
#include #include int main() { FILE* pf = fopen("test.txt", "w"); fputs("abcdef", pf); printf("睡眠10秒-已经写数据了,打开test.txt文件,发现文件没有内容\n"); Sleep(10000); printf("刷新缓冲区\n"); fflush(pf);//刷新缓冲区的函数,才将输出缓冲区的数据写到文件(磁盘) //注: fflush 函数在高版本的VS不能使用了 printf("再睡眠10秒-此时再打开test.txt文件,发现文件有内容了\n"); Sleep(10000); fclose(pf); //注:fclose关闭文件时,也会刷新缓冲区 pf = NULL; return 0; }这里可以得出一个结论:
因为有缓冲区的存在,C语言在操作文件的时候,需要做刷新缓冲区或者在文件操作结束时关闭文件,如果不做,可能导致读写文件问题。
第十六章:编译和链接
1、翻译环境和运行环境
在ANSI C的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行机器指令(二进制指令)
第2种是执行环境,它用于实际执行代码
2、翻译环境
那翻译环境是怎么将源代码转换为可执行的机器指令的呢?这里我们就得展开讲解一下翻译环境所做的事情。
其实翻译环境是由编译和链接两个大的过程组成的,而编译又可以分解成:预处理(预编译)、编译、汇编三个过程。
一个C语言的项目中可能有多个.c文件一起构建,那么多个.c文件如何生成可执行程序呢?
- 多个.c文件单独经过编译处理生产对应的目标文件(.obj)
- 注:在Windows环境下的目标文件的后缀是.obj,Linux环境下目标文件的后缀是.o
- 多个目标文件(.obj)和链接库一起经过链接器的处理生成最终的可执行程序
- 链接库是指运行时库(它是支持程序运行的基本函数集合)或者第三方库
什么是链接库?
在学习C语言时我们经常会用到库函数,比如printf、scanf,这些函数肯定不是凭空出现的,像这样的库函数是被编译成一个一个的链接库,这些函数都包含在这个链接库中也就是第三方库,是C编译器厂商自己提供的库来供我们使用。在我们的C程序中会用到库函数,但是必须经过链接器目标文件和链接库一起链接才能调用该库函数。
举个例子:比如有一条河,两个人想见面,一个人(程序)在河的这边,而另一个人(库函数实现)在河的那边,那两人想见面(调用)是不是必须搭一座桥,而这座桥就是链接器。
其实还可以把编译器的编译展开成3个过程,那就变成了下面的过程:
Linux环境下:
2.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.2 编译
编译过程就是将预处理后的文件进行一系列:词法分析、语法分析、语义分析、符号汇总(链接阶段会讲一下符号汇总有什么用)及优化,生成相应的汇编指令。简单来说编译过程就是将C语言代码转换成汇编代码。
编译过程的命令如下:
gcc -S test.i -o test.s
对下面代码进行编译的时候,会怎么做呢?假设有下面的代码:
array[index] = (index + 4) * (2 + 6);
2.2.1 词法分析
将源代码程序输入扫描器,扫描器的任务就是简单的进行词法分析,把代码中的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等)。
上面代码进行词法分析后得到了16个记号:
记号 类型 array 标识符 [ 左方括号 index 标识符 ] 右方括号 = 赋值 ( 左圆括号 index 标识符 + 加号 4 数字 ) 右圆括号 * 乘号 ( 左圆括号 2 数字 + 加号 6 数字 ) 右圆括号 2.2.2 语法分析
接下来语法分析器,将对扫描产生的记号进行语法分析,从而产生语法树。这些语法树是以表达式为节点的树
2.2.3 语义分析
由语义分析器来完成语义分析,即对表达是的语法层面分析,编译器所能做的分析是语义的静态分析,静态语义分析通常包括声明和类型匹配,类型的转换等。这个阶段会报告错误的语法信息。
以上步骤完成后,最后就是将代码生成汇编指令,然后编译阶段就完成了。
2.3 汇编
汇编器是将汇编代码转变(翻译)为可执行的二进制指令,每一个汇编语言几乎都对应一条机器指令。就是根据汇编指令和机器指令的对照表一一的进行翻译,也不做指令优化。
注:这个过程还会形成符号表,是根据编译过程的符号汇总生成符号表的。
汇编的命令如下:
gcc -c test.s -o test.o
2.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符号表
符号 地址 g_val 0x100 Add 0x200 test.obj符号表
符号 地址 Add 0x000(无效的地址) g_val 0x000(无效的地址) main 0x300 注:以上地址是自己填上去的,真正的地址不是这样,只是举个例子使用
test.c里的符号Add和g_val由于是外部声明符号,并不知道符号真实地址,所以形成符号表时就给个无效地址。
链接过程中这些符号表是要进行合并的,多个目标文件都是一个项目的,没必要那么多符号表,所以只需将多个文件的符号表合成一个就够了。
add.obj和test.obj经过链接合并成的符号表:
符号 地址 Add 0x200 g_val 0x100 main 0x300 因为合并时找到了符号本身的有效地址,多以合并时将无效地址替换掉了,最终两个文件的符号表合并在了一起,运行时便可以通过该符号表的地址找到对应符号并调用。
而合并符号表过程中将test.obj符号表中Add符号的无效地址或g_val符号的无效地址替换掉就叫做符号的决议和重定位。
总结:
多个文件之间相互调用首先需要在编译阶段进行符号汇总,然后汇编阶段将汇总出的符号形成符号表,符号表中的每个符号都分配有对应地址。最后在链接阶段将多个目标文件的符号表进行符号表合并,至此多个文件的符号都有了联系,一个文件如果想调用另一个文件的函数就可以通过符号表的地址找到该函数并调用。
3、运行环境
1. 程序必须载入内存中,在有操作系统的环境中,一般这个由操作系统完成,程序的载入必须要手工安排,也可能是通过可执行代码置入只读内存完成。
2. 程序的指向便开始,接着便调用main函数。
3. 开始执行程序代码,这个时候程序将使用一个运行时堆栈(stack(函数栈帧)),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留它们的值。
4. 终止程序,正常终止main函数;也有可能是意外终止。
第十七章:预处理
1、预定义符号
C语言设置了一些预定义符号,可以直接使用。预定义符号也是在预处理期间处理的。
__FILE__ //进行编译的源文件 __LINE__ //文件当前的行号 __DATE__ //文件被编译的日期 __TIME__ //文件被编译的时间 __STDC__ //如果编译器遵循ANSI C,其值为1, 否则未定义
举个例子:
#include int main() { printf("进行编译的源文件:%s\n", __FILE__); printf("当前文件的行号:%d\n", __LINE__); printf("当前文件编译日期:%s\n", __DATE__); printf("当前文件编译时间:%s\n", __TIME__); return 0; }运行:
__STDE__只有在编译器遵循ANSI C时才为1,但是VS使用该标识符是未定义的,说明VS并不支持ANSI C
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定义标识符的时候,要不要在最后加上;?
比如:
#define MAX 1000; #define MAX 1000
建议不要加上;,这样容易导致问题
比如下面场景:
if(condition) max = MAX; else max = 0;如果是加上了分号的情况,等替换后,if和else之间就是2条语句,而没有大括号的时候,if后边只能有一条语句。这里会出现语法错误。
替换后 if(condition) max = 1000;; else max = 0;
因为1000后面多出了一个;,而多出的这个;会被当做一条空语句,看似一条语句,实则两条语句,所以使用时一定要注意#define定义的标识符后面尽量不加分号。
总结:#define定义标识符的后面的可以是常量、字符、浮点数、字符串、关键字或一段代码等...
3、#define定义宏
define不止可以定义常量,还可以定义宏。
#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(define macro)。
下面是宏的申明方式:
#define name(parament-list) stuff
其中的parament-list是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意:
参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
那宏怎么使用呢?举个例子:
#include #define SQAURE(X) X*X//假设我要计算一个数的平方而使用define定义一个宏 int main() { int a = 5; printf("%d\n", SQAURE(a));//传一个参数过去当经过预处理阶段时会替换成我们定义的表达式 return 0; }这样来看是不是感觉宏和函数的使用方式有一些相似。
其实宏的计算和函数有点不一样的是将参数传给宏,并不是在宏里完成表达式计算返回值,而是在预处理阶段将调用宏的地方替换成宏定义的表达式。
例如:
#include int main() { int a = 5; printf("%d\n", a*a);//预处理阶段,展开#define定义并替换 return 0; }警告:
这个宏存在一个问题:
观察下面的代码段:
#include #define SQAURE(X) X*X//假设我要计算一个数的平方而使用define定义一个宏 int main() { int a = 5; printf("%d\n", SQAURE(a+2));//传一个参数过去当经过预处理阶段时会替换成我们定义的表达式 return 0; }我们想象的结果是a+2也就是7的开平方49,实际上结果是17,为什么?
因为我们给宏传参传表达式并不是计算完成后在计算宏,而是在预处理阶段直接将我们传参的表达式替换到宏定义的表达式。
例如:
#include int main() { int a = 5; printf("%d\n", 7*7);//我们想象的 printf("%d\n",a+2*a+2);//预处理阶段实际做的 return 0; }解决方法:所以我们使用宏时一定要注意,当定义宏的表达式时一定要用括号将表达式中的参数单个括起来,说不定这个参数本身也是一个表达式。
比如:
#include #define SQAURE(X) ((X)+(X))//宏的整体也括一下 //因为调用宏的位置说不定是在某表达式中调用,因为操作符优先级导致计算顺序并不能达到我们的预期 int main() { int a = 5; printf("%d\n", 2*SQAURE(a+2)); printf("%d\n",2*((a+2)+(a+2)));//预处理阶段替换 return 0; }记得把宏定义表达式整体也括一下,这样才能保证先运算宏定义的表达式。
4、带有副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用表达式求值的时候出现的永久性效果。
例如:
x+1; //不带副作用 x++; //带副作用
什么是带有副作用的表达式呢?就是我想解决一件问题,但却因此留下了另一个问题。就比如我感冒了,我开了点感冒药。吃完感冒药后感冒是好了但是胃又因此不舒服了,这就是副作用。
例如:
#include int main() { int a = 10; int b = ++a;//我想得到a+1的值11,使用++a是得到了11但是因此a也发生了改变 printf("a=%d b=%d\n", a, b);//结果:11,11 return 0; }这就是带有副作用的表达式。
那如果宏参数是带有副作用的表达式会发生什么呢?
举个例子:
#include #define MAX(a, b) ((a)>(b)?(a):(b)); int main() { int a = 15; int b = 9; int m = MAX(a++,b++); printf("m=%d\n", m); printf("a=%d b=%d\n", a, b);//再猜一下a和b的值是多少 return 0; }最后的结果是什么呢?
运行结果:m=16, a=17, b = 10
为什么?看下面解析:
#include int main() { int a = 15; int b = 9; int m = ((a++)>(b++)?(a++):(b++));//预处理替换后 printf("m=%d\n", m); printf("a=%d b=%d\n", a, b); return 0; }代码解析:首先判断(a++)>(b++),此时是转换成15>9来进行判断的,因为先使用后++,当15>9成立,该表达式就返回a++,此时a是16,因为先使用后++就先返回16,m就拿到了16,所以m=16,然后a++就是17,前后a++了两次,b++了一次,所以a=17, b=10
总结:
1、宏的参数是如果是表达式,不会计算的。和函数相反,函数是先将表达式参数进行运算,将运算结果作为参数传参。
2、宏是直接将参数原封不动的替换到宏定义的表达式中的。
宏的参数是不参与计算的,当我们给宏的参数传递一个表达式时,并不是将表达式计算结果进行计算,而是在预处理阶段直接将表达式参数替换到宏定义的表达式,然后再替换到调用宏的位置。
5、宏的替换规则
在程序中扩展#define定义符号和宏是,需要涉及几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号,如果是,它们首先被替换。
2. 替换文本后被插入到程序中原来文本的位置,对于宏,参数名被他们的值所替换。
3. 最后,再次对结果文件进行扫描,看看它是否包含任何由#define的符号。如果是,就重复上述处理过程。
注意:
1. 宏参数和#define定义中出现其他#define定义的符号。但是对于宏,不能出现递归。
2. 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。
6、宏和函数的对比
宏通常被应用于执行简单的运算。
比如在两个数中找出较大的一个时,写成下面的宏,更有优势一些。
#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));宏和函数的一个对比:
属性 #define定义宏 函数 代码长度 每次使用时,宏代码都会被插入到程序中。
除非非常小的宏之外,程序的长度会大幅度
增长
函数代码只出现于一个地方;每次使用函
数时,都调用那个地方的同一份代码
执行速度 更快 存在函数的调用和返回的额外开销,所以
相对慢一些
操作符优先级 宏参数的求值是在所有周围表达式的上下文
环境里,除非加上括号,否则邻近操作符的
优先级可能会产生不可预料的后果,所以建
议宏在书写的时候多写括号
函数参数只在函数调用的时候求值一次,
它的结果值传递给函数。表达式的求值
结果更容易预测
带有副作用的参数 参数可能被替换到宏体中的多个为止,如
果宏的参数被多次计算,带有副作用的参数
可能会产生不可预料的结果
函数参数只在传参的时候求值一次,结果
更容易控制
参数类型 宏的参数与类型无关,只要对参数的操作是
合法的,他就可以使用任何参数类型
函数的参数是与类型有关的,如果参数的
类型不同,就需要不同的函数,即使他们
执行的任务是不同的。
调试 宏是不方便调试的 函数是可以逐语句调试的
递归 宏是不可以递归的 函数是可以递归的 那什么时候该有宏,什么时候该有函数呢?
- 如果计算逻辑比较简单就可以使用宏。
- 如果计算逻辑比较复杂就可以使用函数。
7、#和##
7.1 #运算符
#既不是#include或#define中的#,又不是+、-、*、/中的运算符。#是预处理中的一种运算符。
#运算符将宏的一个参数转换为字符串字面量,它仅允许出现在带参数的宏的替换列表中,#运算符所执行的操作可以理解为"字符串化"。
printf的特性:
这里首先要了解一下printf函数还有一个特性,就是当我们给printf传两个或多个字符串时,printf会自动将它们参数合并成一个字符串并输出,举个例子:
可以看到第二次调用printf将"hello" "world\n",分成两个字符串,可是printf自动将这两个字符串合并成一个。
知道了printf的这个特性我们就可以继续向下学习。
知道了#运算符可以在宏体中将宏的参数转换成字符串,我们就可以写下面这样代码:
#include #define Print(n, format)\ printf("the value of " #n " is " format "\n", n) int main() { char c = 'a'; Print(c, "%c"); printf("the value of" "c" "is" "%c" "\n", c);//预处理阶段替换后 int n = 10; Print(n, "%d"); printf("the value of" "n" "is" "%d" "\n", n);//预处理阶段替换后 float f = 3.14f; Print(f, "%f"); printf("the value of" "f" "is" "%f" "\n", f);//预处理阶段替换后 return 0; }运行结果:
因为#运算符修饰的参数本来就是"字符串化",如果n是变量c那#n就"c",如果n是变量a,那#n就是"a",如果n是变量f,那#n就是"f",所以"#n"经过预处理阶段就会替换为" "a" ",所以不需要再"#n"的套一层字符串。
7.2 ## 运算符
## 可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符,## 被称为记号粘合这样的连接必须产生一个合法的标识符,否则器结果就是未定义的。
这里我们想想,写一个函数求2个数的较大值的时候,不同的数据类型就得写不同的函数。
比如:
int int_max(int x, int y) { return x>y?x:y; } float float_max(int x, int y) { return x>y?x:y; }但是这样写起来太繁琐了,现在我们这样写代码试试:
//宏定义 #define GENERIC_MAX(type) \ type type##_max(type x, type y)\ { \ return (x>y?x:y); \ } \使用宏,定义不同类型
//预处理前的程序格式 #define GENERIC_MAX(type)\ type type##_max(type x, type y)\ {\ return (x>y?x:y);\ } //下面两行代码是使用宏定义两个自定义函数 GENERIC_MAX(int) GENERIC_MAX(float) int main() { int a = 10; int b = 20; int ret = int_max(a, b); printf("%d\n", ret); float c = 11.1f; float d = 22.2f; float fret = float_max(c, d); printf("%.2f\n", fret); return 0; } //预处理后的程序格式 int int_max(int x, int y) { return (x>y?x:y); } float float_max(float x, float y) { return (x>y?x:y); } int main() { int a = 10; int b = 20; int ret = int_max(a, b); printf("%d\n", ret); float c = 11.1f; float d = 22.2f; float fret = float_max(c, d); printf("%.2f\n", fret); return 0; }8、命名约定
一般来讲函数和宏的使用语法很相似。所以语言本身没法帮我们区分二者
那我们平时的一个习惯是:
把宏名全部大写
函数名不用全部大写
9、#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; }10、命令行定义
许多C 的编译器提供了一种能力,允许在命令行中定义符号。用于启动编译过程。
例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处(假设某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另一个机器内存大些,我们需要一个数组能够大些)
注:VS是不支持命令行定义的,只能在gcc下观察。
#include int main() { int array[ARRAY_SIZE];//ARRAY_SIZE可以在源文件中定义,也可以在命令行中输入命令来定义 int i = 0; for (i = 0; i编译指令:
//Linux 环境演示 gcc -D ARRAY_SIZE 10 programe.c //-D是定义命令,后面定义一个标识符,再在标识符后面输入一个值 //ARRAY_SIZE是标识符 100是标识符的常量 //programe.c是当前源文件的文件名
11、条件编译
在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。
注:条件编译后的只能是常量或常量表达式来进行判断,不能使用变量来进行判断。
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。
满足条件,就编译
不满足条件,就放弃编译
常见的条件编译指令:
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 #endif11.1 条件编译
那我们是怎么使用的呢?看下面代码:
#include #define flag 1 int main() { //flag = 1 #if flag printf("hello world---1\n"); #endif //!flag = 0 #if !flag printf("hello world---2\n"); #endif return 0; }运行结果:
if 和 #if 的区别:
如果if判断为真则执行if中的语句,if为假则不执行if中的语句。
如果#if判断为真预处理阶段就保留#if中的语句,如果为假,则在预处理阶段删除#if中的语句。
所以上面代码经过预处理后是这样的:
int main() { printf("hello world---1\n"); return 0; }因为只有第一条#if判断为真,所以这条语句被保留了下来,第二条#if判断为假,则删除语句。
注:条件编译完最后记得加上一条#endif来表示条件编译结束。
11.2 多分支条件编译
多分支条件编译不管有多少条编译总归得执行一条,例如:
#include #define flag 17 int main() { #if flag%3 == 1 printf("flag取模3的余数为1\n"); #elif flag%3 == 2 printf("flag取模3的余数为2\n"); #else printf("flag取模3的余数为0\n"); #endif return 0; }运行结果:
和if、else if、else的使用方法相似,只是功能不一样。
所以这条代码经过预处理后是这个格式:
int main() { printf("flag取模3的余数为2\n"); return 0; }11.3 判断是否被定义
#ifdef或#if defined()就是判断该标识符符有没有定义,定义了就保留这条语句,未定义就删除语句。而#ifndef或#if !defined()判断该标识符没有定义就保留语句,定义了就删除语句。
#include #define MAX 100 int main() { #ifdef MAX printf("MAX标识符已定义\n"); #endif //等价 #if defined(MAX) printf("MAX标识符已定义\n"); #endif //......... #ifndef MAX printf("MAX标识符未定义\n"); #endif //等价 #if !defined(MAX) printf("MAX标识符未定义\n"); #endif return 0; }运行结果:
所以这条代码经过预处理后是这个格式:
int main() { printf("MAX标识符已定义\n"); return 0; }12、头文件的包含
12.1 头文件被包含方式
12.1.1 本地文件包含
#include "filename.h"
查找策略:先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。
如果找不到就提示编译错误。
Linux环境的标准头文件路径:
/usr/include
VS环境的标准头文件路径:
c:\Program Files (x86)\Microsoft Visual Studio 12.0\VC\include //这是VS2013的默认路径
注意按照自己的安装路径去找。
12.1.2 库文件包含
#include
查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
那是不是可以说,对于库文件也可以使用" "的形式包含?
#include "stdio.h"
答案是肯定的,可以,但是这样查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。
12.2 嵌套文件包含
我们已经知道,#include 指令可以使另一个文件被编译。就像它实际出现于#include 指令的地方一样。
这种替换的方式很简单:预处理器先删除这条指令,并用包含文件的内容替换。
一个头文件被包含10次,那就实际被编译10次,如果重复包含,对编译的压力就比较大。
test.c源文件
#include "test.h" #include "test.h" #include "test.h" #include "test.h" #include "test.h" int main() { return 0; }test.h头文件
void test(); struct stu { int id; char name[20]; };如果直接这样写,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 和 #include "filename.h"有什么区别?
答:区别是所包含的头文件只寻找1次,而" "包含的头文件寻找2次。说明包含的头文件是标准库中的头文件,便会直接去标准库中寻找,找不到就编译错误,并不会额外花费时间去本地文件路径找。" "说明包含的头文件是本地文件,会先去本地文件路径下寻找,如果未找到就去标准库找,找了2次。
13、其他预处理指令
#error #pragma #line ... 不做介绍,可以自己去了解 #pragme pack()在结构体部分介绍过了
14、offsetof模拟实现
offsetof是宏定义,参数就给它一个结构体类型,然后再给一个成员名它就可以求出该成员在结构体类型中内存对齐的偏移量。偏移量就是结构体的起始地址和内存对齐后变量的地址之间的距离。单位是:字节(byte)。
假设我们把0作为结构体的起始地址,那其他的成员的地址就是偏移量。有了思路我们就可以模拟offsetof了:
#include #define OFFSETOF(type,mem) (size_t)&(((type*)0)->mem) //假设结构体的地址是0,通过0这个地址->找到成员取地址取出的就是偏移量。 //此时这个取出偏移量还是地址,将这个地址强制类型转换成(size_t)无符号整型。 struct S { char c1; int i; char c2; }; int main() { printf("%d\n", OFFSETOF(struct S, c1)); printf("%d\n", OFFSETOF(struct S, i)); printf("%d\n", OFFSETOF(struct S, c2)); return 0; }如果结构体的起始位置从0开始的话,那它成员的位置(地址)刚好就可以表示偏移量。
C99后
引入了一个概念:内联函数(inline)
内联函数:具有了函数的特点,也具有了宏的特点
函数的特点:参数、返回值
宏的特点:和宏一样,在调用内联函数的地方展开
学习C++时可以学到
到这里本篇C语言从入门到进阶博客结束了,欢迎大家在评论区留言,我们下一篇博客再见-
- fread判断返回值是否小于实际要读的个数
- 指针-指针不能是两个不同变量空间的地址相减,1.如果类型不同不确定是用哪个类型来表示元素个数的元素。2.就算类型一样两个地址相减也没有什么意义,答案也不对,因为两块不同的空间中间会有未开辟的内存空间隔开,谁知道未开辟的内存空间里有多少元素个数。
- 准确来说指针-指针求出的是以元素大小为单位的绝对值
- 指针-指针必须指向同一块空间,可以相互运算。因为如果是&arr[0]+9就是&arr[9],arr[9]-arr[0]就是9了,指针减指针也是看两个指针的类型求出它们之间的元素个数。
- 局部变量:在大括号内部创建的变量就是局部变量
- 全局变量:在大括号外部创建的变量就是全局变量











































































