c语言
编译与链接
C语言是一门编译型语言,C语言源代码都是文本文件,本身无法执行,必须通过编译器翻译和链接器的链接,生成二进制的可执行文件,才能执行。写出C语言代码后,是.c
为后缀的文件,想要得到最终允许的可执行文件,中间要经过编译和链接2个过程。
一个工程一般都会有多个源文件组成,如下图所示,演示了源程序经过编译器和链接器处理的过程。
注:
- 每个源文件(.c)单独经过编译器处理生成对应的目标文件(.obj为后缀的文件)
- 多个目标文件和库文件经过链接器处理生成对应的可执行程序(.exe文件)
C语言编译器
前面我们了解到C语言是一门编译型的计算机语言,需要依赖编译器将计算机语言转换成机器能够执行的机器指令。那我们常见的C语言编译器有哪些呢?
比如:msvc、clang、gcc可以直接使用,也有一些集成开发环境:VS、XCode、CodeBlocks、DevC++、Clion等
集成开发环境(IDE):用于提供程序开发环境的应用程序,一般包括代码编辑器、编译器、调试器和图形用户界面等工具。集成了代码编写功能、分析功能、编译功能、调试功能等一体化的开发软件服务套装。
• VS2022 集成了MSVC(安装包较⼤⼀些,安装简单,⽆需多余配置,使⽤起来⾮常⽅便)
• XCode 集成了clang(苹果笔记本上的开发⼯具)
• CodeBlocks 集成了gcc(这个⼯具⽐较⼩众,需要配置环境,不太推荐)
• DevC++ 集成了gcc(⼩巧,但是⼯具过于简单,对于代码⻛格的养成不好,⼀些竞赛使⽤)
• Clion 是默认使⽤CMake,编译器是可以配置的(⼯具是收费,所以暂时推荐⼤家使⽤)
VS使用小技巧
- 字体⼤⼩调整
按住键盘Ctrl键,滑动⿏标的滚轮
- 复制代码
直接将⿏标点击到某⼀⾏,不需要选中,直接Ctrl+c(复制),Ctrl+v(粘贴)。
- 注释快捷⽅式
默认情况下,在VS的⼯具栏就能看到,快捷⽅式,选中代码后,就可以给代码注释(Ctrl+k,
Ctrl+c),或者取消注释(Ctrl+k,Ctrl+u)。
编写第一个C语言程序
1 |
|
打开编译器,写入代码,执行
main函数是什么
每个C语言程序不管有多少行代码,都是从main函数开始执行的,main函数是程序的入口,main函数也被叫做:主函数。main
前面的int
表示main
函数执行结束的时候返回一个整数类型的值。所以在main
函数的最后写return 0;
正好前后呼应。
- main函数是程序的入口
- main函数有且仅有一个
- 即使一个工程中有多个.c文件,但是只能有一个main函数(因为程序的入口只能有一个)
库函数和printf
现在我们知道了main函数的作用,那么第四行的printf
是干什么的呢?
其实printf
是一个库函数,库函数是为了方便程序员写代码的。库函数是由C语言给出标准后,由编译器厂商提供的一组现成可直接使用的函数,这些函数一般是以静态库的方式提供的。
C语⾔中提供了⼀系列的库函数,在这⾥可以看⼀下,后期我们⼀点点的介绍。
参考链接:https://cplusplus.com/reference/clibrary/
库函数的使用,需要包含对应的头文件,比如printf
函数的使用,需要包含stdio.h
,所以你看代码的第一行,就是如下所示的头文件包含,#include
是预处理指令,<>
中是头文件的名字。
注意:库函数的使用一定要包含对应的头文件,否则可能出现错误。
1 |
printf 就是⼀个C语⾔编译器提供的⼀个打印信息的函数,我们可以使⽤函数打印我们各种类型的
数据,⽐如:
1 | printf("hello world"); |
main函数的多种写法
常见写法
写法1:这种写法做常⻅,也是我推荐的写法
1 | int main() |
写法2:这种写法, main 后边的括号中写 void 表⽰ main 函数不接受任何参数。
1 | int main(void) |
写法3:这种写法,⽐较少⻅,主要是给main后边的括号中写了2个参数,其实main函数是可以接受参数的,
写上参数但是也可以不使⽤,只有在实现⼀些命令⾏的功能时才会使⽤。初学的时候不建议这样写
法,太繁琐,等后期有基础了,再去探索。
1 | int main(int argc,char* argv[]) |
可以参考这个资料学习:https://zh.cppreference.com/w/c/language/main_function
旧式写法
在过去旧的C语⾔语法中和旧的书籍中,main函数也会有下⾯的写法,但是现在都不推荐了。当你看
到有书籍中按照下⾯的⽅式写,那这本书⼀定很⽼旧了。
1 | #include <stdio.h> |
数据类型
字符
char、[signed] char、unsigned char
整型
//短整型
short [int]、 [signed] short [int]、unsigned short [int]
//整型
int、[signed] int、unsigned int
//⻓整型
long [int]、[signed] long [int]、unsigned long [int]
//更⻓的整型
//C99中引⼊
long long [int]、[signed] long long [int]、unsigned long long [int]
因为数据是有正、负之分的,unsigned表示无符号的,signed表示有符号的。
signed修饰的变量中可以表示正数,也可以表示负数。
unsigned修饰的变量中只能表示正数。
浮点型
float、double、long double
布尔类型
_Bool
布尔类型的使用得包含头文件<stdbool.h>
布尔类型变量的取值是:true或false
1 |
变量
生活中有很多的变化的值,比如:年龄、体重、薪资等,如何描述变化的值呢?答案就是:变量
变量可以存放数值,存放的数值也可以根据需要修改的。
变量的创建
1 | // 类型 变量名 |
变量的名字要根据实际情况的需要,起一个有意义的名字。
变量命名的一般规则:
- 只能由字母(包括大写和小写)、数字和下划线(_)组成。
- 不能以数字开头。
- 长度不能超过63个字符。
- 变量名中区分大小写的。
- 变量名不能使用关键字。
初始化
如果变量在创建的同时,想给一个确定的值,这叫变量的初始化,如下:
1 | int age = 20; |
这些数据类型可以直接用来定义变量,变量就是可以改变的对象,请看下面的代码:
1 |
|
使用其他类型创建变量,并给一个初始值,这些变量的值也是可以修改的。
1 |
|
printf函数
基本用法
printf
的作用是将参数文本输出到屏幕。它名字里面的f
代表format
(格式化),表示可以定制输出文本的格式。
1 |
|
上⾯命令会在屏幕上输出⼀⾏⽂字“Hello World”。
printf
不会在行尾自动添加换行符,运行结束后,光标就停留在输出结束的地方,不会自动换行。
为了让光标移到下一行的开头,可以在输出文本的结尾,添加一个换行符\n
。
1 |
|
如果⽂本内部有换⾏,也是通过插⼊换⾏符来实现
printf()
是在标准库的头文件stdio.h
定义的。使用这个函数之前,必须在源码文件头部引入这个头文件。
占位符
printf
可以在输出文本中指定占位符。
所谓”占位符”,就是这个位置可以用其他值代入。
1 |
|
上面示例中,There are %d apples\n
是输出文本,里面的%d
就是占位符,表示这个位置要用其他值来替换。占位符的第一个字符一律为百分号%
,第二个字符表示占位符的类型,%d
表示这里代入的值必须是一个整数。
printf()
的第二个参数就是替换占位符的值,上面的例子就是整数3替换%d
。
常用的占位符除了%d
,还有%s
表示代入的是字符串。
输出⽂本⾥⾯可以使⽤多个占位符。
printf
参数与占位符是一一对应关系,如果有n
个占位符,printf
的参数就应该有n+1
个。如果参数个数少于对应的占位符,printf()
可能会输出内存中的任意值。
占位符的列举
printf()
的占位符有许多种类,与C语言的数据类型相对应。
- %a:十六进制浮点数,字母输出为小写。
- %A:十六进制浮点数,字母输出为大写。
- %c:字符。
- %d:十进制整数。
- %e:使用科学计数法的浮点数,指数部分的
e
为小写。 - %E:使用科学计数法的浮点数,指数部分的
E
为大写。 - %i:整数,基本等同于
%d
。 - %f:小数(包含
float
类型和double
类型)。 - %g:6个有效数字的浮点数。整数部分一旦超过6位,就会自动转为科学计数法,指数部分的
e
为小写。 - %G:等同于
%g
,唯一区别是指数部分的E
为大写。 - %hd:十进制short int类型。
- %ho:八进制short int类型。
- %hx:十六进制short int类型。
- %hu:unsigned short int类型。
- %ld:十进制long int类型。
- %lo:八进制long int类型。
- %lx:十六进制long int类型。
- %lu:unsigned long int类型。
- %lld:十进制long long int类型。
- %llo:八进制long long int类型。
- %llx:十六进制long long int类型。
- %llu:unsigned long long int类型。
- %Le:科学计数法表示的long double类型浮点数。
- %Lf:long double类型浮点数。
- %n:已输出的字符串数量。该占位符本身不输出,只将值存储在指定变量之中。
- %o:八进制整数。
- %p:指针。
- %s:字符串。
- %u:无符号整数(unsigned int)。
- %x:十六进制整数。
- %zd:
size_t
类型。 - %%:输出一个百分号。
输出格式
printf()
可以定制占位符的输出格式。
限定宽度
printf
允许限定占位符的最小宽度。
1 |
|
上⾯⽰例中, %5d 表⽰这个占位符的宽度⾄少为5位。如果不满5位,对应的值的前⾯会添加空格。
输出的值默认是右对齐,即输出内容前面会有空格;如果希望改成左对齐,在输出内容后面添加空格,可以在占位符的%
的后面添加一个-
号。
1 |
|
对于小数,这个限定符会限制所有数字的最小显示宽度。
1 |
|
上面示例中,%10f
表示输出的浮点数最少要占据10位。由于小数的默认显示精度是小数点后6位,所以3.0
的头部会输出两个空格,即为” 3.000000”,两个空格+八位小数
总是显示正负号
默认情况下,printf()
不对正数显示+
号,只对负数显示-
号。如果想让正数也输出+
号,可以在占位符的%
后面加一个+
。
1 |
|
限定小数位数
输出小数时,有时希望限定小数的位数。举例来说,希望小数点后只保留两位,占位符可以写成%.2f
。
与限定宽度占位符,结合使⽤。
1 |
|
上面的示例中,宽度为10,显示正号,保留两位小数,那么结果为 “ +3.00”
最小宽度和小数位数这两个限定值,都可以用*
来代替,通过printf()
的参数传入。
1 |
|
输出部分字符串
%s
占位符用来输出字符串,默认是全部输出。如果只想输出开头的部分,可以用%.[m]s
指定输出长度,其中[m]
代表一个数字,表示所要输出的长度。
1 |
|
scanf
当我们有了变量,我们需要给变量输入值就可以使用scanf
函数,如果需要将变量的值输出在屏幕上的时候可以使用printf
函数,下面看一个例子:
1 |
|
基本用法
scanf()
函数用于读取用户的键盘输入。
程序允许到这个语句时,会停下来,等待用户从键盘输入。
用户输入数据、按下回车键后,scanf()
就会处理用户的输入,将其存入变量。
它的原型定义在头文件stdio.h
。
scanf()
的语法与printf()
类似。
scanf(“%d”, &i);
它的第一个参数是一个格式字符串,里面会放置占位符(与printf()的占位符基本一致
),告诉编译器如何解读用户的输入,需要提取的数据是什么类型。
这是因为C语言的数据都是有类型的,scanf()
必须提前知道用户输入的数据类型,才能处理数据。
它的其余参数就是存放用户输入的变量,格式字符串里面有多少个占位符,就有多少个变量。
上面示例中,scanf()
的第一个参数%d
,表示用户输入的应该是一个整数。%d
就是一个占位符,%
是占位符的标志,d
表示整数。第二个参数&i
表示,将用户从键盘输入的整数存入变量i
。
注意:
变量前面必须加上&
运算符(指针变量除外),因为scanf()
传递的不是值,而是地址,即将变量i
的地址指向用户输入的值。
如果这里的变量是指针变量(比如字符串变量),那就不用加&
运算符。
下⾯是⼀次将键盘输⼊读⼊多个变量的例⼦。
scanf(“%d%d%f%f”, &i, &j, &x, &y);
上面示例中,格式字符串%d%d%f%f
,表示用户输入的前两个是整数,后两个是浮点数,比如1 -20 3.4 -4.0e3
。这四个值依次放入i、j、x、y四个变量。
scanf()
处理数值占位符时,会自动过滤空白字符,包括空格、制表符、换行符等。
所以,用户输入的数据之间,有一个或多个空格不影响scanf()
解读数据。另外,用户使用回车键,将输入分成几行,也不影响解读。
scanf()
处理用户输入的原理是,用户的输入先放到缓存,等到按下回车键后,按照占位符对缓存进行解读。
解读用户输入时,会从上一次解读遗留的第一个字符开始,直到读完缓存,或者遇到第一个不符合条件的字符为止。
1 |
|
上面示例中,scanf()
读取用户输入时,%d
占位符会忽略起首的空格,从-
处开始获取数据,读取到-13
停下来,因为后面的.
不属于整数的有效字符。这就是说,占位符%d
会读到-13
第二次调用scanf()
时,就会从上一次停止解读的地方,继续往下读取。这一次读取到的首字符是.
,由于对应的占位符是%f
,会读取到.45e12
,这是采用科学计数法的浮点数格式。后面的#
不属于浮点数的有效字符,所以会停在这里。
由于scanf()
可以连续处理多个占位符,所以上面的例子也可以写成下面这样。
1 |
|
scanf()
的返回值是一个整数,表示成功读取的变量个数。如果没有读取任何项,或者匹配失败,则返回0
。如果读取到文件结尾,则返回常量EOF。
1 |
|
占位符
scanf()
常用的占位符如下:与printf()
的占位符基本一致。
- %c:字符。
- %d:整数。
- %f:float类型浮点数。
- %lf:double类型浮点数。
- %Lf:long double类型浮点数。
- %s:字符串。
- %[]:在方括号中指定一组匹配的字符(比如
%[0-9]
),遇到不在集合之中的字符,匹配将会停止。
上面所有占位符之中,除了%c
以外,都会自动忽略起首的空白字符。%c
不忽略空白字符,总是返回当前第一个字符,无论该字符是否为空格。
如果要强制跳过字符前的空白字符,可以写成scanf(" %c",&ch)
,即%c
前加上一个空格,表示跳过零个或多个空白字符。
下面要**特别说一下占位符%s
**,它其实不能简单的等同于字符串。它的规则是,从当前第一个非空白字符开始读起,直到遇到空白字符(即空格、换行符、制表符等)为止;
因为%s
不会包含空白字符,所以无法用来读取多个单词,除非多个%s
一起使用。这也意味着,scanf()
不适合读取可能包含空格的字符串。另外,scanf()
遇到%s
占位符,会在字符串变量末尾存储一个空字符\0
。
scanf()
将字符串读入字符数组时,不会检测字符串是否超过了数组长度。所以,储存字符串时,可能会超出数组的边界。为了防止这种情况,使用%s
占位符时,应该指定读入字符串的最长长度,即写成%[m]s
,其中[m]
是一个整数,表示读取字符串的最大长度,后面的字符将被丢弃。
1 |
|
上⾯⽰例中, name 是⼀个⻓度为11的字符数组, scanf() 的占位符 %10s 表⽰最多读取⽤⼾输⼊
的10个字符,后⾯的字符将被丢弃,这样就不会有数组溢出的⻛险了。
赋值忽略符
有时,用户的输入可能不符合预定的格式。
1 |
|
当用户输入的格式与你的不同时,scanf()
解析数据就会失败。
为了避免这种情况,scanf()
提供了一个赋值忽略符(assignment suppression character)*
。
只要把*
加在任何占位符的百分号后面,该占位符就不会返回值,解析后将被丢弃。
1 |
|
VS上提⽰scanf函数不安全,怎么办
解决办法1:
在当前的.c⽂件的第⼀⾏,加上:
#define _CRT_SECURE_NO_WARNINGS 1
解决办法2:
每⼀个版本的VS安装后,电脑上都有⼀个⽂件叫: newc++file.cpp 的⽂件
找到这个⽂件,在这个⽂件中加⼊下⾯这句代码,以后新建的.c⽂件中⾃动就加⼊这句代码的
#define _CRT_SECURE_NO_WARNINGS 1
这里推荐方法2
getchar和putchar
getchar
getchar()
函数返回用户从键盘输入的一个字符,使用时不带有任何参数。
程序运行到这个命令就会暂停,等待用户从键盘输入,等同于使用scanf()
方法读取一个字符。
它的原型定义在头文件stdio.h
。
1 |
|
getchar()
不会忽略起首的空白字符,总是返回当前读取的第一个字符,无论是否为空格。
如果读取失败,返回常量EOF,由于EOF通常是-1
,所以返回值的类型要设为int,而不是char。
由于getchar()
返回读取的字符,所以可以用在循环条件之中。
下⾯的例⼦是统计某⼀⾏的字符⻓度。
1 |
|
上⾯⽰例中, getchar() 每读取⼀个字符,⻓度变量 len 就会加1,直到读取到换⾏符为⽌,这时 len 就是该⾏的字符⻓度。
putchar
putchar()
函数将它的参数字符输出到屏幕,等同于使用printf()
输出一个字符。它的原型定义在头文件stdio.h
。
1 |
|
操作成功时, putchar() 返回输出的字符,否则返回常量 EOF。
算术运算符
C语⾔中为了⽅便运算,提供了⼀系列操作符,其中有⼀组操作符叫:算术操作符,分别是:+ 、- 、*、\、%,都是双⽬操作符。
+和-
+
和-
用来完成加法和减法
1 |
|
*
运算符*
用来完成乘法
1 |
|
/
运算符/
用来完成除法
除号的两端如果是整数,得到的结果也是整数。
1 |
|
上面的示例中,尽管变量x
的类型是float
(浮点数),但是6 / 4
得到的结果是1.0
,而不是1.5
。原因是C语言中的整数除法是整除,只会返回整数部分,丢弃小数部分。
如果希望得到浮点数的结果,两个运算数必须至少有一个浮点数,这时就会进行浮点数计算了
1 |
|
%
运算符 % 表⽰求模运算,即返回两个整数相除的余值。这个运算符只能⽤于整数,不能⽤于浮点数。
1 |
|
负数求模的规则是,结果的正负号由第⼀个运算数的正负号决定。
1 |
|
++和–操作符
单目操作符:++、–
C语言中还有一些操作符只有一个操作数,被称为单目操作符
++、--
就是单目操作符
++操作符
++是一种自增1的操作符,又分为前置++和后置++
前置++
1 |
|
计算口诀:先+1,后使用
后置++
1 |
|
计算口诀:先使用,后+1
–与++同理
赋值操作符
赋值操作符的作用就是在需要的时候,给变量一个值,比如:
1 |
|
赋值操作符的功能比较单一,但是使用非常频繁,值得注意的就是,在C语言中=
是赋值操作符,==
是判断相等,这里要做区分。
复合赋值符
如果变量对自身的值进行算术运算,C语言提供了简写形式,允许将赋值运算符和算术运算符结合成一个运算符叫做复合赋值符
- +=
- -=
- *=
- /=
- %=
1 | i + = 3; // 等同于 i = i + 3 |
连续赋值
赋值操作符也可以连续赋值,如:
1 | int a = 3; |
块作用域和文件作用域
程序块
C语言中成对大括号构成代码叫程序块(也叫复合语句),代码如下:
1 |
|
作用域
**作用域(scope)**指的是变量生效的范围
C语言的变量作用域主要有两种:文件作用域(file scope)和块作用域(block scope)
块作用域
在程序块(复合语句)中声明的名称,只在该程序块中通用,在其他区域都无效。也就是说,变量的名称从变量声明的位置开始,到包含该声明的程序块最后的大括号,在这一区间内通用。这样的作用域称为块作用域。
块作用域指的是由大括号({}
)组成的代码块,它形成一个单独 作用域。凡是在块作用域里面声明的变量,只在当前代码块有效,代码块外部不可见。
块作用域一般针对的是局部变量。
代码块嵌套
1 |
|
代码块可以嵌套,即代码块内部还有代码块,这时就形成了多层的块作用域。
规则是:内部代码块可以使用外层声明的变量,但外层不可以使用内层声明的变量。如果内层的变量与外层同名,那么会在当前作用域覆盖外层变量。
–> 局部优先
for循环也是块作用域
最常见的块作用域就是函数,函数内部声明的变量,对于函数外部是不可见的。for
循环也是一个块作用域,循环变量只对循环体内部可见,外部是不可见的。
1 |
|
文件作用域
文件作用域(file scope)指的是在函数的外部声明的变量(全局变量),从声明的位置到文件结束都有效,通俗的讲就是全局变量是具有文件作用域的。
1 |
|
上面的示例中,变量x
是在所有的函数外定义的变量,是全局变量,从声明位置开始的整个当前文件都是它的作用域,可以在这个范围的任何地方读取整个变量,比如函数main()
内部可以读取整个变量。
全局变量是具有文件作用域的。
甚至全局变量,在其他源文件内部也是可以使用的。
C语言关键字
C语⾔中有⼀批保留的名字的符号,⽐如: int 、 if 、 return ,这些符号被称为保留字或者关键字,他们都有特殊的意义,是保留给C语⾔使⽤的,我们程序员⾃⼰在创建标识符的时候是不能和关键字重复的。
C语⾔的32个关键字如下:
1 | auto break case char const continue default do double else enum extern |
注:在C99标准中加⼊了inline、restrict、_Bool、_Comploex、_Imaginary等关键字。⼀些关键字
⼤家可以去了解⼀下,不过使⽤最多的还是上⾯的32个关键字。
注:https://zh.cppreference.com/w/c/keyword(C语⾔关键字的全部介绍)
这些关键字也可以分类
存储类型(4):
1 | atuo static register extern |
数据类型相关(14):
1 | char short int long float double signed unsigned struct union enum void sizeof |
控制语句相关(12):
1 | if else switch case default for while do break continue goto return |
说明符(2)
1 | const volatile |
sizeof
sizeof是C语言提供的一个运算符(操作符),也是一个关键字。
使用的形式有下面2种:
1 | sizeof(类型) |
sizeof
返回某种数据类型或某个值占用的字节数量,它的参数可以是数据类型的关键字,也可以是变量名或某个具体的值。
sizeof
不仅仅可以计算内置的类型的大小,计算数组、自定义类型的大小都是可以的。
1 |
|
说明:
- 整数类型的变量是4个字节,直接使用整型类型也是4个字节
sizeof
的括号中如果给的不是类型的花,括号可以省略的3.14
被编译器识别为double
类型,所以大小是8个字节3.14f
因为在3.14的后边加了f
,会被编译器识别为float
类型,是4个字节
sizeof的返回类型
sizeof
运算符的返回值,C语言只规定是无符号整数,并没有规定具体的类型,而是留给系统自己去决定,sizeof
到底返回什么类型。不同的系统中,返回值的类型有可能是unsigned int
,也有可能是unsigned long
,甚至是unsigned long long
,对应的printf()
占位符分别是%u %lu %llu
。这样不利于程序的可移植性。
C 语⾔提供了⼀个解决⽅法,创造了⼀个类型别名 size_t ,⽤来统⼀表⽰ sizeof 的返回值类型。
该别名定义在 stddef.h 头⽂件(引⼊ stdio.h 时会⾃动引⼊)⾥⾯,对应当前系统的 sizeof
的返回值类型,可能是 unsigned int ,也可能是 unsigned long 。
注:VS2022中 size_t 是定义在 vcruntime.h 中的,不同的编译器实现上略有差异的。
C语言还提供了一个常量SIZE_MAX
,表示size_t
可以表示的最大整数。
所以,size_t
能够表示的整数范围为[0,SIZE_MAX]
;
printf()
有专门的占位符%zd
或%zu
,用来处理size_t
类型的值。
1 |
|
上⾯代码中,不管 sizeof 返回值的类型是什么, %zd 占位符(或 %zu )都可以正确输出。
如果当前系统不⽀持 %zd 或 %zu ,可使⽤ %u (unsigned int)或 %lu (unsigned long int)代替。
signed和unsigned
C语言引入signed
和unsigned
关键字来修饰char
、short~
、int
、long
等整型。
使用signed
关键字,表示一个类型带有正负号,包含负值;
使用unsigned
关键字,表示该类型不带有正负号,只能表示零和正整数。
int类型
对于int
类型,默认是带有正负号的,也就是说int
等同于signed int
。
由于这是默认情况,关键字signed
一般都省略不写,但是写了也没问题。
1 | signed int a; |
int
类型可以不带正负号,只表示非负整数。这时就必须使用关键字unsigned
声明变量。
1 | unsigned int a; |
整数变量声明为unsigned
的好处是,同样长度的内存能够表示的最大整数值,增大了一倍。
比如,16位的signed short int
的取值范围是:-32768~32767,最大是32767;
而unsigned short int
的取值范围是:0~65535,最大值增大到了65535。
unsigned int ⾥⾯的 int 可以省略,所以上⾯的变量声明也可以写成下⾯这样。
1 | unsigned a; |
特殊的char类型
字符类型char
也可以设置signed
和unsigned
。
1 | signed char c; // 范围是 -128 - 127 |
注意,C语言规定char
类型默认是否带有正负号,由当前系统决定。
这就是说,char
不等同于signed char
,它可能是signed char
,也可能是unsigned char
。
这一点与int
不同,int
就是等同于signed int
。
注释
注释的表示方法
/**/的形式
第一种方法是将注释放在/**/
之间,内部可以分行
1 |
|
//的形式
第⼆种写法是将注释放在双斜杠 // 后⾯,从双斜杠到⾏尾都属于注释。这种注释只能是单⾏,可以放在⾏⾸,也可以放在⼀⾏语句的结尾。这是 C99 标准新增的语法。
1 |
|
不管是哪⼀种注释,都不能放在双引号⾥⾯。
双引号⾥⾯的注释符号,会成为字符串的⼀部分,解释为普通符号,失去注释作⽤。
C语言操作符介绍
C 语⾔的运算符(运算符)⾮常多,⼀共有 50 多种,可以分成若⼲类。
算术操作符: + - * / %
移位操作符: << >>
位操作符: & | ^
赋值操作符: += -= *= /= %= <<= >>= &= |= ^=
单⽬操作符:只有⼀个操作数
关系操作符: > >= < <= != ==
逻辑操作符: && ||条件操作符(三⽬操作符): ? : ,例如: x ? y : z
逗号操作符: , ,例如: a,b,c
下标引⽤: [] ,例如: a[b]
函数调⽤: () ,例如: fun()
结构成员: . -> ,例如: a.b a->b
1 | ! 逻辑反操作 |
操作符的优先级
优先级指的是,如果一个表达式包含多个运算符,哪个运算符应该优先执行。各种运算符的优先级是不一样的。
如果两个运算符的优先级相同,没办法确定哪一个了,这时候就看结合性了,根据运算符是左结合,还是右结合,决定执行顺序。大部分运算符是左结合(从左到右执行),少数运算符是右结合(从右到左执行),比如赋值运算符=
运算符优先级如下(按照优先级从低到高排列)
• 圆括号( () )
• ⾃增运算符( ++ ),⾃减运算符( – )
• ⼀元运算符( + 和 - )
• 乘法( * ),除法( / )
• 加法( + ),减法( - )
• 关系运算符( < 、 > 等)
• 赋值运算符( = )
C语言的语句分类
C语言的代码是由一条一条的语句构成的,C语言中的语句可分为以下五类:
- 空语句
- 表达式语句
- 函数调用语句
- 复合语句
- 控制语句
空语句
空语句是最简单的,一个分号就是一条语句,是空语句。
1 |
|
空语句,一般出现的地方是:这里需要一条语句,但是这个语句不需要做任何事,就可以写一个空语句
表达式语句
表达式语句就是在表达式的后边加上分号。如下所示:
1 |
|
函数调用语句
函数调用的时候,也会加上分号,就是函数调用语句
1 |
|
复合语句
复合语句其实就是前面讲过的代码块,成对括号中的代码就构成一个代码块,也被称为复合语句。
1 |
|
控制语句
控制语句用于控制程序的执行流程,以实现程序的各种结构方式(C语言支持三种结构:顺序结构、选择结构、循环结构),它们由特定的语句定义符组成,C语言由九种控制语句。
可分成以下三类:
- 条件判断语句也叫分支语句:if语句、switch语句;
- 循环执行语句:do while语句、while语句、for语句;
- 转向语句:break语句、goto语句、continue语句、return语句。
if和else
if语句的语法形式如下:
1 | if(表达式) |
表达式成立(为真),则语句执行,表达式不成立(为假),则语句不执行
在C语言中,0为假,非0表示真,也就是表达式的结果如果是0,则语句不执行,表达式的结果如果不是0,则语句执行。
1 |
|
else的语法,需要和if联用
1 | if (表达式) |
嵌套if
嵌套if直接展示语法
判断一个人的年龄是为0 or 大于0 or 小于0
1 |
|
练习:
- 输⼊⼀个⼈的年龄
- 如果年龄<18岁,打印少年
- 如果年龄在18岁⾄44岁打印⻘年
- 如果年龄在45岁⾄59岁打印中⽼年
- 如果年龄在60岁⾄89岁打印⽼年
- 如果90岁以上打印⽼寿星
1 |
|
悬空else问题
如果有多个 if 和 else ,可以记住这样⼀条规则, else 总是跟最接近的 if 匹配。
运行这段代码,会发现什么也没输出
1 |
|
这就是因为上面那个结论,else与最近的if匹配,所以它和b == 2这个表达式匹配了
关系操作符和条件操作符
关系操作符
C语言用于比较的表达式,称为“关系表达式”(relational expression),里面使用的运算符就称为“关系运算符”(relational operator),主要有下面6个。
>
大于运算符<
小于运算符>=
大于等于运算符<=
小于等于运算符==
相等运算符!=
不相等运算符
关系表达式通常返回0
或1
,表示真假。
C语言中,0
表示假,所有非零值表示真。比如,20 > 12
返回1
,12 > 20
返回0。
关系表达式常用于if
或while
结构。
1 | if (x == 3){ |
注意:相等运算符==
与赋值运算符=
是两个不一样的运算符,不要混淆。有时候,可能会不小心写成=
为了防⽌出现这种错误,有的程序员喜欢将变量写在等号的右边。
1 | if (3 == x) ... |
另一个需要避免的错误是,多个关系运算符不宜连用。
1 | i < j < k |
上面示例中,连续使用两个小于运算符。这是合法表达式,不会报错,但是通常达不到想要的结果,即不是保证变量j
的值在i
和k
之间。因为关系运算符是从左到右,所以实际执行的是:(i < j) < k
上面式子中,i < j
返回0 or 1,所以最终是0 or 1和k进行比较,如果想判断j在i和k之间,应该使用下面的写法。
i < j && j < k
⽐如:我们输⼊⼀个年龄,如果年龄在18岁~36岁之间,我们输出⻘年。
1 |
|
条件运算符(三目)
exp1 ? exp2 : exp3
条件操作符的计算逻辑是,如果exp1为真,exp2计算,计算的结果是exp2表达式的结果;如果exp1为假,exp3计算,计算结果为exp3表达式的结果。
练习:使⽤条件表达式实现找两个数中较⼤值。
1 |
|
逻辑操作符
逻辑运算符提供逻辑判断功能,用于构建更复杂的表达式,主要有下面三个运算符。
!
:逻辑取反运算符(改变单个表达式的真假)。&&
:与运算符,就是并且的意思(两侧的表达式都为真,则为真,否则为假)。||
:或运算符,就是或者的意思(两侧至少有一个表达式为真,则为真,否则为假)。
注:C语言中,非0表示真,0表示假
练习:闰年判断
输⼊⼀个年份year,判断year是否是闰年
闰年判断的规则:
- 能被4整除并且不能被100整除是闰年
- 能被400整除是闰年
1 |
|
短路
C语言逻辑运算符还有一个特点,它总是先对左侧的表达式求值,再对右边的表达式求值,这个顺序是保证的。如果左边的表达式满足逻辑运算符的条件,就不再对右边的表达式求值。这种情况称为“短路”。
如前面的代码:
1 | if(month >= 3 && month <= 5) |
表达式中&&的左操作数是month >= 3
,右操作数是month <= 5
,当左操作数month >= 3
的结果是0的时候,即使不判断month <= 5
,整个表达式的结果也是0;
所以,对于&&来说,左操作数不满足条件时,右操作数就不再执行。
||
也是同理,当||
的左操作数满足时,右操作数就不再执行。
像这种仅仅根据左操作数的结果就能知道整个表达式的结果,不再对右操作数进⾏计算的运算称为短路求值。
switch语句
除了if语句外,C语言还提供了switch语句来实现分支结构。
switch语句是一种特殊形式的if…else结构,用于判断条件有多个结果的情况。
它把多重的else if
改成更易用、可读性更好的形式。
语法形式如下:
1 | switch (expression) |
上面代码中,根据表达式expression
不同的值,执行相应的case
分支。如果找不到对应的值,就执行default
分支。
注:
- switch后的
expression
必须是整型表达式 - case后的值,必须是整型常量表达式
练习:输⼊任意⼀个整数值,计算除3之后的余数
1 |
|
- case 和后边的数字之间必须有空格
- 每⼀个case语句中的代码执⾏完成后,需要加上break,才能跳出这个switch语句。
switch中的break
前⾯的代码中,如果我们去掉case语句中的break,会出现什么情况呢?
测试一下,如果输入7
输出12,两次
这是为什么呢?
原因是 switch 语句是实现分⽀效果的,只有在 switch 语句中使⽤ break 才能在跳出 switch语句,如果某⼀个case语句的后边没有break 语句,代码会继续往下执⾏,有可能执⾏其他case语句中的代码,直到遇到break语句或者switch语句结束。就⽐如上⾯的代码 case 1 后边没有break ,就继续往下执⾏,于是执⾏了 case 2 中的语句。所以在 switch 语句中 break 语句是⾮常重要的,能实现真正的分⽀效果。
当然,也不是每个 case 语句都得有 break ,这就得根据实际情况来看了。
练习:输⼊⼀个1~7的数字,打印对应的星期⼏
1 |
|
switch语句中的default
在使用switch
语句的时候,我们经常可能遇到一种情况,比如switch
后的表达式中的值无法匹配代码中的case
语句的时候,这时候要不就不做处理,要不就得在switch
语句中加入default
子句。
switch语句中的case和default的顺序问题
在 switch 语句中 case 语句和 default 语句是没有顺序要求的,只要你的顺序是满⾜实际需求的就可以。不过我们通常是把 default ⼦句放在最后处理的。
while语句
⾸先上来就是执⾏判断表达式,表达式的值为0,循环直接结束;表达式的值不为0,则执⾏循环语句,语句执⾏完后再继续判断,是否进⾏下⼀次判断。
练习:在屏幕上打印1~10的值
1 |
|
break、continue
break 的作⽤是⽤于终⽌循环的,在 while 循环中,只要有机会执⾏到 break ,不管后续还可能
有多少次循环,循环都会终⽌。请看下⾯的代码:
1 |
|
结果只会打印1,因为到2时已经break了
continue 是继续的意思,在循环中的作⽤就是跳过本次循环中 continue 后边的代码,继续进⾏下⼀次循环的判断
1 |
|
练习:连续输入字符,只打印数字,其他字符跳过,不做处理
1 |
|
上述的代码中,ASCII码值不在’0’~’9’范围内的,使⽤continue跳过后续的putchar语句,不再打印。
ASCLL码表
练习:输⼊⼀个正的整数,逆序打印这个整数的每⼀位
1 |
|
for语句
语法形式
for循环是三种循环中使⽤最多的,for循环的语法形式如下:
1 | for(表达式1; 表达式2; 表达式3) |
表达式1 ⽤于循环变量的初始化
表达式2 ⽤于循环结束条件的判断
表达式3 ⽤于循环变量的调整
执⾏流程
⾸先执⾏表达式1初始化循环变量,接下来就是执⾏表达式2的判断部分,表达式2的结果如果==0,则循环结束;表达式2的结果如果!=0则执⾏循环语句,循环语句执⾏完后,再去执⾏表达式3,调整循环变量,然后再去表达式2的地⽅执⾏判断,表达式2的结果是否为0,决定循环是否继续。整个循环的过程中,表达式1初始化部分只被执⾏1次,剩下的就是表达式2、循环语句、表达式3在循环。
练习:在屏幕上打印1~10的值
1 |
|
for和while在实现循环的过程中都有初始化、判断、调整这三个部分,但是for循环的三个部分⾮常集中,便于代码的维护,⽽如果代码较多的时候while循环的三个部分就⽐较分散,所以从形式上for循环要更优⼀些。
练习:计算1~100之间3的倍数的数字之和
1 |
|
break、continue
与while中的用法一致。
do while语句
在循环语句中do while语句的使⽤最少,它的语法如下:
1 | do |
while和for这两种循环都是先判断,条件如果满⾜就进⼊循环,执⾏循环语句,如果不满⾜就跳出循环;
⽽do while循环则是先直接进⼊循环体,执⾏循环语句,然后再执⾏while后的判断表达式,表达式为真,就会进⾏下⼀次,表达式为假,则不再继续循环。
在do while循环中先执⾏图上的“语句”,执⾏完语句,在去执⾏“判断表达式”,判断表达式的结果是!=0,则继续循环,执⾏循环语句;判断表达式的结果==0,则循环结束。
所以在do while语句中循环体是⾄少执⾏⼀次的,这是do while循环⽐较特殊的地⽅。
break、continue与其他两个循环语句是一致的。
练习:输⼊⼀个正整数,计算这个整数是⼏位数?
1 |
|
循环嵌套例题
找出100~200之间的素数,并打印。
注:素数又称质数,只能被1和本身整除的数字。
1 |
|
goto语句和标号
C语言提供了一种非常特别的语法,就是goto语句和跳转标号,goto语句可以实现在同一个函数内跳转到设置好的标号处。
例如:
1 |
|
goto 语句如果使⽤的不当,就会导致在函数内部随意乱跳转,打乱程序的执⾏流程,所以我们的建议是能不⽤尽量不去使⽤;但是goto语句也不是⼀⽆是处,在多层循环的代码中,如果想快速跳出使⽤goto就⾮常的⽅便了。
一维数组的创建和初始化
变量的出现使得我们可以存放单个数据,那假设我们有一组数据,比如:某个班级的数学成绩有30个数据,这时候C语言中给了一个数组的概念,可以让我们创建一块连续的空间来存放一组数据。
数组的概念
数组是一组相同类型元素的集合。
从这个概念中我们就可以发现2个有价值的信息:
- 数组中存放的是1个或者多个数据。
- 数组中存放的数据,类型是相同的。
数组的创建
数组创建的基本语法如下:
1 | type arr_name[常量值]; |
存放在数组的值被称为数组的元素,数组在创建的时候可以指定数组的大小和数组的元素类型。
- type指定的是数组中存放数据的类型,可以是:char、short、int、float等,也可以是自定义的类型
- arr_name指的是数组名的名字,这个名字根据实际情况,起的有意义就行。
[]
中的常量值是用来指定数组的大小的,这个数组的大小是根据实际的需求指定的。
比如:我们想存储20人的成绩,可以创建一个数组:int scores[20];
数组的初始化
有时候,数组在创建的时候,我们需要给定一些初始值,这种就称为初始化。
那数组如何初始化呢,数组的初始化一般使用大括号,将数据放在大括号中。
1 | // 完全初始化 |
数组的类型
数组也是有类型的,数组算是一种自定义类型,去掉数组名留下的就是数组的类型。
如:int arr[10]、char ch[5]
arr的类型是int [10]
,ch的类型是char [5]
一维数组的使用
数组下标
C语言中规定数组是有下标的,下标是从0开始的,假设数组有n个元素,最后一个元素的下标是n-1,下标就相当于数组元素的编号`
在C语言中数组的访问提供了一个操作符[]
,这个操作符叫:下标引用符。
有了下标访问操作符,我们就可以轻松的访问到数组的元素了,比如我们访问下标为7的元素,我们就可以使用arr[7]
1 |
|
数组元素打印
接下来,如果想要访问整个数组的内容,那怎么办呢?
只要我们产⽣数组所有元素的下标就可以了,那我们使⽤for循环产⽣0~9的下标,接下来使⽤下标访问就⾏了。
如下代码:
1 |
|
数组的输⼊
通过scanf + for即可
一维数组在内存中的存储
打印数组元素的地址
1 |
|
从输出的结果分析,数组随着下标的增长,地址是由小到大变化的,并且我们发现每两个相邻的元素之间相差4(因为一个整型是4个字节)。所以我们得出结论:数组在内存中是连续存放的,随着数组下标的增长,地址是由低到高变化的。
sizeof计算数组元素个数
sizeof中C语言是一个关键字,是可以计算类型或者变量大小的,其实sizeof也可以计算数组的大小。
比如:
1 |
|
这里输出的结果是40,计算的是数组所占内存空间的总大小,单位是字节。
我们又知道数组中所有元素的类型都是相同的,那只要计算出一个元素所占字节的个数,数组的元素个数九年计算出来。
1 |
|
这⾥的结果是:10,表⽰数组有10个元素。
以后在代码中需要数组元素个数的地⽅就不⽤固定写死了,使⽤上⾯的计算,不管数组怎么变化,计算出的⼤⼩也就随着变化了。
二维数组的创建和初始化
数组的概念
我们前面学习的数组被称为一维数组,数组的元素都是内置类型的,如果我们把一维数组作为数组的元素,这时候就是二维数组,二维数组作为数组的元素则是三维数组,二维数组以上的数组统称为多维数组。
数组的创建
语法如下:
1 | type arr_name[常量值1][常量值2] |
解释:
- 2表示数组有2行
- 3表示每一行有5个元素
- itnt 表示数组的每个元素都是整型类型
- arr是数组名,可以根据自己的需要指定名字
数组的初始化
在创建变量或者数组的时候,给定一些初始值,被称为初始化。
那二维数组如何初始化呢?像一维数组一样,也是使用大括号初始化的。
不完全初始化
1 | int arr1[3][5] = {1,2}; |
完全初始化
1 | int arr3[3][5] = {1,2,3,4,5, 2,3,4,5,6, 3,4,5,6,7}; |
按行初始化
1 | int arr4[3][5] = {{1,2},{3,4},{5,6}}; |
初始化省略行,但不能省略列号
1 | int arr5[][5] = {1,2,3}; |
二维数组的使用
数组下标
二维数组的访问也是使用下标的形式的,二维数组是有行和列的,只要锁定了行和列九年唯一锁定数组中的一个元素。
C语言规定,二维数组的行从0开始,列也是从0开始
1 |
|
二维数组的输入输出和内层与一维数组是一样的。
C99中变长数组
在C99标准之前,C语言在创建数组的时候,数组大小的指定只能使用常量、常量表达式,或者如果我们初始化数据的话,可以省略数组的大小。
如:
1 | int arr1[10]; |
这样的语法限制,让我们创建数组不够灵活,大了浪费空间,小了不够用。
C99中给一个变长数组(variable-length array,简称VLA)的新特性,允许我们可以使用变量指定数组大小。
1 | int n = a + b; |
上面的示例中,数组arr
就是变长数组,因为它的长度取决于变量n
的值,编译器没法事先确定,只有运行时才能知道n
是多少。
变长数组的根本特征,就是数组长度只有运行时才能确定,所以变长数组不能初始化。它的好处是不需要写死一个值。
遗憾的是在VS2022上,虽然⽀持⼤部分C99的语法,没有⽀持C99中的变⻓数组,没法测试
字符和ASCII编码
C语言中提供了一种类型叫char
,这种类型专门用来创建字符变量,字符变量是用来存储字符的,所谓字符就是我们在键盘上敲出来的单个符号,如:A a 1 @ # $
等等,C语言规定,字符必须要放在单引号中。
这些字符想存储起来,就得放在字符变量中,比如:
1 | char ch = 'a'; |
我们知道在计算机中所有的数据都是以⼆进制的形式存储的,那这些字符在内存中分别以什么样的⼆进制如何存储的呢?如果我们每个⼈⾃⼰给这些字符中的每个字符编⼀个⼆进制序列,这个叫做编码,为了⽅便⼤家相互通信的⽅便,不造成混乱,后来美国国家标准学会(ANSI)出台了⼀个标准ASCII编码,C语⾔中的字符就遵循了ASCII编码的⽅式。
我们不需要记住所有的ASCII码表中的数字,但是最好能掌握一些数据:
- 字符A
Z的ASCII码值从6590 - 字符a
z的ASCII码值从97122 - 对应的大小写字符(a和A)的ASCII码值的差值是32
- 数字字符0
9的ASCII码值从4857 - 换行\n的ASCII值是:10
- ASCII码值从0~31 这32个字符是不可打印字符,无法打印在屏幕上观察
转义字符
什么是转义字符
在字符中有一组特殊的字符是转义字符,转义字符顾名思义:转变原来的意思。
比如:我们有字符n
,在字符串打印的时候自然能打印出这个字符,如下:
1 |
|
此时可以正常的打印
如果我们修改⼀下代码,在 n 的前⾯加上 \ ,变成如下代码:
1 |
|
我们可以看到修好的前后代码输出的结果,截然不同的,那这是为什么呢?
这就是转义字符的问题, \n 是⼀个转义字符表⽰换⾏的意思,我们可以简单的理解为 \ 让 n 的意思发⽣了转变, n 本来是⼀个普通的字符,被 \ 转义为换⾏的意思。
转义字符有哪些
\?
:在书写连续多个问号时使用,防止被解析成三字母词,在新的编译器上没法验证了。\'
:用于字符常量’\"
:用于表示一个字符串内部的双引号\\
:用于表示一个反斜杠,防止它被解释为一个转义序列符\a
:警报,使终端发出警报声或出现闪烁,或者两者同时发生。\b
:退格键,光标回退一个字符,但不删除字符。\f
:换页符,光标移到下一页。现在已经看不出来了,行为类似\v
\n
:换行符\r
:回车符,光标移到同一行的开头\t
:制表符,光标移到下一个水平制表位,通常是下一个8的倍数\v
:垂直分隔符,光标移到下一个垂直制表位,通常是下一行的同一列。
两类特殊的转义字符
下⾯2种转义字符可以理解为:字符的8进制或者16进制表⽰形式
\ddd
:ddd表示1~3个八进制数字。如:\130表示字符X- 八进制转义序列拥有3个八进制位的长度限制,但若提前遇到不是合法八进制位的字符,则在首个这种字符处终止
xdd
:dd表示2个十六进制数字。如: \x30表示字符0- 十六进制转义序列无长度限制,并在首个不是合法十六进制位的字符处终止。
\0
:null字符,代表没有内容,\0
就是\ddd
这类转义字符的一种,用于字符串的结束标志,其ASCII码值是0.
字符串和字符数组
字符处和\0
C语言中有字符类型,但是没有字符串类型,C语言中字符串就是由双引号引起来的一串字符,比如:abcdef
;
一个字符串中我们直观的能看到一些字符,比如:字符串常量”abcdef”中,我们看到了a、b、c、d、e、f这6个字符,但是实际上在末尾还隐藏一个\0
的转义字符,\0
是作为字符串的结束标志存在的。
正因为字符串中隐藏一个\0
字符,是字符串的结束标志,所以我们在使用库函数打印字符串(printf)或者计算字符串长度(strlen)的时候,遇到\0
的时候就自动停止了。
其实字符串和字符数组是非常类似的,字符串在内存中存储的时候,也是连续存放的,就像数组一样。
字符数组的创建和初始化
字符数组就一个存放字符的数组,创建形式如下:
1 | // 创建字符数组并且初始化 |
这里的data就是一个字符数组,可以存放5个字符。当然我们可以根据需要修改字符数组的大小。
我们是可以通过下标访问字符数组的,使用方法与数组同理。
1 |
|
字符串常量初始化字符数组
字符数组初始化,也可以直接使用常量字符串,如下代码:
1 | // 指定数组大小 |
所以当我们使用常量字符串初始化数组的时候,其实我们给数组中存放了能看到的字符和一个\0
字符。这里也可以做个对比:
1 |
|
在使⽤常量字符串初始化的时候,数组中多了⼀个’\0’字符;这个’\0’就是字符串常量中隐藏的。
\0作为字符串的结束标志
在这里演示一些\0的作用:
1 |
|
我们可以看到,arr1字符数组在打印的时候,打印了a、b、c后还打印了一些随机值,这是因为arr1在末尾的地方没有\0字符作为结束标志,在打印的时候没有停止。
但是arr2的打印就是完全正常的,就是因为arr2数组是使用字符串常量初始化的,数组中有\0作为标志,可以正常停止。
如果我们在arr1数组中单独放一个\0字符会怎么样呢?
1 |
|
此时结果一致。
字符数组的输入和输出
我们可以使用scanf函数和printf函数完成字符串的输入和输出,请看以下代码:
1 |
|
注:使⽤scanf函数输⼊的时候,我们⾃⼰要保证字符数组⾜够⼤,能够容纳下输⼊进去的字符,要不然就会出问题。这也是scanf被诟病不安全的地⽅。
求字符串长度
在C语言中有一个库函数叫strlen
,这个函数是专门用来求字符串长度的。strlen
的使用需要包含一个头文件string.h
。
strlen函数统计的是字符串中\0
之前的字符的个数,所以传递给strlen函数的字符串中必须得包含\0
。
1 |
|
gets和puts
gets
1 | char * gets(char * str) |
函数功能:在标准输入(键盘)中读取字符串,存放在参数str指向的字符串,如果输入成功则返回存放字符串数据空间的起始地址。
注:这个函数虽然在一些编译器上还是可以使用的,但是在新的一些C语言标志中不再支持。
1 |
|
puts
1 | int puts ( const char * str ); |
函数功能:puts函数打印str指向的字符串到标准输出(⼀般指屏幕),同时在打印结束后会打印换⾏。
1 |
|
函数是什么
函数(function),也叫子程序,C语言中的函数就是完成某项特定的任务的一小段代码。这段代码是有特殊写法和调用方法的。
函数的优势:
- 分解任务:C语言的程序其实是由无数个小的函数组合而成的,也可以说:使用函数可以把大的计算任务分解成若干个较小的函数(对应较小的任务)完成。
- 代码复用:一个函数如果能完成某项特定任务的话,这个函数是可以服用的,提升了开发软件的效率。
函数的分类:
- 库函数
- 自定义函数
库函数
C语言标准中规定了C语言的各种语法规则,C语言并不提供库函数;C语言的国际标准ANSI C规定了一些常用的函数的标准,被称为标准库,不同的编译器厂商根据ANSI提供的C语言标准就给出了一系列函数的实现。这些函数就被称为库函数。
自定义函数
库函数再好,提供的功能还是有限的;日常写代码的需求是千变万化的,所以还是需要程序员写各种各样的代码,这些代码也是由一个个函数组成,我们设计和实现的函数就是自定义函数。
比如,我们可以写一个函数判断闰年:
1 |
|
C语言库函数和使用方式
库函数的介绍
各种编译器的标准库中提供了一系列的库函数,这些库函数根据功能的划分,都在不同的头文件中进行了声明。
库函数相关头文件:https://zh.cppreference.com/w/c/header
库函数的使用方法
库函数的学习和查看⼯具很多,⽐如:
C/C++官⽅的链接:https://zh.cppreference.com/w/c/header
cplusplus.com:https://legacy.cplusplus.com/reference/clibrary/
查文档。
自定义函数
函数的语法形式
其实自定义函数和库函数是一样的,形式如下:
1 | ret_type fun_name(形式参数) |
ret_type 是函数返回类型
fun_name是函数名括号中放的是形式参数
{}括起来的是函数体
ret_type 是 ⽤来表⽰函数计算结果的类型,有时候返回类型可以是void,表⽰什么都不返回
fun_name 是为了⽅便使⽤函数;就像⼈的名字⼀样,有了名字⽅便称呼,函数有了名字⽅便调⽤,所以函数名尽量要根据函数的功能起的有意义。
函数的参数就相当于,⼯⼚中送进去的原材料,函数的参数也可以是void,明确表⽰函数没有参数。如果有参数,要交代清楚参数的类型和名字,以及参数个数。
{}括起来的部分被称为函数体,函数体就是完成计算的过程。
例:写一个加法函数,完成2个整型变量的加法
1 |
|
函数的形参和实参
实参
实际参数就是真实传递给函数的参数,比如上述的1,1
形参
在定义函数时,在函数名后括号内的n1和n2,称为形式参数,简称形参。
为什么叫形参呢,实际上如果只是定义了add函数而不去调用的花,那么,add函数的参数x和y只是形式上存在的,不会向内存申请空间,不会真实存在的,所以叫形式参数。形式参数只有在函数被调用的过程中为了存放实参传递过来的值,才向内存申请空间,这个过程就是形参的实例化。
形参是实参的⼀份临时拷⻉
函数的链式访问
什么是链式访问
所谓链式访问就是将一个函数的返回值作为另一个函数的参数,像链条一样将函数串起来,就是函数的链式访问。
非链式
1 | int len = strlen("abcdef");//1.strlen求⼀个字符串的⻓度 |
链式访问
printf("%d\n", strlen("abcdef"));
无参数和无返回值
函数在设计的时候,一般情况下,函数是有参数和返回值的,但是也有特殊情况,是不需要参数,也可能不需要返回值的。
如果我们想无参数,很简单,直接不写参数,如果想无返回值,在返回参数类型处写void即可。
函数的return语句
在C语言中设计函数的时候,一个函数经过复杂的计算后,一般都会算出一个结果,需要返回就可以使用return语句
如果⼀个函数不需要返回值,在某种条件发⽣的时候,想提前结束函数,也可以使⽤return语句。
函数的声明和定义
一般我们在使用函数的时候,直接将函数写出来就使用了。
那如果我们将函数的定义放在函数的调⽤后边,此时,就会出现警告,说函数未定义,因为函数的定义是在后面,所以出现这个问题,我们可以把声明放在前面,这样就可以了。
函数的调⽤⼀定要满⾜:先声明后使⽤;
函数的定义也是⼀种特殊的声明,所以如果函数定义放在调⽤之前也是可以的。
如果我们需要在多个文件的情况下使用函数,可以在一个文件中先把函数写好,再去其他需要该函数的文件引入对应的.c文件即可
extern关键字
我们在写代码时,经常会出现多个.c文件的情况,有时候我们也需要调用来自外部源文件(.c)中的函数。
例如:
1 |
|
代码编译的结果有⼀个警告,这是因为编译器是单个源⽂件进⾏编译的,在编译test.c的时候,并不知道add.c中有Add函数的,所以才有了这个警告。
需要最上方声明该内容
1 | //声明来⾃外部的符号 |
不过,由于函数声明的函数原型默认就是 extern ,所以这⾥不加 extern ,效果是⼀样的。
statis关键字
- static修饰局部变量
- static修饰全局变量
- static修饰函数
static修饰局部变量
1 |
|
不加入static时,局部变量在使用后都会被销毁,而加入static后,n只会被创建和实例化一次,此时它不会被销毁,可以复用
static修饰全局变量
全局变量本来是具有外部链接属性的,⽽被static修饰之后,外部链接属性就变成了内部链接属性,被static修饰的全局变量只能在⾃⼰所在的源⽂件内部使⽤,⽆法在其他源⽂件内部使⽤的。
如果我们希望⼀个全局变量只能在⾃⼰所在的源⽂件内部使⽤,不让其他源⽂件使⽤,就可以使⽤static修饰这个全局变量,达到这种效果。
1 | //static修饰全局变量 |
static修饰函数
static修饰函数和static修饰全局变量是⾮常类似的,本来函数是具有外部链接属性的,在其他源⽂件内部声明后可以调⽤使⽤,但是被static修饰就变成了内部链接属性,这时这个函数只能在⾃⼰所在的源⽂件内部使⽤,如果在其他源⽂件内部调⽤就报错;
和修饰全局变量是差不多的。
函数递归
递归是什么
递归是一种解决问题的方法,在C语言中,递归就是函数自己调用自己。
写一个炒鸡简单的C语言递归代码:
1 |
|
上述就是一个简单的递归程序,只不过上面的递归只是为了演示递归的基本形式,不是为了解决问题,代码最终会陷入死递归,导致栈溢出(Stack overflow)。
递归的思想
把一个大型复杂问题层层转化为一个与原问题相似,但规模较小的子问题;直到子问题不能再被拆分,递归就结束了。所以递归的思考方式就是把大事化小的过程。
递归中的递就是递推的意思,归就是回归的意思。
递归的限制条件
递归在书写的时候,有2个必要条件:
- 递归存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件。
递归举例
求n的阶乘
阶乘的概念:一个正!整数的阶乘(factorial)是所有小于及等于该数的正整数的积,并且0的阶乘为1。
自然数n的阶乘写作n!。
题目:计算n的阶乘(不考虑溢出),n的阶乘就是1~n的数字累积相乘。
分析和代码实现
我们知道n的阶乘公式:n! = n * (n - 1)!
1 | 举例: |
这样的思路就是把一个较大的问题,转换成一个与原问题相似,但规模较小的问题来求解的。
当n == 0
的时候,n的阶乘是1,其余n的阶乘都是可以通过公式计算。
n的阶乘的递归公式如下:
那我们就可以写出函数Fact求n的阶乘,假设Fact(n)就是求n的阶乘,那么Fact(n-1)就是求n-1的阶乘,函数如下:
1 |
|
顺序打印一个正整数的每一位
输入一个整数m,按照顺序打印整数的每一位。
比如:
输入:1234 输出:1 2 3 4
分析和代码实现
如果n是一位数,n的每一位就是n自己
n超过一位数,就得拆分每一位
1234%10就能得到4,依次计算,但这样的结果是倒着的
那我们假设想写⼀个函数Print来打印n的每⼀位,递归到最后一位时,这个位数必然是小于10的,那么我们进行判断,当它小于10时,条件结束,我们依次开始打印每一位即可。
1 |
|
求第n个斐波那契数
斐波那契数列的规律如下:
小于3时,值为1
否则:通过前两个值之和作为第三个值的结果值
规律如下:1、1、2、3、5以此类推
有些时候是不适合通过递归来编写对应代码的,因为递归很消耗性能,例如斐波那契数列,如果你通过递归来写
->
1 |
|
当数字过大时,递归所产生的量将会以量级的形式递增。
为了避免这种情况,我们可以直接通过循环的形式来编写,因为斐波那契是一种有规律的数列
1 |
|
Debug和Release
Debug通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序;
程序员在写代码的时候,需要经常性的调试代码,就将这里设置为debug
,这样编译产生的是debug
版本的可执行程序,其中包含调试信息,是可以直接调试的。
Release称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好的使用。当程序员写完代码,测试再对程序进行测试,直到程序的质量符合交付给用户使用的标准,这个时候就会设置为release
,编译产生的就是release
版本的可执行程序,这个版本是用户使用的,无需包含调试信息等。
C语言常见的错误类型
编程常见错误归类
编译型错误
编译型错误一般都是语法错误,这类错误一般看错误信息就能找到一丝蛛丝马迹的,双击错误信息也能初步的跳转到代码错误的地方或附近。
链接型错误
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是因为
- 标识符名不存在
- 拼写错误
- 头文件没包含
- 引用的库不存在
运行时错误
运⾏时错误,是千变万化的,需要借助调试,逐步定位问题,调试解决的是运⾏时问题。
内存和地址
在讲内存和地址之前,我们想有个⽣活中的案例:
假设有⼀栋宿舍楼,把你放在楼⾥,楼上有100个房间,但是房间没有编号,你的⼀个朋友来找你玩,如果想找到你,就得挨个房⼦去找,这样效率很低,但是我们如果根据楼层和楼层的房间的情况,给每个房间编上号,如:
1 | ⼀楼:101,102,103... |
有了房间号,如果你的朋友得到房间号,就可以快速的找房间,找到你。
如果把上面的例子对照到计算中,又是怎么样呢?
我们知道CPU(中央处理器)在处理数据的时候,需要的数据是在内存中读取的,处理后的数据也会放回内存中,那我们买电脑的时候,电脑上内存是8GB/16GB/32GB等,那这些内存空间如何⾼效的管理呢?
其实也是把内存划分为一个个的内存单元,每个内存单元的大小取1个字节。
计算机中常见的单位(补充):⼀个⽐特位可以存储⼀个2进制的位1或者0
1 | bit - ⽐特位 |
1 | 1byte = 8bit |
其中,每个内存单元,相当于一个学生宿舍,一个字节空间里面能放8个比特位
每个内存单元也都有一个编号(这个编号就相当于门牌号),有了这个内存单元的编号,CPU就可以快速找到一个内存空间。
生活中我们把门牌号叫做地址,在计算机中我们把内存单元的编号也称为地址。
C语言中给地址起了新的名字:指针。
所以我们可以理解为:内存单元的编号 == 地址 == 指针
如何理解编址
CPU访问内存中的某个字节空间,必须知道这个字节空间在内存的什么位置,而因为内存中字节很多,所以需要给内存进行编址(相当于编号)
计算机中的编址,并不是把每个字节的地址记录下来,而是通过硬件设计完成的。
首先,必须理解,计算机内是有很多的硬件单元,而硬件单元是要互相协同工作的。所谓的协同,至少相互之间要能够进行数据传递。
但是硬件与硬件之间是互相独立的,那么如何通信呢?答案很简单,用“线”连起来。
而CPU和内存之间也是有大量的数据交互的,所以,两者必须也用线连起来。
而这些,就要通过地址总线来连接。
我们可以简单理解,32位机器有32根地址总线,每根线只有两态,表示0,1【电脉冲有无】。那么一根线,就能表示2种含义,2根线,就能表示4种含义,以此类推,32则是2^32种含义,每一种含义都代表一个地址。
地址信息被下达给内存,在内存上,就可以找到该地址对应的数据,将数据在通过数据总线传入CPU内寄存器。
指针变量和地址
取地址操作符(&)
通过&
我们可以获取到某个单位的地址。
指针变量
我们通过取地址操作符(&)拿到的地址是一个数值,比如:0x006FFD70,这个数值有时候也是需要存储起来,那我们存放在哪里呢:指针变量。
比如:
1 |
|
指针变量也是一种变量,这种变量就是用来存放地址的,存放在指针变量中的值都会理解为地址。
解引用操作符
我们将地址保存起来,未来是要使用的,那怎么使用呢?
我们只要拿到了地址(指针),就可以通过地址(指针)找到地址(指针)指向的对象。
1 |
|
*pa
的意思就是通过pa中存放的地址,找到指向的空间,*pa
其实就是a变量了
指针变量的大小
如果指针变量是用来存放地址的,那么指针变的大小就得是4个字节的空间才可以。
同理64位的机器,假设有64根地址线,一个地址就是64个二进制位组成的二进制序列,存储起来就需要8个字节的空间,指针变量的大小就是8个字节。
1 | //指针变量的⼤⼩取决于地址的⼤⼩ |
结论:
- 32位平台下地址是32个bit位,指针变量大小是4个字节
- 64位平台下地址是64个bit位,指针变量大小是8个字节
- 注意指针变量的大小和类型是无关的,只要指针类型的变量,在相同的平台下,大小都是相同的。
指针变量类型的意义
指针的解引用
对⽐,下⾯2段代码,主要在调试时观察内存的变化。
1 |
|
1 |
|
调试我们可以看到,代码1会将n的4个字节全部改为0,但是代码2只是将n的第一个字节改为0。
结论:指针的类型决定了,对指针解引用的时候有多大的权限(一次能操作几个字节)。
比如:char*
的指针解引用就只能访问一个字节,而int *
的指针的解引用就能访问四个字节。
代码1的情况
代码2的情况
指针+-整数
1 |
|
运行后
我们可以看出,char*
类型的指针变量+1跳过1个字节,int*
类型的指针变量+1跳过了4个字节。这就是指针变量的类型差异带来的变化。
结论:指针的类型决定了指针向前或者向后走一步有多大(距离)。
void*指针
在指针类型中有一种特殊的类型是void*
类型的,可以理解位无具体类型的指针(或者叫泛型指针),这种类型的指针可以用来接受任意类型地址。但是也有局限性,void*
类型的指针不能直接进行指针的+-整数和解引用的运算。
举例:
1 |
|
在上图的代码中,将一个int类型的变量的地址赋值给一个char*类型的指针变量。编译器给出了一个警告,是因为类型不兼容。而使用void*
类型就不会有这样的问题。
使用void*
类型的指针接收地址
1 |
|
void*
可以接收地址,但当它赋值时(进行指针运算时),就会提示错误信息了
那么void*
类型的指针到底有什么用呢?
一般void*
类型的指针是使用在函数参数的部分
,用来接收不同类型数据的地址,这样的设计可以实现泛型编程的效果,使得一个函数来处理多种类型的数据。
const修饰指针
const修饰变量
变量是可以修改的,如果把变量的地址交给一个指针变量,通过指针变量也可以修改这个变量。
但是如果我们希望一个变量加上一些限制,不能被修改,怎么做呢?这就是const的作用。
1 |
|
上述代码中的n是不能被修改的,其实n本质是变量,只不过被const修饰后,在语法上添加了限制,只要我们在代码中对n进行修改,就不符合语法规则,就直接报错,无法修改n。
但如果我们绕过n,通过n的地址,去修改n就能做到了,虽然这样做是在打破语法规则。
1 |
|
我们发现这里确实被修改了,但这不合理,所以应该让p拿到了n的地址也不能修改n,接下来该怎么做呢?
const修改指针变量
一般来说const修饰指针变量,可以放在*
的左边,也可以放在*
的右边,意义是不一样的。
1 | int * p ; // 没有const修饰 |
- const如果放在
*
的左边,修饰的是指针指向的内容,保证指针指向的内容不能通过指针来改变。但是指针变量本身的内容可变。 - const如果放在
*
的右边,修饰的是指针变量本身,保证了指针变量的内容不能修改,但是指针指向的内容,可以通过指针改变。
简单来说,在左可以改变指针变量本身,在右可以改变指针指向的内容。
举例:
1 |
|
1 |
|
1 |
|
指针运算
指针的基本运算有三种,分别是:
- 指针+-整数
- 指针-指针
- 指针的关系运算
指针+-整数
因为数组在内存中是连续存放的,只要知道第一个元素的地址,顺藤摸瓜就能找到后面的所有元素。
1 | int arr[3] = {1,2,3} |
使用指针打印数组所有元素的值:
1 |
|
指针-指针
就想日期-日期得到天数一样,指针和指针可以相减,指针-指针的绝对值是指针和指针之间元素的个数。
指针-指针的前提是两个指针指向同一块空间(比如同一个数组)。
例如:
1 |
|
应用:写一个函数求字符串长度(本质是模拟实现strlen函数)
1 |
|
指针的关系运算
1 |
|
这里 arr
是数组的首地址,而在 C 中,数组名 arr
可以被看作是一个指向数组第一个元素的指针。因此,arr + sz
实际上是计算数组 arr
的末尾地址之后的一个地址。这是因为在指针算术操作中,将指针与整数相加会将指针向前移动整数乘以指针指向类型大小的字节。因此,arr + sz
将指向数组末尾之后的一个位置(即 arr[3]
的位置,这是数组 arr
的边界之外)。
在 while
循环的每次迭代中,p
指向当前元素,并打印该元素的值
然后 p
被递增,指向下一个元素
循环将继续,直到 p
达到或超过 arr + sz
。由于 arr + sz
是数组末尾之后的地址,所以 p
在遍历数组中的每个元素之后,仍然小于 arr + sz
。当 p
遍历完数组中的所有元素后,它将指向数组末尾之后的第一个位置,但仍然不会达到 arr + sz
,因此循环会在打印完数组中的所有元素后终止。
野指针
概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
理解野指针的时候,可以想象为外界的,无主的,危险的。
野指针成因
指针未初始化
1 |
|
指针越界访问
1 |
|
指针指向的空间释放
1 |
|
n是局部的,p获取到了n的地址,而n已经释放了
如何规避野指针
指针初始化
如果明确知道指针指向哪里就直接赋值地址,如果不知道指针应该指向哪里,可以给指针赋值NULL,NULL
是C语言定义的一个标识符常量,值是0,0也是地址,但这个地址是无法使用的,读写该地址会报错。
初始化如下:
1 |
|
小心指针越界
一个程序向内存申请了哪些空间,通过指针也就只能访问哪些空间,不能超出范围访问,超出了就是越界访问。
使用指针的时候一定要注意边界,通过指针访问的内存是不能越界的。
指针变量不再使用时,及时置NULL,指针使用之前检查有效性
当指针变量指向一块区域的时候,我们可以通过指针访问该区域,后期不再使用这个指针访问空间的时候,我们可以把该指针置为NULL。因为约定俗成的一个规则就是:只要是NULL指针就不去访问,同时使用指针之前可以判断指针是否为NULL。
避免返回局部变量地址
别返回局部变量的地址即可。
assert断言
assert.h
头文件定义了宏assert()
,用于在运行时确保程序符合指定条件,如果不符合,就报错终止运行。这个宏常常被称为“断言”。
1 | assert(p != NULL); |
上面代码在程序运行到这一行语句时,验证变量p
是否等于NULL
。如果确实不等于NULL
,程序继续运行,否则就会终止运行,并且给出报错信息提示。
assert()
宏接受一个表达式作为参赛。
- 如果该表达式为真(返回值非0),
assert()
不会产生任何作用,程序继续运行。 - 如果该表达式为假(返回值为0),
assert()
就会报错,在标准错误流stderr
中写入一条错误信息,显示没有通过的表达式,以及包含这个表达式的文件名和行号。
assert() 的使⽤对程序员是⾮常友好的,使⽤ assert() 有⼏个好处:
它不仅能⾃动标识⽂件和出问题的⾏号,还有⼀种⽆需更改代码就能开启或关闭 assert() 的机制。
如果已经确认程序没有问题,不需要再做断⾔,就在 #include <assert.h> 语句的前⾯,定义⼀个宏 NDEBUG 。
1 |
然后,重新编译程序,编译器就会禁⽤⽂件中所有的 assert() 语句。如果程序⼜出现问题,可以移除这条 #define NDBUG 指令(或者把它注释掉),再次编译,这样就重新启⽤了 assert() 语句。
assert() 的缺点是,因为引⼊了额外的检查,增加了程序的运⾏时间。⼀般我们可以在 Debug 中使⽤,在Release 版本中选择禁⽤ assert 就⾏,在 VS 这样的集成开发环境中,在 Release 版本中,直接就是优化掉了。
这样在debug版本写有利于程序员排查问题,在 Release 版本不影响⽤⼾使⽤时程序的效率。
传值调用和传址调用
传值调用
练习:写一个函数求2个整数的较大值
要求:
- 函数完成
- 函数需要接收两个整型参数
- 函数需返回一个较大值
1 |
|
在调⽤Max的时候,传递的是a和b变量本⾝,直接传递给x和y,这种调⽤函数的⽅式被称为传值调⽤。
传址调用
传址调用,就得使用指针
例如:写一个函数,交换两个整型变量的值。
1 |
|
运行后,我们发现没有变化,这是为什么呢?
我们发现,这种方式是传值调用,只是将值传递进去了,而没有将地址传递进去,导致里面的变量是临时创建的,用完并没有反馈给原地址。
也就是说实参传递给形参的时候,形参会单独创建一份临时空间来接收实参,对形参的修改不影响实参,所以失败了。
我们现在要解决的就是当调用函数时,函数内部操作的就是main函数中的a和b,直接将a和b的值交换了,那么就可以使用指针了,在main函数中将a和b的地址传递给函数,函数通过地址操作a和b,就可以达到交换的效果了
1 |
|
此时,顺利的完成了交换,这种函数调用方式叫:传址调用。
传址调用,可以让函数和主调函数之间建立真正的联系,在函数内部可以修改主调函数的值;所以未来函数中需要主函数中的值实现计算的话,就可以采用传值调用。如果函数内部要修改主调函数中的变量的值,就需要传址调用。
数组名的理解
数组名是首元素的地址
在之前,我们通过指针访问数组的内容时,是这么写的
1 |
|
这里我们使用&arr[0]
的方式拿到了数组第一个元素的地址,但其实数组名本来就是地址,而且是数组首元素的地址,我们来做个测试。
1 |
|
运行后,我们发现两者的结果是一致的
数组名的例外
数组名如果是数组首元素的地址,那下面的代码怎么解释呢?
1 |
|
输出的结果是:40,如果arr是数组首元素的地址,那输出应该是4 or 8才对(不同系统区分字节情况不同)
其实数组名就是数组首元素(第一个元素)的地址是对的,但是有两个例外:
- **sizeof(数组名)**,sizeof中单独放数组名,这里的数组名表示整个数组,计算的是整个数组的大小,单位是字节
- &数组名,这里的数组名表示整个数组,取出的是整个数组的地址(整个数组的地址和数组首元素的地址是有区别的)
除此之外,任何地方使用数组名, 数组名都表示首元素的地址。
&arr+1是跳过整个数组的,例如:
1 |
|
一维数组传参
当我们已经有了一维数组,我们对一维数组的处理是否可以使用函数呢?这样就涉及到将一维数组传递给函数,就是一维数组传参。
比如:我们现在想写一个函数打印一个整型数组的内容。
1 |
|
在数组传参的时候,传递的是数组名,也就是说本质上数组传参本质上传递的是数组首元素的地址。
通过sizeof 计算的是一个地址的大小,而不是数组的大小。如果我们想在函数内部计算数组的长度,是没办法实现的,因为函数的参数部分的本质是指针。
总结:⼀维数组传参,形参的部分可以写成数组的形式,也可以写成指针的形式。
冒泡排序
1 |
|
二级指针
指针变量也是变量,是变量就有地址,拿指针变量的地址存放在哪里?
这就是二级指针
。
对于二级指针的运算有
*ppa
通过对ppa中的地址进行解引用,这样找到的就是pa
,*ppa
其实访问的就是pa
**ppa
先通过*ppa
找到pa
,然后对pa
进行解引用操作:*pa
,那找到的是a
.
1 |
|
指针数组
指针数组是指针还是数组?
我们类比一下,整型数组,是存放整型的数组,字符数组是存放字符的数组。
那指针数组呢?是存放指针的数组。
指针数组的每个元素是地址,⼜可以指向⼀块区域。
指针数组模拟二维数组
1 |
|
在数组中存储对应的数组地址即可。
数组指针变量
之前我们学习了,指针数组,指针数组是一种数组,数组中存放的是地址(指针)。
数组指针变量是指针变量?还是数组?
答案是:指针变量。
我们已经熟悉:
- 整型指针变量:
int * pint;
存放的是整型变量的地址,能够指向整型数据的指针。 - 浮点型指针变量:
float * pf;
存放的是浮点型变量的地址,能够指向浮点型数据的指针。
那数组指针变量是:存放数组的地址,能够指向数组的指针变量。
1 | int * p1[10]; // 指针数组 |
解释:p先和*结合,说明p是一个指针变量,然后指针指向的是一个大小为10个整型的数组。所以p是一个指针,指向一个数组,叫数组指针变量。
这里要注意:[]的优先级是高于*号的,必须加上()来保证p先和*
结合。
数组指针变量是用来存放数组地址的,那怎么获得数组的地址呢?就是我们之前使用的&数组名
。
1 | int arr[10] = 0; |
如果要存放数组的地址,就得存放在数组指针变量中,如下:
1 | int(*p)[10] = &arr; |
二维数组传参本质
首先我们再次理解一下二维数组,二维数组其实可以看做是每个元素是一维数组的数组,也就是二维数组的每个元素是一个一维数组。那么二维数组的首元素就是第一行,是一个一维数组。
如下图:
所以,根据数组名是数组首元素的地址这个规则,二维数组的数组名表示的就是第一行的地址,是一维数组的地址。根据上面的例子,第一行的一维数组的类型就是int [5]
,所以第一行的地址的类型就是数组指针类型int(*)[5]
。那就意味着二维数组传参本质上也是传递了地址,传递的是第一行这个一维数组的地址,那么形参也是可以写成指针形式的。如下:
1 |
|
函数指针变量
函数的地址
我们知道变量是有地址的,数组是有地址的,那么函数是否有地址呢?
其实函数也是有地址的,我们做个测试:
1 |
|
确实打印出来了地址,所以函数是有地址的,函数名就是函数的地址,当然也可以通过&函数名
的方式获得函数的地址。
函数指针变量
什么是函数指针变量呢?
函数指针变量是用来存放函数地址的,未来通过地址能够调用函数的。
如果我们要将函数的地址存放起来,就得创建函数指针变量了,函数指针变量的写法其实和数组指针非常类似。
空参
1 |
|
带参
1 |
|
1 | int (*pf3) (int x, int y) |
调用方式
1 |
|
typedef关键字
typedef是用来给类型重命名的,可以将复杂的类型简单化。
比如,你觉得unsigned int
写起来不方便,我们可以这样写:
1 |
|
那么指针类型呢?
1 | typedef int* ptr_t; |
但是对于数组指针和函数指针稍微有点区别:
比如我们有数组指针类型int(*)[5]
,需要重命名为parr_t
,那可以这样写:
1 | typedef int(*parr_t)[5]; // 新的类型名必须在*的右边 |
函数指针类型的重命名也是一样的,比如,将void(*)(int)
类型重命名为pf_t
,可以这样写:
1 | typedef void(*pf_t)(int); // 新的类型名必须在*的右边 |
回调函数
回调函数就是一个通过函数指针调用的函数。
如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,被调用的函数就是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另一方调用的,用于对该事件或条件进行响应。
举例说明:
⼀家酒店,为了更好的服务客⼾,想了⼀个”叫醒服务”,客⼾可以登记:在什么时间,⽤什么样的⽅法来叫醒⾃⼰。
⽐如:
这⾥边,客⼾名字、时间和叫醒⽅式是客⼾在酒店前台登记的,当到了时间酒店就会按照登记的⽅式去执⾏叫醒服务。
如果使⽤代码去完成这个逻辑就可以使⽤回调函数。
1 |
|
通过传入不同函数的地址,就实现不同函数的情况。
这些叫醒⽅式的函数就被称为回调函数。
qsort函数
函数介绍
1 | void qsort(void* base, // 指向待排序数据的起始位置的指针 |
- qsort是⼀个直接可以使⽤的库函数
- qsort函数底层使⽤的快速排序算法
- qsort函数默认排序结果是升序还是降序取决于第4个参数
- qsort函数,只要给定2个数的⽐较⽅法,可以排序任意类型的数据
使用qsort函数排序整型数据
1 |
|
使用qsort排序结构体数据
1 |
|
字符分类函数
C语言中有一系列的函数是专门做字符分类的,也就是一个字符是属于什么类型的字符的。
这些函数的使用都需要包含一个头文件ctype.h
。
这些函数的使用方法非常类似,这里讲解一个
1 | int islower(int c); |
islower
是能够判断参数部分的c
是否是小写字母的。
通过返回值来说明是否是小写字母,如果是小写字母就返回非0整数,如果不是小写字母则返回0。
练习:
写一个代码,将字符串之后的小写字母转大写,其他字符不变。
1 |
|
字符转换函数
C语言提供了2个字符转换函数:
1 | int tolower(int c); // 将参数传进去的大写字母转小写 |
其实就是转换一下,用法没区别。
strlen
strlen的使用
1 | size_t strlen(const char * str); |
- 字符串以
'\0'
作为结束标志,strlen函数返回的是在字符串中'\0'
前面出现的字符个数(不包括'\0'
)。 - 参数指向的字符串必须要以
'\0'
结束。 - 注意函数的返回值为size_t,是无符号的
- strlen的使用需要包含头文件
1 |
|
练习:strlen返回值
1 |
|
strlen模拟实现
1 |
|
1 |
|
1 |
|
strcpy
1 | char * strcpy(char * destination,const char * source); |
功能:字符串拷贝,从source拷贝到destination,拷贝的内容到source中的\0
为止
参数:
destination
:指针,指向目的地空间
source
:指针,指向源头数据
返回值:
strcpy
返回目标空间的起始地址
演示
1 |
|
使用注意事项:
- 源字符串必须以
\0
结束。 - 会将源字符串中的
\0
拷贝到目标空间。 - 目标空间必须足够大,以确保能存放源字符串。
- 目标空间必须可修改。
模拟实现
1 |
|
strcat
1 | char * strcat(char * destination,const char * source); |
功能:字符串追加,把source
指向的源字符串中的所有字符都追加到destination
指向的空间中。
参数:
destination
:指针,指向目的地空间source
:指针,指向源头数据
返回值:
strcat
函数返回的目标空间的起始地址
演示
1 |
|
使用注意事项:
- 源字符串必须以
\0
结束。 - 目标字符串中也得有
\0
,否则没办法知道追加从哪里开始。 - 目标空间必须有足够的大,能容纳下源字符串的内容。
- 目标空间必须可修改。
模拟实现
1 |
|
strcmp
1 | int strcmp(const char * str1,const char * str2); |
功能:用来比较str1
和str2
指向的字符串,从两个字符串的第一个字符开始比较,如果两个字符的ASCII码值相等,就比较下一个字符。直到遇到不相等的两个字符,或者字符串结束。
参数:
str1
:指针,指向要比较的第一个字符串
str2
:指针,指向要比较的第二个字符串
返回值:
- 标准规定:
- 第一个字符串大于第二个字符串时,返回大于0的数字
- 第一个字符串等于第二个字符串时,返回0
- 第一个字符串小于第二个字符串,则返回小于0的数字
代码演示
1 |
|
模拟实现
1 |
|
strncpy
1 | char * strncpy(char * destination,const char * source,size_t num); |
功能:字符串拷贝;将source
指向的字符串拷贝到destination
指向的空间中,最多拷贝num
个字符。
参数:
destination
:指针,指向目的地空间
source
:指针,指向源头数据
num
:从source指向的字符串中最多拷贝的字符个数
返回值:
strncpy
函数返回的目标空间的起始地址
strcpy函数拷贝到\0
为止,如果目标空间不够的话,容易出现越界行为。
strncpy函数指定了拷贝的长度,源字符串不一定要有\0
,同时在设计参数的时候,就会多一层思考:目标空间的大小是否够用,strncpy相对strcpy更加安全。
strncat
1 | char * strncat(char * destination,const char * source,size_t num); |
功能:字符串追加;将source
指向的字符串内容,追加到destination
指向的空间,最多追加num
个字符。
参数:
destination
:指针,指向了目标空间
source
:指针,指向了源头数据
num
:最多追加的字符的个数
返回值:返回的是目标空间的起始地址
代码演示
1 |
|
strcat和strncat对比
- 参数不同,strncat多了一个参数
- stcat函数在追加的时候要将源字符串的所有内容,包含
\0
都追加过去,但是strncat函数指定了追加的长度。 - strncat函数中的源字符串中不一定要有
\0
了。 - strncat更加灵活,也更加安全。
strncmp
1 | int strncmp(const char * str1,const char * str2,size_t num); |
功能:字符串比较;比较str1
和str2
指向的两个字符串的内容,最多比较num
字符。
参数:
str1
:指针,指向一个比较的字符串
str2
:指针,指向另外一个比较的字符串
num
:最多比较的字符个数
返回值:
- 标准规定:
- 第一个字符串大于第二个字符串,则返回大于0的数字
- 第一个字符串等于第二个字符串,则返回0
- 第一个字符串小于第二个字符串,则返回小于0的数字
代码演示
1 |
|
strstr
1 | char * strstr(const char * str1,const char * str2); |
功能:
strstr
函数,查找str2
指向的字符串在str1
指向字符串中第一次出现的位置。
简而言之:在一个字符串中查找子字符串第一次出现的位置。
strstr
的使用得包含<string.h>
参数:
str1
:指针,指向了被查找的字符串
str2
:指针,指向了要查找的字符串
返回值:
- 如果str1指向的字符串中存在str2指向的字符串,那么返回第一次出现位置的指针
- 如果str1指向的字符串中不存在str2指向的字符串,那么返回NULL
1 |
|
strerror
1 | char * strerror(int errnum); |
功能:
strerror
函数可以通过参数部分的errnum
表示错误码,得到对应的错误信息,并且返回这个错误信息字符串首字符的地址。strerror
函数只针对标准库中的函数发生错误后设置的错误码的转换。strerror
的使用需要包含<string.h>
参数:
errnum
:表示错误码
这个错误码一般传递的是errno
这个变量的值,在C语言有一个全局的变量叫errno
,当库函数的调用发生错误的时候,就会将本次错误的错误码存放在errno
这个变量中,使用这个全局变量需要包含一个头文件errno.h
。
返回值:
函数返回通过错误码得到的错误信息字符串的首字符的地址。
代码演示
1 |
|
memcpy
1 | void * memcpy(void * destination,const void * source,size_t num); |
功能:
- memcpy是完成内存块拷贝的,不关注内存中存放的数据是啥
- 函数
memcpy
从source
的位置开始向后复制num
个字节的数据到destination
指向的内存位置。 - 如果source和destination有任何重叠,复制的结果都是未定义的。(内存重叠的情况使用
memmove
就行) - memcpy的使用需要包含
<string.h>
参数:
destination
:指针,指向目标空间,拷贝的数据存放在这里
source
:指针,指向源空间,要拷贝的数据从这里来
num
:要拷贝的数据占据的字节数
返回值:
拷贝完成后,返回目标空间的起始地址
代码演示
1 |
|
模拟实现
1 |
|
memmove
1 | void * memmove(void * destination,const void * source,size_t num); |
功能:
- memmove函数也是完成内存块拷贝的
- 和memcpy的差别就是memmove函数处理的源内存块和目标内存块是可以重叠的。
- memmove的使用需要包含
<string.h>
参数:
destination
:指针,指向目标空间,拷贝的数据存放在这里
source
:指针,指向源空间,要拷贝的数据从这里来
num
:要拷贝的数据占据的字节数
返回值:
拷贝完成后,返回目标空间的起始地址
1 |
|
memset
1 | void * memset(void * ptr,int value,size_t num); |
功能:
memset
函数是用来设置内存块的内容的,将内存中指定长度的空间设置为特定的内容。memset
的使用需要包含<string.h>
参数:
ptr
:指针,指向要设置的内存空间,也就是存放了要设置的内存空间的起始地址。
value
:要设置的值,函数将会把value值转换为unsigned char
的数据进行设置的。也就是以字节为单位来设置内存块的。
num
:要设置的内存长度,单位是字节。
返回值:返回的是要设置的内存空间的起始地址。
1 |
|
memcmp
1 | int memcmp(const void * ptr1,const void * ptr2,size_t num); |
功能:
比较指定的两块内存块的内容,比较从ptr1和ptr2指针指向的位置开始,向后num个字节
memcmp
的使用需要包含<string.h>
参数:
ptr1
:指针,指向一块待比较的内存块
ptr2
:指针,指向另外一块待比较的内存块
num
:指定的比较长度,单位是字节
1 |
|
如果要⽐较2块内存单元的数据的⼤⼩,可以使⽤ memcmp 函数,这个函数的特点就是可以指定⽐较⻓度。
atoi
1 | int atoi(const char * str); |
功能:
- 解析字符串内容,将字符串转化为整数
atoi
的使用得包含<stdlib.h>
参数:
str
:指针,指向了待转换的字符串。
返回值:转换成功的话,返回转换得到整数,如果是空字符串或者是跳过空白字符后第一个不是数字字符将返回0,如果转化得到数字超出int的取值范围,则是未定义的行为。
1 |
|
atof
1 | double atof(const char * str); |
功能:
atof
函数用于将字符串转换为浮点数(double
类型)。- 它解析字符串中的数字部分,并将其转换为相应的浮点数表示。
- 该函数在
<stdlib.h>
头文件中声明。
参数:
str
:指针,指向待转换的字符串,它可以包含可选的正负号、整数部分、小数点和指数部分。
返回值:
atof
函数的返回值类型是double
,表示成功转换的浮点数。
如果无法转换(例如,字符串不是有效浮点数),则返回值为0.0
。
1 |
|
结构体
C语言已经提供了内置类型,如:char、short、int、long、float、double等,但是只有这些内置类型是不够的,假设我想描述学生,描述一本书,这时单一的内置类型是不行的。描述一个学生需要姓名、年龄、学号等等;描述一本书需要作者、出版社、定价等。C语言为了解决这个问题,增加了结构体这种自定义的数据类型,让程序员可以自己创造适合的类型。
结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量,如:标量、数组、指针,甚至是其他结构体。
结构类型的声明
1 | struct tag |
描述一个学生
1 | struct Stu |
结构体变量的定义和初始化
1 | // 代码1:变量的定义 |
结构成员访问操作符
结构体成员的直接访问
结构体成员的直接访问是通过点操作符(.)访问的。点操作符接受两个操作数。如下所示:
1 |
|
使用方式:结构体变量.成员名
结构体成员的间接访问
有时候我们得到的不是一个结构体变量,而是得到了一个指向结构体的指针。如下所示:
1 |
|
使用方式:结构体指针->成员名
结构体内存对齐
问题:计算一下,下面结构类型struct S1
的大小
1 | struct S1 |
一般我们能想到的就是c1是字符占1个字节,i是整型占4个字节,c2是字符占1个字节,总共6个字节,但是实际我们在编译器测试,并非6个字节,这是因为结构体的成员在内存中不一定连续存放,而是存放在一些对齐边界上的。那结构体是如何对齐的呢?
对齐规则
首先得掌握结构体的对齐规则:
对齐数 = 编译器默认的一个对齐数 与该成员变量大小的较小值。
VS 中的默认的值为 8
Linux 中 gcc 没有默认对齐数,对齐数就是成员自身的大小
- 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
- 结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍
- 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
为什么存在内存对齐
大部分参考资料都是这样说的:
- 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取得某些特定类型的数据,否则抛出硬件异常。
- 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存仅需要一次访问。假设一个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证所有的double类型的数据的地址都对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
总体来说:结构体的内存对齐是拿空间来换取时间的做法。
位段
什么是位段
位段的声明和结构是类似的,有两个不同:
- 位段的成员必须是
int、unsigned int或signed int
,在C99中位段成员的类型也可以选择其他类型。 - 位段的成员名后边有一个冒号和一个数字。
比如:
1 | struct A |
A就是一个位段类型。
那位段所占内存的大小是多少?
1 |
|
如果我们是结构体的话,所占内存的大小是多少呢?
1 |
|
如果我们观察输出的结果,可以明显发现位段式的结构体⼤⼩是明显⼩于同样成员的普通结构体的⼤⼩的,其实位段的出现主要是在完成功能的情况下尽量的节省内存。
但是位段在节省内存的情况下也带来了⼀些问题,⽐如跨平台型问题。
位段的跨平台问题
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大 32,写成27,在16位机器会出问题)。
- 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
总结:
跟结构相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
联合体-union
联合体类型的声明
像结构体一样,联合体也是由一个或多个成员构成,这些成员可以是不同的类型。
但是编译器只为最大的成员分配足够的内存空间。联合体的特点是所有成员共用一块内存空间,所以联合体也叫:共用体。
给联合体其中一个成员赋值,其他成员的值也跟着变化。
1 |
|
联合体的特点
联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)。
1 | // code 1 |
1 | // code 2 |
code1的输出结果:
1 | 00D3FB1C |
code2的输出结果:
1 | 11223355 |
代码1输出的三个地址一模一样,代码2的输出,我们发现将i的第4个字节的内容修改为55了。
结构体和联合体对比
联合体大小的计算
- 联合的大小至少是最大成员的大小
- 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。
1 |
|
枚举
枚举类型
枚举类型的声明和使用
枚举顾名思义就是一一列举,把可能的取值一一列举。
比如我们现实生活中:
- 一周的7天
- 性别
- 月份
- ……
这些数据的表示就可以使用枚举了。
枚举在C语言中通常用于提高代码的可读性,使得程序员可以使用更加有意义的符号代表一组相关的常量值,而不必记住这些常量对应的具体数值。
1 | enum Sex // 性别 |
{}中的内容是枚举类型的可能取值,也叫枚举常量
。
这些可能取值都是有值的,默认从0开始,依次递增1,当然在声明枚举类型的时候也可以赋予初值。
1 | enum Color // 颜色 |
那是否可以拿整数给枚举变量赋值呢?在C语⾔中是可以的。
我们可以使用#define
定义常量,为什么非要使用枚举来定义常量呢?
枚举的优点:
- 增加代码的可读性和可维护性
- 和#define定义的标识符比较枚举有类型检查,更加严谨。
- 便于调试,预处理阶段会删除
#define
定义的符号 - 使用方便,一次可以定义多个常量
- 枚举常量是遵循作用域规则的,枚举声明在函数内,只能在函数内使用
动态内存管理
为什么要有动态内存分配
我们已经掌握的内存开辟方式有:
1 | int val = 20; // 在栈空间上开辟四个字节 |
但是上述的开辟空间的方式有两个特点:
- 空间开辟大小是固定的。
- 数组在申明的时候,必须指定数组的长度,数组空间一旦确定了大小不能调整。
但是对于空间的需求,不仅仅是上述的情况。有时候我们需要的空间大小在程序运行的时候才能知道,那数组的编译时开辟空间就不能满足了。
C语言引入了动态内存开辟,让程序员自己可以申请和释放空间,就比较灵活了。
malloc和free
malloc
C语言提供了一个动态内存开辟的函数:
1 | void * malloc(size_t size); |
这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。
- 如果开辟成功,则返回一个指向开辟好空间的指针。
- 如果开辟失败,则返回一个
NULL
指针,因此malloc的返回值一定要做检查。 - 返回值的类型是
void*
,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。 - 如果参数
size
为0,malloc的行为是标准是未定义的,取决于编译器。
free
C语言提供了另一个函数free,专门是用来做动态内存的释放和回收的,函数原型如下:
1 | void free(void * ptr); // 传过去是要释放的空间的起始地址 |
free函数用来释放动态开辟的内存。
- 如果参数
ptr
指向的空间不是动态开辟的,那free函数的行为是未定义的。 - 如果参数
ptr
是NULL指针,则函数什么都不做。
malloc和free声明在stdlib.h
头文件中。
举个栗子:
1 |
|
calloc
C语言还提供了一个函数叫calloc
,calloc
函数也用来动态内存分配。原型如下:
1 | void * calloc(size_t num,size_t size); |
- 函数的功能是为
num
个大小为size
的元素开辟一块空间,并把空间每个字节初始化为0。 - 与函数
malloc
的区别只在于calloc
会在返回地址之前把申请的空间每个字节初始化为全0。
举个栗子:
1 |
|
如果我们对申请的内存空间的内容要求初始化,那么可以很方便的使用calloc函数来完成任务。
realloc
- realloc函数的出现让动态内存管理更加灵活。
- 有时我们发现过去申请的空间太小或太大了,那为了合理的使用内存,我们一定会对内存的大小做灵活的调整。那
realloc
函数就可以做到对动态开辟内存大小的调整。
函数原型如下:
1 | void * realloc(void * ptr,size_t size); |
ptr
是要调整的内存空间的起始地址size
调整之后新大小- 返回值为调整之后的内存空间的起始位置。
- 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到
新
的空间。 - realloc在调整内存空间的时候存在两种情况:
- 情况1:原有空间之后有足够大的空间
- 情况2:原有空间之后没有足够大的空间
当情况1的时候,要拓展内存就直接原有内存之后追加空间,原来空间的数据不发生变化。
当情况2的时候,原有空间之后没有足够多的空间时,拓展的方法是:在堆空间上另找一个合适大小的连续空间使用。这样函数返回的是一个新的内存地址。
常见的动态内存的错误
对NULL指针的解引用操作
1 | void test() |
对动态开辟空间的越界访问
1 | void test() |
对非动态开辟内存使用free释放
1 | void test() |
使用free释放一块动态开辟内存的一部分
1 | void test() |
对同一块动态内存多次释放
1 | void test() |
动态开辟内存忘记释放(内存泄漏)
1 | void test() |
忘记释放不再使用的动态开辟的空间会造成内存泄漏。
切记:动态开辟的空间一定要释放,并且正确释放。
链表
什么是数据结构
数据结构是由数据
和结构
两词组合而来。
什么是数据
常见的数值包括1234、1、2、3、4等、网上肉眼可以看见的信息(文字、图片、视频等),这些都是数据。
什么是结构
当我们想要使用大量同一类型的数据时,通过定义大量的独立的变量,可读性非常差,而且非常不方便,这时我们可以借助数组这样的数据结构将大量的数据组织在一起。结构也可以理解为组织数据的方式。
生活中也有这样的例子:
- 想要从草原上找到名为”小白”的羊很难
- 但是从羊圈找到1号羊就很简单,羊圈这样的结构有效的将羊群组织起来。
概念:数据结构是计算机的内存中存储和组织数据的方式。
常见的数据结构分类
数据结构一般根据组织形式,分为:线性数据结构和非线性数据结构。
线性的数据结构有:数组、链表、栈和队列等。
非线性的数据结构有:树、散列表、堆、图等
链表的概念
链表是一种线性数据结构,由一系列节点组成,每个节点包含数据和指向下一个节点的指针。
链表中的元素在内存中不必顺序排列,而是通过指针相互连接。
链表的结构跟火车车厢相似,淡季时车次的车厢会相应减少,旺季时车次的车厢会额外增加几节。只需要将火车上的某节车厢去掉/加上,不会影响其他车厢,每节车厢都是独立存在的。
车厢是独立存在的,且每节车厢都有车门,想象一下这样的场景,假设每节车厢的车门都是锁上的状态,需要不同的钥匙才能解锁,每次只能携带一把钥匙的情况下如何从车头走到车尾?
最简单的做法:每节车厢里都放一把下一节车厢的钥匙。
火车的每节车厢就相当于链表的一个节点。
在链表里,每个节点(车厢)是什么样的呢?
图中指针变量plist
保存的是第一个节点的地址,我们称plist
此时指向第一个节点,如果我们希望plist指向第二个节点时,只需要将plist保存的内容修改为2的地址即可,即修改为0x0012FFA0
。
问:为什么还需要指针变量来保存下一个节点的位置?
链表中每个节点都是独立申请的(即需要插入数据时才去申请一块节点的空间),我们需要通过指针变量来保存下一个节点位置才能从当前节点找到下一个节点。
链表的结构
链表的基本结构由节点组成,每个节点包含数据和指向下一个节点的指针。
1 | typedef struct Node |
data是存放数据的,Node来指向下一个节点
链表的分类
链表可以分为单向链表、双向链表和循环链表。
- 单向链表:每个节点只有一个指针指向下一个节点。
- 双向链表:每个节点有两个指针,分别指向前一个节点和后一个节点。
- 循环链表:尾节点指向头节点。
单链表
单链表动态申请节点并初始化
1 | typedef struct Node |
链表元素的打印
打印链表的所有节点数据。
1 | void printList(Node* cur) |
测试
1 |
|
单链表的头部插入元素
在链表的头部插入节点。
1 | void PushFront(Node** pphead,int data) |
单链表的尾部插入元素
在链表的尾部插入节点
1 | void PushBack(Node** pphead,int x) |
单链表头部删除元素
1 | void PopFront(Node** pphead) |
单链表尾部删除元素
1 | void PopBack(Node** pphead) |
链表删除指定元素
1 | void deleteNode(Node** pphead,int key) |
文件、流、标准流
在计算机编程中,文件、流和标准流是常用的概念,用于处理输入和输出。这里将介绍文件、流、标准流的概念及其在C语言中的应用。
文件
文件是存储在计算机存储设备(如硬盘、闪存等)上的一组数据,可以持久保存。文件可以分为文本文件和二进制文件两种类型。
- 文本文件:由字符组成,可以使用文本编辑器打开查看和编辑。
- 二进制文件:以字节为单位存储,存储的是二进制的信息,不便于直接查看和编辑
流
我们程序中产生的数据需要输出到各种外部设备,也需要从外部设备获取数据,不同的外部设备的输入输出操作各不相同,为了方便程序员对各种设备进行方便的操作,我们抽象出了流的概念,我们可以把流想象成流淌着字符的河。
C程序针对文件、画面、键盘等的数据输入输出操作都是通过流操作的。
一般情况下,我们想向流里写数据,或者从流中读取数据,都是要打开流,然后操作,最后关闭流。
流是一种抽象的概念,用于在程序中处理输入和输出。流提供了一种顺序访问数据的方式,可以是从文件、内存、网络等不同来源获取数据,也可以将数据发送到不同的目的地。
C语言中的流可以分为输入流和输出流两种类型。
- 输入流(Input Stream):用于从外部获取数据,常见的输入流包括标准输入流
stdin
和文件输入流。 - 输出流(Output Stream):用于将数据发送到外部,常见的输出流包括标准输出流
stdout
和文件输出流。
标准流
那为什么我们从键盘输入数据,向屏幕输出数据,并没有打开流呢?
那是因为C语言程序在启动的时候,默认打开了3个流:
- stdin - 标准输入流,在大多数的环境中从键盘输入,scanf函数就是从标准输入流中读取数据。
- stdout - 标准输出流,大多数环境中输出至显示器页面,printf函数就是将信息输出到标准输出流中。
- stderr - 标准错误流,大多数环境中输出到显示器页面。
这是默认打开了这三个流,我们使用scanf、printf等函数就可以直接进行输入输出操作的。
C语言中,就是通过FILE*
的文件指针来维护流的各种操作的。
文件信息区和文件指针
前面的内容提到了FILE*
的指针类型,我们称为文件类型指针
,简称文件指针
。每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名字,文件状态及文件当前的位置等)。这些信息是保存在一个结构体变量中的。该结构体类型是由系统声明的,取名FILE.
例如,VS2013编译环境提供的stdio.h
头文件中有以下的文件类型声明:
1 | struct _iobuf{ |
不同的C编译器的FILE类型包含的内容不完全相同,但是大同小异。
每当打开一个文件的时候,系统会根据文件的情况自动创建一个FILE结构的变量,并填充其中的信息,使用者不必关心细节。
一般都是通过一个FILE的指针来维护这个FILE结构的变量,这样使用起来更加方便。
下面我们可以创建一个FILE*的指针变量:
1 | FILE * pf; // 文件指针变量 |
定义pf
是一个指向FILE类型数据的指针变量。可以使pf
指向某个文件的文件信息区(是一个结构体变量)。通过该文件信息区中的信息就能访问该文件。也就是说,通过文件指针变量能够简洁找到与它关联的文件。
比如:
fopen和fclose
文件在读写之前先打开文件,在使用结束之后应该关闭文件
ANSI C规定使用fopen
函数来打开文件,fclose
来关闭文件。
fopen
函数原型:
1 | FILE* fopen(const char * filename,const char * mode) |
功能:根据文件的名字和打开方式,打开一个文件
参数:
- filename:指针,传递需要打开的文件的名字
- mode:指针,表示打开文件的方式
返回值:如果打开文件成功返回文件信息区的地址,如果打开失败返回NULL,因为文件打开可能会失败,所以在调用完fopen函数后,一定要判断其返回值。
以下是文件的打开模式:
文件使用方式 | 含义 | 如果指定文件不存在 |
---|---|---|
r(只读) | 为了输入数据,打开一个已经存在的文本文件 | 出错 |
w(只写) | 为了输出数据,打开一个文本文件 | 建立一个新的文件 |
a(追加) | 向文本文件尾添加数据 | 建立一个新的文件 |
rb(只读) | 为了输入数据,打开一个二进制文件 | 出错 |
wb(只写) | 为了输出数据,打开一个二进制文件 | 建立一个新的文件 |
ab(追加) | 向一个二进制文件尾添加数据 | 建立一个新的文件 |
r+(读写) | 为了读和写,打开一个文本文件 | 出错 |
w+(读写) | 为了读和写,建立一个新的文件 | 建立一个新的文件 |
a+(读写) | 打开一个文件,在文件尾进行读写 | 建立一个新的文件 |
rb+(读写) | 为了读和写打开一个二进制文件 | 出错 |
web+(读写) | 为了读和写,新建一个新的二进制文件 | 建立一个新的文件 |
ab+(读写) | 打开一个二进制文件,在文件尾进行读和写 | 建立一个新的文件 |
fclose
函数原型:
1 | int fclose(FILE * stream); |
功能:关闭与参数stream关联的文件流,在关闭文件之前会刷新缓冲区。
参数:
- stream:这是指向FILE对象的指针,该FILE对象指定了要被关闭的流。
返回值:如果流成功关闭,则该方法返回零。如果失败,则返回EOF。
栗子
1 |
|
在打开文件的时候,文件名字中可以加入文件所在的路径,可以是相对路径,也可以是绝对路径。
fgetc和fputc
在进行文件操作的时候,我们需要向文件中写入字符,从文件中读取字符,这时就可以使用fgetc
和fputc
来完成任务。
fegtc
函数介绍
1 | int fgetc(FILE * stream); |
功能:函数用于从指定的文件流中读取一个字符,并将其返回为一个无符号字符。
参数:
stream
:指向FILE类型结构体的指针,指定了要读取字符的文件流。
返回值:返回读取的字符,返回的是字符的ASCII码值。如果到达文件末尾或者发生错误,返回EOF
,即(-1)
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fgetc
之前,需要确保文件已经以可读方式打开。 - 返回的字符是以
unsigned char
形式返回,但是它会被转换为int
类型以支持特殊值EOF
。 - 文件指针会随着每次读取的调用而向前移动。
1 |
|
fputc
函数介绍
1 | int fputc(int c,FILE * stream); |
功能:函数用于向指定的文件流中写入一个字符。
参数:
c
:要写入的字符,以整数的形式给出。stream
:指向FILE类型结构体的指针,指定了要写入字符的流。
返回值:如果成功写入字符,则返回写入的字符,以无符号字符表示。如果发生错误,返回EOF
,即-1
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fputc()之前
,需要确保文件已经以可写方式打开。 - 文件指针会随着每次写入的调用而向前移动。
1 |
|
补充
当fgetc
的参数是stdin
的时候,函数是在标准输入(键盘)上读取字符。
当fputc
的参数stdout
的时候,函数是向标准输出(屏幕)上写字符。
fgets和fputs
在进行文件操作的时候,我们需要向文件中写入一行字符,从文件中读取一行字符,这时就可以使用fgets
和fputs
来完成。
fgets
1 | char * fgets(char * str,,int n,FILE * stream); |
功能:函数用于从指定的文件流中读取一行文本,并将其存储为一个字符串。
参数:
str
:指向一个字符数组的指针,用于存储读取的文本内容。该数组必须足够大以容纳最大长度n-1
的字符串,因为函数会在末尾添加一个\0
终止符。n
:要读取的最大字符数,包括末尾的\0
终止符。如果读取的行超过n-1
个字符,则剩余的字符会被截断。stream
:指向FILE类型结构体的指针,指定了要读取的文件流。
返回值:
char *
:如果成功读取到一行文本,则返回str
参数的值。如果到达文件末尾或者发生错误,则返回NULL
。
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fgets()
之前,需要确保文件已经以可读方式打开。 - 函数会将包括换行符在内的整行文本读取到
str
指向的缓冲区中,但会在末尾添加\0
终止符 - 如果一行文本的字符数不超过
n-1
,则整行文本以及末尾的换行符都会被读取并存储。 - 如果一行文本的字符数超过
n-1
,则前n-1
个字符会被读取并存储,剩余的字符会被截断。 - 如果文件中没有更多的行可读,则
fgets()
返回NULL
代码演示
1 |
|
fputs
函数介绍
1 | int fputs(const char *str,FILE * stream); |
功能:函数用于将一个字符串写入到指定的文件流中,直到遇到\0
为止。
参数:
str
:要写入的字符串stream
:指向FILE类型结构体的指针,指定了要写入字符串的文件流。
返回值:
int
:如果成功写入字符串,则返回非负值(通常为0)。如果发生错误,返回EOF
,即(-1)
。
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fputs()
之前,需要确保文件已经以可写方式打开。 fputs()
写入的字符串不会自动添加换行符,需要手动添加\n
,如果需要。
代码示例
1 |
|
fscanf和fprintf
如果我们需要向文件中写入和读取的不仅仅是字符,而是有其他类型的文本数据,那就得考虑fscanf函数和fprintf函数。
fscanf
函数介绍
1 | int fscanf(FILE * stream,const char * format,...) |
功能:函数用于从指定流中读取格式化输入,并根据指定的格式化字符串将输入数据解析为相应的数据类型。
参数:
stream
:指向FILE类型结构体的指针,指定了要读取的文件流。format
:格式化字符串,指定了要读取的输入数据的格式,类似于scanf()
中的格式化字符串。...
:可变数量的参数,根据format
字符串中指定的格式,用于接收解析后的数据。
返回值:
int
:返回成功读取和匹配的参数个数。如果达到文件末尾或者发生读取错误,则返回EOF
,即(-1)
。
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fscanf()
之前,需要确保文件已经以可读方式打开。 format
字符串中可以包含各种格式标识符,如%d
(整数)、%f
(浮点数)、%s
(字符串)等。- 与
scanf()
不同,fscanf()
是从指定的流中读取数据,可以是标准输入流。也可以是文件流。
假设我们有一个这样的文件
1 | hello 3.3 5 |
1 |
|
fprintf
函数介绍
1 | int fprintf(FILE * stream,const char * format,...); |
功能:fprintf()
函数用于将格式化数据写入到指定的流中。
参数:
stream
:指向FILE类型结构体的指针,指定了要写入的流。format
:格式化字符串,指定了要写入的输出数据的格式,类似于printf()
中的格式化字符串。...
:可变数量的参数,根据format
字符串中指定的格式,用于提供写入的数据。
返回值:int
:返回写入的字符数。如果发生错误,返回负值。
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fprintf()
之前,需要确保文件已经以可写方式打开。 format
字符串中科院包含各种格式标识符,如%d
(整数)、%f
(浮点数)、%s
(字符串)等。fprintf()
将输出写入到指定的流中,可以是标准输出流,也可以是文件流。
1 |
|
fread和fwrite
fwrite
函数介绍
1 | size_t fwrite(const void *ptr,size_t size,size_t nmemb,FILE * stream); |
功能:函数用于将数据块写入文件流中,是以2进制的形式写入的。
参数:
ptr
:指向要写入的数据块的指针。size
:要写入的每个数据项的大小(以字节为单位)。nmemb
:要写入的数据项的数量。stream
:指向FILE类型结构体的指针,指定了要写入数据的文件流。
返回值:返回实际写入的数据项数量。如果发生错误,则返回值可能小于nmemb
。
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fwrite()
之前,需要 确保文件已经以二进制可写方式打开。 fwrite
通常用于二进制数据的写入,如果写入文本数据,请谨慎处理换行符和编码等问题。
代码演示
假设要将一组整数写入到文件中:
1 |
|
fread
函数介绍
1 | size_t fread(void *ptr,size_t size,size_t nmemb,FILE * stream); |
功能:函数用于从文件中读取数据块,并将其存储到内存缓冲区中。
参数:
ptr
:指向内存区域的指针,用于存储从文件中读取的数据。size
:要读取的每个数据块的大小(以字节为单位)。nmemb
:要读取的数据块的数量。stream
:指向FILE类型结构体的指针,指定了要从中读取数据的文件流。
返回值:返回实际读取的数据块数量。
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fread()
之前,需要确保文件已经以二进制可读方式打开。 ptr
指向的内存区域必须足够大,以便存储指定数量和大小的数据块。- 如果
fread()
成功读取了指定数量的数据块,则返回值等于nmemb
;如果读取数量小于nmemb
,则可能已经到达文件末尾或者发生了错误。 - 在二进制文件读取时,
fread()
是常用的函数,但对于文本文件读取,通常使用fgets()
或fscanf()
。
1 |
|
文本文件和二进制文件
根据数据的组织形式,数据文件被称为文本文件或者二进制文件。
数据在内存中以二进制的形式存储,如果不加转换的输出到外存的文件中,就是二进制文件。
如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文本文件。
一个数据在文件中是怎么存储的呢?
字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以使用二进制形式存储。
如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节),而二进制形式素材,则在磁盘上只占4个字节。
ftell
函数介绍
1 | long ftell(FILE *stream); |
功能:函数用于获取文件流的当前位置,即指针相对于文件起始位置的偏移量(以字节为单位)。
参数:
stream
:指向FILE类型结构体的指针,指定了要获取当前位置的文件流。
返回值:long
:返回当前位置相对于文件起始位置的偏移量(以字节为单位)。如果发生错误,返回(-1)
。
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
ftell()
之前,需要确保文件已经以读取或写入方式打开。 - 返回值可以用于后续使用
fseek()
将文件指针移动回到此位置。
1 |
|
fseek
函数介绍
1 | int fseek(FILE * stream,long offset,int origin); |
功能:函数用于设置文件流的位置指针,即将文件流的当前位置移动到指定位置。这个函数通常与ftell()
一起使用,可以在文件中定位到特定的位置进行读取或写入操作。
参数:
stream
:指向FILE类型结构体的指针,指定了要设置位置指针的文件流。offset
:相对于origin
参数的偏移量,以字节为单位。origin
:指定偏移量的起始位置,可以是以下值之一:SEEK_SET
:从文件起始位置开始偏移。SEEK_CUR
:从当前位置开始偏移。SEEK_ENd
:从文件末尾位置开始偏移。
返回类型:
int
:如果成功设置位置指针,则返回0;如果发生错误,返回非零值。
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
fseek()
之前,需要确保文件已经以读取或写入方式打开。 offset
参数可以是正值或负值,表示向前或向后偏移。- 使用
fseek()
之后,应该检查返回值以确保是否发生了错误。
1 |
|
feof
函数介绍
1 | int feof(FILE *stream); |
功能:feof()
函数用于检查文件流的结束标志。它用于确定文件指针是否已经达到文件末尾。
参数:
stream
:指向FILE类型结构体的指针,指定了要检查结束标志的文件流。
返回类型:
int
:如果文件流的结束标志已经设置,则返回非零值;否则返回0。
使用注意事项
- 需要包含
<stdio.h>
头文件。 - 在使用
feof()
之前,需要确保文件已经以读取方式打开。 feof()
一般不直接用于判断文件时是否读取结束,而是已经读取结束后,用于判断是否遇到了文件末尾。因为读取结束也可能是读取时发生了读取的错误等。- 如果文件已经到达末尾,
feof()
返回非零值;否则返回0。
1 |
|
ferror
函数介绍
1 | int ferror(FILE *stream); |
功能:函数用于检查文件流的错误标志,以确定文件读写操作是否发生了错误。
参数:stream
:指向FILE类型结构体的指针,指定了要检查错误标志的文件流。
返回值:int
:如果文件流的错误标志已经设置,则返回非零值;否则返回0.
使用注意事项:
- 需要包含
<stdio.h>
头文件。 - 在使用
ferror()
之前,需要确保文件已经以读取或写入方式打开。 ferror()
通常是在文件读写结束后,用来判断是否在文件读写的过程中发生了错误。
1 |
|
文件读取结束的判断
文本文件读取是否结束,判断返回值是否为EOF
(fgetc
),或者NULL
(fgets)
例如:
fgetc
判断是否为EOF
.fgets
判断返回值是否为NULL
.
1 |
|
二进制文件的读取结束判断,判断返回值是否小于实际要读的个数。
例如:
- fread判断返回值是否小于实际要读的个数。
1 |
|
#define定义常量
基本语法:
1 |
举个栗子:
1 |
思考:在define定义标识符的时候,要不要在最后加上;
?
比如:
1 |
建议不要加上;
,这样容易导致问题。
比如下面的场景:
1 | if(condition) |
如果是加了分号的情况,等替换后,if和else之间就是2条语句,而没有大括号的时候,if后边只能有一条语句。这里会出现语法错误。
#define 定义宏
宏的基本语法
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或者定义宏(define macro)。
下面是宏的申明方式:
1 |
其中的parament-list
是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意:
参数列表的左括号必须与name紧邻,如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。
宏的示例
实现一个宏,求一个数的平方
1 |
注意:
这个宏存在一个问题:
观察下面的代码段:
1 | int a = 5; |
如果不注意,你可能觉得会打印36,但是实际上是11
替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:
1 | printf("%d\n",a + 1 * a + 1); |
在宏定义上加上两个括号,这个问题便轻松的解决了:
1 |
此时,就会变为(a + 1) * (a + 1)
宏和函数的对比
我们经常能发现其实完成一个代码的时候,既可以使用宏,又可以使用函数,比如,求两个整数的较大值。
1 | // 使用函数实现 |
1 | // 使用宏来实现 |
宏通常被应用于执行简单的运算,在两个数中找出较大的一个时,写成宏,更有优势一些。
那为什么不用函数来完成这个工作?
原因:
- 用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作需要的时间更多。所以宏比函数在程序的规模和速度方面更胜一筹。
- 更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏可以适用于整型,长整形,浮点型等可以用于
>
来比较的类型。宏的参数是类型无关的。
宏和函数相比的劣势:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
- 宏是没法调试的。
- 宏由于类型无关,也就不够严谨。
- 宏可能会带来运算符优先级的问题,导致程序出错。
宏有时候可以做函数做不到的事情。比如:宏的参数可以出现类型,但是函数做不到。
1 |
|
预处理操作符#
#运算符
#运算符 将宏的一个参数转换为字符串字面量。它仅允许出现在带参数的宏的替换列表中。
#运算符所执行的操作可以理解为字符串化
。
当我们有一个变量int a = 10;
的时候,我们想打印出:the value of a is 10
.
就可以写:
1 |
此时#n就转为了字符串
预处理操作符##
##运算符
##
可以把位于它两边的符号合成一个符号,它允许宏定义从分离的文本片段创建标识符。##
被称为记号粘合
这样的连接必须产生一个合法的标识符。否则其结果就是未定义的。
这里我们想想有这么一个场景,假如要写函数求2个数的较大值的时候,那么不同的数据类型,就得写不同的函数。我们通过宏定义会很方便。
1 |
|
传入对应的type就会拼接为对应的类型。
#include<>和#include “”
如果我们写C的话,经常能看到头文件的包含方式一般会有两种:
1 |
那两种的区别是什么呢?主要区别在于头文件的查找策略。
本地文件包含
1 |
查找策略:
先在源文件所在目录下查找,如果头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。
如果找不到就提示编译错误。
库文件包含
1 |
查找策略:直接去标准路径下查找头文件,如果找不到就提示编译错误。
这样是不是说,对于库文件也可以使用""
包含
当然可以,但是效率会变低。