C语言学习笔记
C语言学习笔记
学习时间:2022年11月5日
学习来源:C primer plus 中文版第六版
0 编程环境搭建
IDE:Visual Studio 2022
- 创建一个空项目
- 在资源管理器“源文件”->“添加”->“新建项”
- 选择“C++文件(.cpp)”,但是文件命名以.c为后缀,如:helloWorld.c
1 初识C语言
1972年,贝尔实验室的丹尼斯·里奇(Demnis Ritch)和肯·汤普逊(Ken Thompson)在开发UNIX操作系统时设计了C语言。然而,C语言不完全是里奇突发奇想而来,他是在B语言(汤普逊发明)的基础上进行设计。
语言标准:美国国家标准协会(ANSI)开发了一套新标准,并于1989年正式公布。ANSI/ISO标准的最终版本通常叫作C89
。此后又有C99
和C11
标准。
编程机制:
1 |
|
C编程的基本策略是,用程序把源代码文件转换为可执行文件(其中包含可直接运行的机器语言代码)。
典型的C实现通过编译和链接两个步骤来完成这一过程。编译器把源代码转换成中间代码,链接器把中间代码和其他代码合并,生成可执行文件。C 使用这种分而治之的方法方便对程序进行模块化,可以独立编译单独的模块,稍后再用链接器合并已编译的模块。通过这种方式,如果只更改某个模块,不必因此重新编译其他模块。另外,链接器还将你编写的程序和预编译的库代码合并。
中间文件有多种形式。最普遍的一种形式,即把源代码转换为机器语言代码,并把结果放在目标代码文件(或简称目标文件)中(这里假设源代码只有一个文件)。虽然目标文件中包含机器语言代码,但是并不能直接运行该文件。因为目标文件中储存的是编译器翻译的源代码,这还不是一个完整的程序。
目标代码文件缺失启动代码(starrup code)。启动代码充当着程序和操作系统之间的接口。
目标代码还缺少库函数。几乎所有的C程序都要使用C标准库中的函数。例如,concrete.c中就使用了
printf
函数。目标代码文件并不包含该函数的代码,它只包含了使用printf函数的指令。printf函数真正的代码储存在另一个被称为库的文件中。库文件中有许多函数的目标代码。
链接器的作用是,把你编写的目标代码、系统的标准启动代码和库代码这3部分合并成一个文件,即可执行文件。
简而言之,目标文件和可执行文件都由机器语言指令组成的。然而,目标文件中只包含编译器为你编写的代码翻译的机器语言代码,可执行文件中还包含你编写的程序中使用的库函数和启动代码的机器代码。
2 C语言概述
略
3 数据和C
复习:
- 位(比特):
bit
,可以存储0或1 - 字节:
byte
,1 byte = 8 bits
- 字:
word
,设计计算机时给定的自然存储单位。常见的有8,16,32和如今的64位
C语言基本数据类型:
C提供了3个附属关键字修饰基本数据类型:
short
,long
和unsigned
int
:有符号整型,一般而言,存储一个int
要占用一个机器字长,早期的16位IBM PC兼容机使用 16 位(2字节)来储存一个int 值,其取值范围(即int 值的取值范围)是-32768~32767
。目前的个人计算机一般是32位,因此用32位(4字节)储存一个int 值。现在,个人计算机产业正逐步向着64位处理器发展,自然能储存更大的整数。ISOC规定int的取值范围最小为-32768~32767
(2字节)。short int(short)
,long int(long)
,long long int(long long)
,unsigned int(unsigned)
等,个人计算机上最常见的设置是,long long 占64位,long 占32位,short 占16位,int 占16位或32位(依计算机的自然字长而定)。char
:存储字符,通常占8位1字节。用单引号括起来。_Bool
:布尔值,占用1位。_Bool
是C99新增的布尔类型,包含在头文件stdbool.c
里,可以用bool表示_Bool,true表示1,false表示0,让代码与C++兼容。C99之前,用0表示假,1表示真。float
,double
,long double
:浮点数。_Complex
,_Imaginary
:复数和虚数
4 字符串和格式化输入输出
略
5 运算符、表达式和语句
略
6 循环
略
7 分支和跳转
略
8 字符输入输出
略
9 函数
9.1 函数示例
1 |
|
打印结果:
1 | **************************************** |
9.2 查找地址:&运算符
一元运算符&
给出变量的存储地址,如果pooh
是变量名,则&pooh
是变量的地址。&
被称为取地址符。
1 |
|
打印结果:
1 | In main(), pooh = 2 and &pooh = 0000003D7DB2F5B4 |
首先,两个pooh的地址不同,两个bah的地址也不同。因此,计算机把它们看成4个独立的变量。其次,函数调用mikado(pooh)
把实际参数(main()
中的pooh
)的值(2
)传递给形式参数(mikado()
中的bah
)。注意,这种传递只传递了值。
更改主调函数中的变量
有时需要在一个函数中更改其他函数的变量,例如交换两个变量的值:
1 |
|
打印结果:
1 | Originally x = 5 and y = 10. |
注意main函数中两个变量的值并未交换。
C语言中是值传递机制。以上述代码为例:
1 | swap(5, 10); |
实际执行过程为:拷贝一份5和10,并赋值给形参,即:
1 | int u = 5; |
在内存中有两份5和10,退出函数后,在将变量u(存储5)和v(存储10)的内存释放掉。
9.3 指针
指针(pointer)是一个值为内存地址的变量。指针类型的变量的值是地址,正如char类型的变量的值是字符,int类型的变量的值是一个整数。
1 | ptr = &pooh; |
上述语句把pooh的地址赋给ptr,说ptr 指向 pooh,注意ptr
是指针类型的变量,而&pooh
是一个地址常量,本质为十六进制数。或者说,ptr是可修改的左值。
9.3.1 间接运算符:*
*
:称为间接运算符,或解引用运算符,用于找出指针指向地址的值。
1 | ptr = &bah; |
9.3.2 声明指针
略,详见10.3
小节
9.3.3 使用指针在函数间通信
1 |
|
打印结果:
1 | Originally x = 5 and y = 10. |
同样,按值传递:
1 | int *u = &x; |
将x和y的地址拷贝一份,分别赋值给新开辟的变量u和v,但是此时能够交换x和y的值,因为操作的是他们的地址。
退出函数后,销毁临时变量u和v(分别存储x和y的地址)。
10 指针和数组
10.1 数组
10.1.1 数组的声明
1 | float candy[365]; // 声明一个大小为365的float数组,索引范围是0~364,即0~sizeof(candy)-1 |
10.1.2 初始化数组
一般形式
1 | // 形式1 |
- 如果不初始化数组,数组元素和未初始化的普通变量一样,存储的都是垃圾值。即编译器使用的值是内存相应位置上的现有值。
1 |
|
运行结果:
- 如果部分初始化数组,剩余的元素会被默认初始化为0
1 |
|
运行结果:
- 如果初始化列表的项数多于数组元素个数,编译器会报错。
10.1.3 指定初始化器 C99标准
C99特性:指定初始化器。利用该特性可以初始化指定的数组元素,例如只初始化最后一个元素。
1 | int arr[6] = {[5]=10}; |
注意:
- 如果指定初始化器后面有更多的值,会依次将这些值赋给初始化指定元素后面的元素。
- 如果再次初始化指定的元素,那么
最后
的初始化将会取代之前的初始化。 - 如果未指定数组大小,编译器会把数组的大小设置为足够装得下初始化的值。
1 |
|
运行结果:
1 | int arr1[] = {1,[6]=1};// 数组大小会被设定为7 |
10.1.4 为数组元素赋值
- C不允许把数组作为一个单元赋给另一个数组
- 除初始化以外,不允许使用花括号列表的形式赋值
1 |
|
10.1.5 数组边界
C不检查边界。不检查边界可以使C的运行速度更快。
10.1.6 指定数组的大小
在C99之前,声明数组时只能在方括号中使用整型常量表达式
。而C99标准允许变长数组,即声明数组大小时可以使用变量,变长数组简称为VLA,C11放弃了这一设定,将其设为可选操作,而不是C必备的特性。
1 | int main(){ |
10.2 多维数组
二维数组的声明和初始化
1 | int arr[3][4];// 声明一个3行4列的二维数组 |
1 | int arr[2][2]={ |
以上的初始化使用了2个数值列表,每个列表用花括号括起来。
也可以省略内部的花括号,按行优先的顺序进行初始化,若初始化的数值不够时,用0进行初始化。
1 | int arr[2][3]={5,6,7,8}; |
10.3 数组和指针
10.3.1 指针简介
指针的概念
指针是一个值为内存地址的变量。在大部分系统中,地址由一个无符号整数表示,但是不要认为指针就是整数类型。一些处理整数的操作不能用来处理指针,反之亦然。指针实际上是一个新类型,不是整数类型。ANSI C专门为指针提供了%p
格式的转换说明(输入输出控制参数)。
假设一个指针变量名时ptr
1 | ptr = &pooh;// 把pooh的地址赋给ptr,此处的区别是,ptr是变量,而&pooh是常量 |
指针变量的值是地址,或者说,指针变量指向了该地址。
间接运算符:*
间接运算符*
也被称为解引用运算符
。作用是找出存储在某个地址中的值。后跟一个指针名或地址。
1 | nurse = 22; |
地址运算符:&
后跟一个变量名时,&给出该变量的地址。例如&nurse表示变量nurse的地址。
声明指针
1 | int * pi; |
- 类型说明符表明了指针所指向对象的类型,星号表明声明的变量是一个指针。
*
和指针名之间的空格可有可无,一般而言,声明时使用空格,在解引用变量时省略空格。
1 | int main(){ |
1 | int main(){ |
注意:上面两个程序是相同的
10.3.2 空指针
每一个指针类型都有一个特殊的值——“空指针”。空指针与同类型的其他指针值都不同,它保证与任何对象或函数的指针值都不相等,也就是说空指针不会指向任何地方,它不是任何对象或函数的地址。简单点说,一个指针不指向任何数据,我们就称之为空指针,空指针用NULL
表示。
1 | int* p = NULL; // p为空指针,其指向的地址为空 |
通常用空指针NULL来初始化指针变量。
10.3.3 野指针
野指针是指向位置随机的错误的指针,系统无法对其进行操作。野指针指向的值是非法的内存地址,指向的内存是不可用的。
产生原因
- 局部指针变量没有初始化:如果没有手动去初始化全局变量,全局变量会自动初始化为0,而局部变量不会。所以如果不将局部指针变量手动初始化为
NULL
,那么这个局部指针将会是一个野指针,指向一块非法内存地址,系统无法对其进行操作。 - 使用已经释放过的指针:这个错误常见于动态开辟的内存空间,使用malloc等动态内存函数后,都要用free函数对其开辟的动态内存空间进行释放,并将其置为空指针,如果我们用了free函数把那块动态内存空间释放了(还给操作系统了),但是还没将指针变量置为空指针就去使用该指针,就会造成非法访问内存。
危害
- 指向不可访问的内存地址,导致引发段错误;
- 指向一个可用的,但是没有明确意义的空间,程序可以运行,但是实际上程序是有问题的,如果我们对野指针进行解引用,对其所指向的内存地址作了非法修改,但是这块空间实际上在正在被使用,这个时候里面的正确内容就会被改变,导致程序奔溃,或数据损坏
规避
- 定义创建一个指针变量时一定要记得初始化
- 动态开辟的内存空间,free释放内存后,一定要马上将对应的指针置为NULL空指针
- 注意在动态开辟内存后,对其返回值做合理判断,判断其是否为空指针
错误代码
1 | int main(void) { |
不要解引用野指针。
10.3.4 数组和指针的关系
关系
数组名就是数组首元素的地址
。假设arr是一个数组,则有:
1 | // arr和&arr[0]都是常量 |
程序示例:
1 |
|
这意味着,数组的表示方法就有两种了,一种是原始的表达方法,一种是利用指针的表示方法。
指针的加减
地址按字节编址。指针加1指的是增加一个存储单元,即指针的值递增它所指向类型的大小(以字节为单位)。对数组而言,加1的地址是下一个元素的地址,而不是下一个字节的地址,二者是等价的。
1 |
|
执行结果:
解读:60FF20
是short数组首元素的地址,也是ptr指向的地址。加1后,因为short占用2字节,所以第一个元素的地址范围是60FF20~60FF21
,因此第二个元素的首地址为60FF22
,也就是指针加1后的值。
10.4 指针与函数
10.4.1 使用指针在函数间通信
一般而言,可以把变量相关的两类信息传递给函数。
1 | int function1(int num) |
1 | int function2(int * ptr) |
如果要计算值或处理值,那么使用第一种形式的函数调用;如果要在被调函数中改变主调函数的变量,则使用第二种形式的函数调用。第二种函数调用传过去的值必须是变量的地址,需要用指针ptr来接收,相当于语句(若传过去的是&x):int * ptr = &x
。
下面的程序的功能是交换x,y的值:
1 | // 函数原型,其中的形参名u和v可以省略,但是在函数定义中,形参名不可省略 |
执行结果:
10.4.2 函数,数组和指针
编写一个处理数组的函数,该函数返回数组所有元素之和,待处理的是名为arr
的int
类型数组,则可能的函数调用为:
1 | int total = sum(arr); |
注意:数组名是该数组首元素的地址,因此,实际参数arr
是一个存储int
类型值的地址
,则应该把它赋给一个指针类型的参数,那么该函数原型应为:
1 | // 形式1 |
程序如下:
1 |
|
打印结果:
注意:ar是指向数组首元素地址的指针,类型为int
,所以占用字节为4字节。
10.5 指针操作
以下程序涉及到多个指针的操作,打印出了指针的值(该指针指向的地址),存储在指针指向地址上的值,以及指针自己的地址。
1 |
|
执行结果:
- 赋值:可以地址赋给指针,例如用数组名,带地址运算符的变量名,另一个指针进行赋值。
- 解引用
- 取址:和所有变量一样,指针变量也有自己的地址和值。对指针而言,取址运算符给出了指针本身的地址。
- 指针与整数相加(减):整数会和指针所指向类型的大小(以字节为单位)相乘,然后把结果与指针指向的初始地址相加。
- 递增(减)指针
- 指针求差:指针求差可以求出两个元素之间的距离,差值的单位与指针所指的类型的单位相同。
千万不要解引用未初始化的指针!
1 | int * ptr;// 未初始化的指针 |
第二行的意思是将5存储在ptr所指向的地址,但是ptr未被初始化,其值是一个随机值,所以不知道将5存在何处,这可能不会出什么错误,也可能擦写数据或代码,也可能导致程序崩溃。
10.6 保护数组中的数据
10.6.1 对形式参数使用const
如果函数的意图不是修改数组中的数据内容(例如只求出数组中所有元素之和),那么在函数原型和函数定义中声明形式参数时应该使用关键字const
,例如:
1 | int sum(const int arr[],int n);// 函数原型 |
一般而言,如果编写的函数需要修改数组,在声明数组形参时不使用const
,反之则最好使用const
。
下面的程序有两个函数,一个函数是只打印出数组的元素,一个函数是将原数组的每个元素都乘以一个固定的常数。
1 |
|
执行结果:
10.6.2 关键字const
的其它内容
- 可以创建
const
的数组,指针和指向const
的指针。
1 | const int * ptr;// 指向const的指针,不可以修改指针指向的地址的内容,但可将指针指向别处 |
- 指向
const
的指针不能用于改变值,但是可以改变指针指向的地址。const
指针不能改变其指向的地址。指向const
的const
指针既不能改变值,也不能改变指向的地址
1 | double arr[3] = { 1.2, 3.6, 4.8}; |
10.7 指针与多维数组
10.7.1 指向二维数组的指针
关系推导
(重要)
1 | int array[4][2]; |
- 数组名
array
是该二维数组的首元素的地址,首元素仍是一个数组,一维数组,含有2个int
类型的元素。因此array
是这个内含两个int
值的数组的地址。 array
的值与&array[0]
和array[0]
的值相同。array[0]
本身是一个含有两个int
元素的一维数组,因此array[0]
的值和它首元素(一个整数)的地址(即&array[0][0]
)相同。但是注意:array[0]
是一个占用一个int大小对象的地址,而array
是一个占用两个int大小对象的地址,尽管二者的值是相同的。- 给指针或地址加1,其值会增加对应类型大小的数值,在这方面,
array
和array[0]
不同,前者增加2个int大小,后者增加1个int大小。也就是array+1的意思是从array[0]位移到了array[1],而array[0]+1的意思是从array[0][0]
位移到了array[0][1]
。 array
是==地址的地址==,需要两次解引用才可得到值。**array
与*&array[0][0]
等价,也就是array[0][0]
。*(array[0])
与array[0][0]
等价。
阅读下面代码:
1 | int main(){ |
执行结果:
注意:*(*(array + 2) + 1)
等价于array[2][1]
通式:*(*(array + m) + n)
等价于array[m][n]
分析:
1 | // array 二维数组首元素的地址,每个元素都是包含两个int的一维数组 |
指向二维数组的指针
声明一个指向二维数组的指针变量ptr:ptr必须指向一个含x个某类型的数组,而不是指向一个某类型值。
1 | int (* ptr)[2]; //声明一个ptr指向内含两个int类型值的数组 |
程序代码:
1 |
|
执行结果:
注意:虽然ptr是一个指针,不是数组名,但是也可以使用ptr[2][1]
这样的写法。
以下表示等价:
1 | array[m][n] == *(*(array + m) + n);// 数组表示法 |
10.7.2 二级指针
指向指针的指针称为二级指针。
1 | int main(void) { |
10.7.3 指针的兼容性
- 两个类型的指针不能相互转换
1 | int n = 5; |
- 更复杂的类型也是如此:
1 | int * pt; |
10.7.4 函数和多维数组
- 设arr是一个3行4列的二维数组,那么arr[i]就是一个一维数组,可将其视为二维数组中的一行,数组名arr实质上是一个指向内含4个int的数组的指针。声明的函数原型格式应当如下:
1 | // 形式1 |
程序示例
1 |
|
运行结果:
10.8 变长数组VLA
注意:变长数组中的变不是指可以修改已创建数组的大小。一旦创建变长数组,它的大小不能够改变,这里的变是指在创建数组时,可以使用变量指定数组的维度。
- 声明一个带二维变长数组参数的函数
1 | // 形参rows和cols必须声明在变长数组ar之前 |
- 变长数组允许动态分配
10.9 复合字面量 C99
字面量是指除符号常量(如define
和枚举类型)以外的常量。例如5是int
类型字面量,81.3是double
类型的字面量,’Y’
是char
类型的字面量。
- 复合字面量类似数组初始化列表,前面是用括号括起来的类型名。
1 | (int [2]){10, 20} // 复合字面量,也叫匿名数组 |
- 因为复合字面量是匿名的,因此必须在创建的同时使用它。
1 | int * ptr; |
本例中,*ptr是10,ptr[1]是20,这时可把ptr既可看作是指针,又可看作该匿名数组的数组名。
- 还可以把复合字面量作为实际参数传给函数。
1 | int sum(const int arr[],int n); |
- 复合字面量是提供只临时需要的值的一种手段。
11 字符串和字符串函数
11.1 字符串简介
字符串是一个或多个字符的序列。
11.1.1 char类型数组和null字符
C语言没有专门用于存储字符串的变量类型,字符串都是存储在char类型的数组中(称之为派生类型)。每个字符串的末尾都以\0
结尾,表示字符串的结束,称之为空字符。这是非打印字符,其ASCII值为0;
程序实例
1 |
|
运行结果:
注意:scanf()
只读取了Zeng Hongyi
的前半段,因为它在遇到第一个空白(空格,制表符或换行符)时就不再读取输入。
11.1.2 strlen()
函数
strlen()
给出字符串的长度(不过包括末尾的结束),头文件string.h
给出了多个与字符串相关的函数原型,当然也给出了该函数的原型。
程序实例——strlen和sizeof的区别
1 |
|
- C99和C10标准专门为
sizeof
运算符的返回类型添加了%zd
转换说明,同样也适用于strlen
,但两者返回的实际类型通常是unsigned
或unsigned long
,也可使用%u
或%lu
接收 - 注意比较两者的不同
11.2 表示字符串和字符串IO
程序示例
1 |
|
运行结果:
puts()
也属于stdio.h
系列的输入输出函数,只显示字符串,而且自动换行。
11.2.1 在程序中定义字符串
① 字符串字面量(字符串常量)
用双引号括起来的内容成为字符串字面量或字符串常量。
- 如果在字符串内部使用双引号需要在前面加上反斜杠
1 | printf("\"run, Spot, run!\" exclaimed Dick.\n"); |
- 字符串常量属于静态存储类别,这说明如果在函数中使用字符串常量,该字符串只会被储存一次,在整个程序的生命期内存在,即使函数被调用多次。用双引号括起来的内容被视为指向该字符串存储位置的指针(例如
“Hello World”
就是一个指针,指向“H”
),这类似于把数组名作为指向该数组位置的指针。
1 |
|
执行结果:
② 字符数组和初始化
- 用指定的字符串初始化字符数组m1
1 | const char m1[] = "Hello World!";// 数组大小可以省略 |
这种初始化的形势比标准的数组初始化形式简单得多:
1 | const char m1[] = {'H','e','l','l','o',' ','W','o','r','l','d','!','\0'}; |
注意最后的空字符。若没有该空字符,这就不是一个字符串m1,而是一个普通的字符数组。
字符数组名和其它数组名一样,代表该数组首元素的地址。
- 还可以使用指针表示法创建字符串,注意指针类型是
char
1 | const char * ptr = "Hello World!";// ptr指向该字符串的首地址 |
这种方式几乎和上面的字符串数组的创建形式相同,但原理仍有不同,下面会说。
③ 数组和指针
- 数组形式(
m1[]
):字符串作为可执行文件的一部分存储在程序的数据段中。当把程序载入内存时,也载入了字符串。字符串存储在内存的静态存储区。但是程序在开始运行时(被cpu调度),才为数组m1分配内存(动态内存),并将静态存储区的字符串拷贝
到该数组中。此时,该字符串有两个副本,一个位于静态存储区(常量区),一个位于动态内存(栈区)中的数组m1中,两者的地址是不同的。此外,m1是==地址常量==,不能够更改m1,即不能实现++m1
操作,只能实现m1+1
操作。 - 指针形式(
*ptr
):指针形式也使得编译器为字符串在静态存储区开辟一个地址空间。开始执行程序时,就会为==指针变量==ptr留出一个存储位置,把字符串的地址存储在指针变量中,该变量最初指向字符串的首字符的地址,但可以改变,即可以实现++ptr
的操作,将ptr指向第二个字符的地址。此时,字符串只有一个副本,位于内存的静态存储区(常量区)。后面会说为什么要用const
修饰。
总之,初始化数组是把静态存储区的字符串拷贝到使用动态内存的数组当中,而初始化指针是直接把静态存储区的字符串的地址拷贝给指针。且数组名是常量,指针名是变量。
程序实例
1 |
|
执行结果:
说明:
- 静态数据(字符串常量和ptr)使用的内存和ar使用的动态内存不同
- 字符串常量
Hello World
在程序的两个地方出现了两次(最开始和最结束),但是编译器只使用了一个存储地址,还和MSG
的地址相同,说明编译器可以把多次使用的相同字面量存储在一处。但这个取决于编译器的逻辑,也可能存储在三个不同的地址。
④ 数组和指针的区别
初始化字符数组来储存字符串和初始化指针来指向字符串有何区别?例如,假设有下面两个声明∶
1 | char heart[] = "I love Mary!"; |
两者最主要的区别是:数组名heart
是常量,而head
是变量。
两者都可以使用数组表示法和指针表示法。但是指针可以进行递增操作:
1 | while(*(head) != '\0') |
- 以下代码中:
ptr = arr;
不会导致ptr指向的字符串消失,这样做只是改变了存储在ptr中的地址(ptr指向了另一个地址)。除非保存了"Hello Sekai!"
的地址,否则当ptr指向别处时,就无法再访问该字符串。
1 |
|
- 对于未用
const
修饰的指针初始化,编译器可能允许指针修改该字符串,但对于当前的C标准而言,这样的行为是未定义的。字符串常量被视为const
数据,建议在指针初始化为字符串常量时使用const
限定符。
1 | // 不推荐的形式 |
- 对非
const
数组初始化为字符串常量不会导致以上问题,因为数组获得的是原始字符串的副本。
1 |
|
- 总结:如果要修改字符串,使用字符数组存储;如果不修改字符串,就是用指针指向字符串。
⑤ 字符串数组
程序示例
1 |
|
执行结果:
- mytalents和yourtalents非常类似,都表示5个字符串。使用一个下标时代表一个字符串,例如
mytalents[0]
和youtalents[0]
。使用两个下标时,代表一个字符,例如mytalents[1][2]
表示数组中第2个指针所指向的第3个字符‘i’
,youtalents[1][2]
表示数组第2个字符串的第3个字符‘e’
。 - 区别1:mytalents是一个内含5个指针的数组(注意指针占用字节与数据类型无关,而与系统的地址有关,这里是4字节),占用20字节。而yourtalents是一个内含5个数组的数组,每个数组又内含40个char,则总共占用5*40=200个字节。
- 区别2:数组初始化形式的内存利用率较低,因为每个数组中的字符串都是静态存储区中原始字符串的副本。
- 区别3:要使用数组表示一系列待显示的字符串,采用指针数组。要改变字符串或为字符串输入预留空间,则采用普通的二维数组,因为指针指向的字符串常量不能更改。
11.2.2 指针和字符串
实际上,字符串的绝大多数操作都是通过指针完成的。
1 |
|
- 上述程序中,对输出参数mesg,可以用输出控制符(转换说明)
%s
输出,也可以用%p
输出,前者是字符串,后者是指针的值(指针指向的地址)。 - &mesg是指针自身的地址。因为指针自身也是一个变量,需要为其分配内存地址。
- 通过赋值操作,mesg和copy指向了同一地址,说明程序并未拷贝字符串,而是让两个指针指向同一地址。
11.3 字符串输入
11.3.1 gets()
函数
- 功能:读取整行输入,直到遇到换行符,然后丢弃换行符,存储其余字符并在末尾添加空字符,使其成为一个字符串
1 |
|
执行结果:
- 可能出现的问题:该函数只有一个参数,无法检查数组是否装得下输入行,可能会导致缓冲区溢出,即多余的字符超出了指定的目标空间。
- C11从标准中废除了get函数,但是实际应用中仍有大量用到该函数的地方
11.3.2 fgets()
函数
下略
11.4 字符串函数
C库提供了多个处理字符串的函数,ANSI C把这些函数的原型放在string.h
头文件中。其中最常用的函数有strlen()
、strcat()
、strcmp()
、strncmp()
、strcpy()
和strncpy()
。另外,还有sprintf()
函数,其原型在stdio.h
头文件中。
11.4.1 strlen
函数原型:
1 | size_t strlen(const char *str) |
用于统计字符串的长度。
程序示例
1 |
|
打印结果:
1 | Things should be as simple as possible,but not simpler |
注:puts
函数在空字符串\0
处停止输出,剩余的字符仍处在缓冲区内。
11.4.2 strcat
strcat
(用于拼接字符串)函数接受两个字符串作为参数。
函数原型:
1 | char *strcat(char * destination, const char * source); |
该函数把第2个字符串的备份附加在第1个字符串末尾,并把拼接后形成的新字符串作为第1个字符串,第2个字符串不变。strcat
函数的返回类型是char *(即,指向 char 的指针)。
strcat
函数返回第1个参数,即拼接第2个字符串后的第1个字符串的地址。
程序示例
1 |
|
打印结果:
1 | What is your favorite flower? |
程序实例
1 |
|
11.4.3 strncat
strcat
函数无法检查第1个数组是否能容纳第2个字符串。如果分配给第1个数组的空间不够大,多出来的字符溢出到相邻存储单元时就会出问题,如下:
strncat
函数的声明:
1 | char *strncat(char *dest, const char *src, size_t n) |
- dest – 指向目标数组,该数组包含了一个 C 字符串,且足够容纳追加后的字符串,包括额外的空字符。
- src – 要追加的字符串。
- n – 要追加的最大字符数。
程序实例
1 | void strcat_test() { |
arr1
的容量为7,已经使用了6个存储空间(包括0字符),只能再追加一个额外的字符,因此设定n=1
来防止溢出。
11.4.4 strcmp
函数原型:
1 | int strcmp(const char *str1, const char *str2) |
该函数返回值如下:
- 如果返回值小于 0,则表示 str1 小于 str2。
- 如果返回值大于 0,则表示 str1 大于 str2。
- 如果返回值等于 0,则表示 str1 等于 str2。
程序实例
1 | void strcmp_test() { |
11.4.5 strcpy
将一个字符串复制到另一块空间地址中的函数,\0
是停止拷贝的终止条件,同时也会将\0
也复制到目标空间。
函数原型:
1 | char* strcpy(char* destination,const char* source); |
char* destination
:目标字符串的首地址const char* source
:源地址,被复制的字符串的首地址,用const修饰,避免修改掉被拷贝的字符串char*
:返回的是目标字符串的首地址
strcpy
接受两个字符串指针作为参数,可以把指向源字符串的第2个指针声明为指针、数组名或字符串常量;而指向源字符串副本的第1个指针应指向一个数据对象(如,数组),且该对象有足够的空间储存源字符串的副本,否则会造成缓冲溢出。记住,声明数组将分配储存数据的空间,而声明指针只分配储存一个地址的空间。
代码示例
1 | int main(void) { |
- 目标空间必须可变,因此不能使用指针指向字符串常量
1 | int main() { |
str1
指向的是常量字符串,是不可以被修改掉的,目标空间必须是可以被修改的,因为要将拷贝的字符串放在目标空间中。而源字符串可以是能够修改的、也可以是不能修改的,因为strcpy
函数的第二个参数已经用const关键字修饰了,保证了拷贝过程中不会被修改。
- strcpy函数还有两个有用的属性。第一,strcpy的返回类型是
char *
,该函数返回的是第1 个参数的值,即一个字符的地址。第二,第1个参数不必指向数组的开始。这个属性可用于拷贝数组的一部分。
1 |
|
打印结果:
1 | beast |
注意:strcpy也会将源字符串的\0
拷贝至目标字符串。
11.4.6 其他字符串函数
- 如果s字符串中包含c字符,该函数返回指向c字符串首位置的指针(末尾的空字符也是字符串的一部分,所以在查找范围内);如果在字符串s中未找到c字符,该函数则返回空指针。
1 | char *strchr(cost char * s, int c); |
- 该函数返回s字符串中c字符的最后一次出现的位置(末尾的空字符也是字符串的一部分,所以在查找范围内)。如果未找到c字符,则返回空指针。
1 | char *strrchr(const char * s, intc); |
- 该函数返回指向s1字符串中s2字符串出现的首位置。如果在s1中没有找到s2,则返回空指针。
1 | char *strstr(const * s1, const * s2); |
11.5 命令行参数
假设一个程序的名称为fuss
,则在windows命令提示模式下运行该程序的命令为:
1 | fuss |
命令行参数是同一行的附加项,例如:
1 | fuss -r Ginger |
这里的-r
和Ginger
就是命令行参数。
可以通过main函数的参数来读取命令行参数:
1 | // 也可以是 |
打印结果:
1 | The command line has 2 arguments: |
运行原理:
C编译器允许main没有参数或者有两个参数(一些实现允许main有更多参数,属于对标准的扩展)。
main有两个参数时:
- 第1个参数是命令行中的字符串数量。过去,这个int类型的参数被称为
argc
(表示参数计数argument count
)。系统用空格表示一个字符串的结束和下一个字符串的开始。因此,上面的 repeat 示例中包括命令名共有3个字符串,其中后2个供 repeat 使用。该程序把命令行字符串储存在内存中,并把每个字符串的地址储存在指针数组中。 - 指针数组的地址则被储存在main的第2个参数中。按照惯例,这个指向指针的指针称为
argv
(表示参数值argument value
)。如果系统允许(一些操作系统不允许这样),就把程序本身的名称赋给argv[0]
,然后把随后的第1个字符串赋给argv[1]
,以此类推。
12 存储类别和内存管理
12.1 存储类别
C提供了多种不同的模型或存储类别(storage class)在内存中储存数据。
目前所有编程示例中使用的数据都储存在内存中。
从硬件方面来看,被储存的每个值都占用一定的物理内存,C 语言把这样的一块内存称为对象(object)。对象可以储存一个或多个值。一个对象可能并未储存实际的值,但是它在储存适当的值时一定具有相应的大小。
从软件方面来看,程序需要一种方法访问对象(内存)。这可以通过声明变量来完成∶
1 | int entity = 3; |
该声明创建了一个名为entity
的标识符(identiffer)。标识符是一个名称,在这种情况下,标识符可以用来指定特定对象的内容。在该例中,标识符entity即是软件(即C程序)指定硬件内存中的对象的方式。该声明还提供了储存在对象中的值3
。
- 存储期:可以使用存储期描述对象(存储了值的非空闲内存)。存储期指对象在内存中保留了多长时间。
- 作用域和链接:可以使用这两者描述标识符。标识符的作用域和链接表明了程序的哪些部分能够使用它。
- 不同的存储类别具有不同的存储期、作用域和链接。标识符可以在源代码的多文件中共享、可用于特定文件的任意函数中、可仅限于特定函数中使用,甚至只在函数中的某部分使用。对象可存在于程序的执行期,也可以仅存在于它所在函数的执行期。对于并发编程,对象可以在特定线程的执行期存在。可以通过函数调用的方式显式分配和释放内存。
- C 使用作用域、链接和存储期为变量定义了多种存储方案。这里先介绍5种:自动、寄存器、静态块作用域、静态外部链接、静态内部链接。以下是5种存储类别:
12.1.1 作用域
作用域描述程序中可访问标识符的区域。
一个C变量的作用域可以是块作用域、函数作用域、函数原型作用域或文件作用域。
块是用一对花括号括起来的代码区域。例如,整个函数体是一个块,函数中的任意复合语句也是一个块。定义在块中的变量具有块作用域(block scope),块作用域变量的可见范围是从定义处到包含该定义的块的末尾。
- 示例1
1 | double blocky(double cleo) { |
- 示例2:声明在内层块的变量,其作用域仅限于该声明所在的块
1 | double blocky(double cleo) { |
函数原型作用域用于函数原型中的形参名:
1 | int mighty(int mouse, double large); |
函数原型作用域的范围是从形参定义处到原型声明结束。这意味着,编译器在处理函数原型中的形参时只关心它的类型,而形参名(如果有的话)通常无关紧要。而且,即使有形参名,也不必与函数定义中的形参名相匹配。
只有在变长数组中,形参名才有用∶
1 | void use_a_VLA(int n, int m, ar[n][m]); // 形参名n和m不可省略 |
变量的定义在函数的外面,具有文件作用域(file scope)。具有文件作用域的变量,从它的定义处到该定义所在文件的末尾均可见。
1 | int units = 0; // 该变量具有文件作用域(全局作用域) |
这里,变量units具有文件作用域,main和critic函数都可以使用它(更准确地说,units 具有外部链接文件作用域)。由于这样的变量可用于多个函数,所以文件作用域变量也称为全局变量(global variable)。
12.1.2 链接
翻译单元:通常在源代码(.c
扩展名)中包含一个或多个头文件(.h 扩展名)。头文件会依次包含其他头文件,所以会包含多个单独的物理文件。但是,C预处理实际上是用包含的头文件内容替换#include
指令。所以,编译器源代码文件和所有的头文件都看成是一个包含信息的单独文件。这个文件被称为翻译单元(ranslation unit),描述一个具有文件作用域的变量时,它的实际可见范围是整个翻译单元。如果程序由多个源代码文件组成,那么该程序也将由多个翻译单元组成。每个翻译单元均对应一个源代码文件和它所包含的文件。
C 变量有3种链接属性∶外部链接、内部链接或无链接。
- 具有块作用域、函数作用域或函数原型作用域的变量都是无链接变量。这意味着这些变量属于定义它们的块、函数或原型私有。
- 具有文件作用域的变量可以是外部链接或内部链接。外部链接变量可以在多文件程序中使用,内部链接变量只能在一个翻译单元中使用。
- 外部链接作用域又称为全局作用域或程序作用域
- 内部链接作用域又称为文件作用域,用存储类别说明符
static
修饰
示例
1 | int giants = 5; // 全局作用域,外部 |
该文件和同一程序的其他文件都可以使用变量giants。而变量dodgers属文件私有,该文件中的任意函数都可使用它。
12.1.3 存储期
作用域和链接描述了标识符的可见性。存储期描述了通过这些标识符访问的对象的生存期。
C对象有4种存储期∶静态存储期、线程存储期、自动存储期、动态分配存储期(详见12.3节)。
- 静态存储期:如果对象具有静态存储期,那么它在程序的执行期间一直存在。文件作用域变量具有静态存储期。注意,对于文件作用域变量,关键字static表明了其链接属性,而非存储期。以static声明的文件作用域变量具有内部链接。但是无论是内部链接还是外部链接,所有的文件作用域变量(声明在函数外面)都具有静态存储期。
- 线程存储期:线程存储期用于并发程序设计,程序执行可被分为多个线程。具有线程存储期的对象,从被声明时到线程结束一直存在。以关键字
_Thread_local
声明一个对象时,每个线程都获得该变量的私有备份。 - 自动存储期:块作用域的变量通常都具有自动存储期。当程序进入定义这些变量的块时,为这些变量分配内存;当退出这个块时,释放刚才为变量分配的内存。这种做法相当于把自动变量占用的内存视为一个可重复使用的工作区或暂存区。例如,一个函数调用结束后,其变量占用的内存可用于储存下一个被调用函数的变量。变长数组稍有不同,它们的存储期从声明处到块的末尾,而不是从块的开始处到块的末尾。
程序示例
- 自动存储期:
number
,index
在每次调用bore
函数时被创建在内存中,离开函数时被销毁。
1 | void bore(int number) { |
- 静态存储期:注意块作用域变量也能具有静态存储期
1 | void more(int number) { |
这里,变量 ct
储存在静态内存中,它从程序被载入到程序结束期间都存在。但是,它的作用域定义在more
函数块中。只有在执行该函数时,程序才能使用ct访问它所指定的对象(但是,该函数可以给其他函数提供该存储区的地址以便间接访问该对象,例如通过指针形参或返回值)。
12.1.4 自动
属于自动存储类别的变量具有自动存储期、块作用域且无链接。默认情况下,声明在块或函数头中的任何变量都属于自动存储类别。为了更清楚地表达意图,可以显式使用关键字auto
,如下所示∶
1 | int main(void) { |
关键字 auto是存储类别说明符(storage-class specifier)。auto关键字在C++中的用法完全不同,如果编写C/C++兼容的程序,最好不要使用auto作为存储类别说明符。
块作用域和无链接意味着只有在变量定义所在的块中才能通过变量名访问该变量(当然,参数用于传递变量的值和地址给另一个函数,但是这是间接的方法)。另一个函数可以使用同名变量,但是该变量是储存在不同内存位置上的另一个变量。
块中声明的变量仅限于该块及其包含的块使用。
程序实例
- 在下面的代码中,i仅在内层块中可见。如果在内层块的前面或后面使用i,编译器会报错。
1 | int loop(int n) { |
- 如果内层块中声明的变量与外层块中的变量同名会怎样?内层块会隐藏外层块的定义。但是离开内层块后,外层块变量的作用域又回到了原来的作用域。
1 |
|
打印结果:
1 | x in outer block: 30 at 006FF934 |
12.1.5 寄存器
变量通常储存在计算机内存中。寄存器变量储存在CPU的寄存器中,或者概括地说,储存在最快的可用内存中。与普通变量相比,访问和处理这些变量的速度更快。由于寄存器变量储存在寄存器(CPU)而非内存中,所以无法获取寄存器变量的地址。绝大多数方面,寄存器变量和自动变量都一样。也就是说,它们都是块作用域、无链接和自动存储期。使用存储类别说明符register
便可声明寄存器变量。
1 | register int quick; |
可声明为register的数据类型有限。例如,处理器中的寄存器可能没有足够大的空间来储存double 类型的值。
12.1.6 块作用域的静态变量(静态无链接)
静态的意思是该变量在内存中原地不动,并不是说它的值不变。
具有文件作用域的变量自动具有(也必须是)静态存储期。前面提到过,可以创建具有静态存储期、块作用域的局部变量。这些变量和自动变量一样,具有相同的作用域,但是程序离开它们所在的函数后,这些变量不会消失。也就是说,这种变量具有块作用域、无链接,但是具有静态存储期。
计算机在多次函数调用之间会记录它们的值。在块中(提供块作用域和无链接)以存储类别说明符static
(提供静态存储期)声明这种变量。
1 |
|
打印结果:
1 | Here comes iteration 1: |
静态变量stay保存了它被递增1后的值,但是fade变量每次都是1。这表明了初始化的不同∶每次调用trystat都会初始化fade,但是stay只在编译strstat时被初始化一次。
1 | int fade = 1; |
第1条声明确实是trystat函数的一部分,每次调用该函数时都会执行这条声明。这是运行时行为。第2条声明实际上并不是trystat函数的一部分。如果逐步调试该程序会发现,程序似乎跳过了这条声明。这是因为静态变量和外部变量在程序被载入内存时已执行完毕。把这条声明放在trystat函数中是为了告诉编译器只有trystat函数才能看到该变量。这条声明并未在运行时执行。
12.1.7 外部链接的静态变量(静态外部链接)
外部链接的静态变量具有文件作用域、外部链接和静态存储期。该类别有时称为外部存储类别(external storage class),属于该类别的变量称为外部变量(external variable)。把变量的定义性声明(defining declaration)放在在所有函数的外面便创建了外部变量。当然,为了指出该函数使用了外部变量,可以在函数中用关键字extern
再次声明。如果一个源代码文件使用的外部变量定义在另一个源代码文件中,则必须用 extern 在该文件中声明该变量。
- 示例1
1 | int Errupt; // 外部定义的变量 |
- 示例2
1 | int Hocus; |
- 示例3
1 |
|
打印结果:
1 | How many pounds to a firkin of butter? |
定义和声明
注意区分定义变量和声明变量。
1 | int tern = 1; // 定义式声明 |
1 | extern int tern; // 编译器会假设 tern 实际的定义在该程序的别处,也许在别的文件中。该声明并不会引起分配存储空间。 |
外部变量只能初始化一次,且必须在定义该变量时进行:
1 | // file_one.c |
12.1.8 内部链接的静态变量(静态内部链接)
该存储类别的变量具有静态存储期、文件作用域和内部链接。在所有函数外部(这点与外部变量相同),用存储类别说明符static
定义的变量具有这种存储类别∶
1 | static int svil = 1; // 静态变量,内部链接 |
可以使用存储类别说明符 extern
,在函数中重复声明任何具有文件作用域的变量。这样的声明并不会改变其链接属性。考虑下面的代码∶
1 | int traveler = 1; // 外部链接 |
对于该程序所在的翻译单元,trveler和stayhome都具有文件作用域,但是只有traveler可用于其他翻译单元(因为它具有外部链接)。这两个声明都使用了extern
关键字,指明了main中使用的这两个变量的定义都在别处,但是这并未改变stayhome的内部链接属性。
只有当程序由多个翻译单元组成时,才体现区别内部链接和外部链接的重要性。
复杂的C程序通常由多个单独的源代码文件组成。有时,这些文件可能要共享一个外部变量。C通过在一个文件中进行定义式声明,然后在其他文件中进行引用式声明来实现共享。也就是说,除了一个定义式声明外,其他声明都要使用extern关键字。而且,只有定义式声明才能初始化变量。
注意,如果外部变量定义在一个文件中,那么其他文件在使用该变量之前必须先声明它(用 extern 关键字)。也就是说,在某文件中对外部变量进行定义式声明只是单方面允许其他文件使用该变量,其他文件在用extern声明之前不能直接使用它。
12.1.9 存储类别说明符
- auto 说明符表明变量是自动存储期,只能用于块作用域的变量声明中。由于在块中声明的变量本身就具有自动存储期,所以使用 auto 主要是为了明确表达要使用与外部变量同名的局部变量的意图(隐藏外部变量)。
- register 说明符也只用于块作用域的变量,它把变量归为寄存器存储类别,请求最快速度访问该变量。同时,还保护了该变量的地址不被获取。
- 用 static 说明符创建的对象具有静态存储期,载入程序时创建对象,当程序结束时对象消失。如果static 用于文件作用域声明,作用域受限于该文件。如果 static 用于块作用域声明,作用域则受限于该块。因此,只要程序在运行对象就存在并保留其值,但是只有在执行块内的代码时,才能通过标识符访问。块作用域的静态变量无链接。文件作用域的静态变量具有内部链接。
- extern 说明符表明声明的变量定义在别处。如果包含 extern 的声明具有文件作用域,则引用的变量必须具有外部链接。如果包含 extern 的声明具有块作用域,则引用的变量可能具有外部链接或内部链接,这接取决于该变量的定义式声明。
12.1.10 存储类别和函数
函数也有存储类别,可以是外部函数(默认)或静态函数。外部函数可以被其他文件的函数访问,但是静态函数只能用于其定义所在的文件。假设一个文件中包含了以下函数原型∶
1 | double gamma(double); // 该函数默认为外部函数 |
在同一个程序中,其他文件中的函数可以调用gamma和delta,但是不能调用beta,因为以static 存储类别说明符创建的函数属于特定模块私有。这样做避免了名称冲突的问题,由于beta受限于它所在的文件,所以在其他文件中可以使用与之同名的函数。
通常的做法是∶ 用 extern 关键字声明定义在其他文件中的函数。这样做是为了表明当前文件中使用的函数被定义在别处。除非使用static关键字,否则一般函数声明都默认为extern。
12.2 随机数函数
ANSI C库提供了rand
函数生成随机数。
为了看清楚程序内部的情况,我们使用可移植的 ANSI版本,而不是编译器内置的 rand 函数。可移植版本的方案开始于一个“种子”数字。该函数使用该种子生成新的数,这个新数又成为新的种子。然后,新种子可用于生成更新的种子,以此类推。该方案要行之有效,随机数函数必须记录它上一次被调用时所使用的种子。这里需要一个静态变量。
1 | // rand0.c ---生成随机数 |
1 | // r_drive0.c ---测试rand0函数 |
打印结果:
1 | 16838 |
再次执行,也是同样的结果。因为每次执行,种子开始都是1。
可以引入另一个函数srand1()
来重置种子,在rand0.c
中新增该函数。
1 | // rand0.c ---生成随机数和重置种子 |
测试函数:
1 |
|
打印结果:
1 | Please enter your choice for seed. |
注意:stdlib
为库函数srand
提供原型。
12.3 分配内存
12.3.1 malloc函数
所有程序都必须预留足够的内存来储存程序使用的数据。这些内存中有些是自动分配的。例如,以下声明∶
1 | float x; |
为一个float类型的值和一个字符串预留了足够的内存,或者可以显式指定分配一定数量的内存∶
1 | int plates[100]; |
该声明预留了100个内存位置,每个位置都用于储存int类型的值。声明还为内存提供了一个标识符。因此,可以使用x
或place
识别数据。
c可以在程序运行时分配更多的内存。主要的工具是 malloc
函数,该函数接受一个参数∶所需的内存字节数。malloc函数会找到合适的空闲内存块,这样的内存是匿名的。也就是说,malloc分配内存,但是不会为其赋名。然而,它确实返回动态分配内存块的首字节地址。因此,可以把该地址赋给一个指针变量,并使用指针访问这块内存。
C使用一个新的类型:指向void的指针。该类型相当于一个”通用指针”。malloc函数可用于返回指向数组的指针、指向结构的指针等,所以通常该函数的返回值会被强制转换为匹配的类型。在ANSIC中,应该坚持使用强制类型转换,提高代码的可读性。然而,把指向 void 的指针赋给任意类型的指针完全不用考虑类型匹配的问题。如果 malloc分配内存失败,将返回空指针。
头文件:stdlib
原型:void * malloc(size_t size)
1 | double * ptd; |
以上代码为30个double类型的值请求内存空间,并设置ptd指向该位置。注意,指针ptd被声明为指向一个double类型,而不是指向内含30个double类型值的块。回忆一下,数组名是该数组首元素的地址。因此,如果让ptd指向这个块的首元素,便可像使用数组名一样使用它。也就是说,可以使用表达式ptd[0]
访问该块的首元素,ptd[1]
访问第2个元素,以此类推。
现在可以使用malloc函数来创建动态数组:
1 | ptd = (double *) malloc(n * sizeof(double)); |
通常,malloc要与free配套使用:
1 | ptd = (double *) malloc(n * sizeof(double)); |
free函数的参数是之前malloc返回的地址,该函数释放之前 malloc分配的内存。因此,动态分配内存的存储期从调用malloc分配内存到调用free释放内存为止。设想malloc和free管理着一个内存池。每次调用malloc分配内存给程序使用,每次调用free把内存归还内存池中,这样便可重复使用这些内存。free的参数应该是一个指针,指向由 malloc分配的一块内存。不能用 free释放通过其他方式(如,声明一个数组)分配的内存。
12.3.2 free函数
头文件:stdlib
原型:void free(void * ptr);
参数:指向要解分配的内存的指针
静态内存的数量在编译时是固定的,在程序运行期间也不会改变。
自动变量使用的内存数量在程序执行期间自动增加或减少。
动态分配的内存数量只会增加,除非用free进行释放。例如,假设有一个创建数组临时副本的函数,其代码框架如下:
1 | int main() { |
第1次调用 gobble时,它创建了指针 temp,并调用 malloc分配了16000字节的内存(假设double为8字节)。假设如代码注释所示,遗漏了free。当函数结束时,作为自动变量的指针 temp 也会消失。但是它所指向的16000字节的内存却仍然存在。由于 temp 指针已被销毁,所以无法访问这块内存,它也不能被重复使用,因为代码中没有调用free释放这块内存。
第2次调用 gobble时,它又创建了指针 temp,并调用malloc分配了16000字节的内存。第1次分配的16000字节内存已不可用,所以malloc分配了另外一块16000字节的内存。当函数结束时,该内存块也无法被再访问和再使用。
循环要执行1000次,所以在循环结束时,内存池中有1600万字节被占用。实际上,也许在循环结束之前就已耗尽所有的内存。这类问题被称为内存泄漏(memory leak)。在函数末尾处调用free函数可避免这类问题发生。
12.3.3 calloc函数
分配内存还可以使用calloc()
:
函数原型:void* calloc(unsigned int num, unsigned int size);
,第一个参数是所需的存储单元数量,第二个参数是存储单元的大小(以字节为单位)
功能:在内存的动态存储区中分配num个长度为size的连续空间,函数返回一个指向分配起始地址的指针;如果分配不成功,返回NULL。
1 | long * newmem = (long *) calloc(100, sizeof(long)); |
free函数也可用于释放calloc分配的内存。
12.3.4 memset函数
头文件:string.h
函数原型:
1 | void *memset(void *s, int c, size_t n); |
- 指针
p
为所操作的内存空间的首地址 c
为每个字节所赋的值n
为所操作内存空间的字节长度,也就是内存被赋值为c的字节数。
memset是以字节为单位进行赋值的,所赋值的范围是0x00~0xFF。若想要对一个double或int型的数组赋值时,就特别需要注意这一点:
1 | // 1 |
因为memset函数以字节为单位进行赋值,那么数组中一个int型元素的4个字节都将被赋值为1(或者说ASCII码1所对应的字符),实际上它所表示的整数是0x01010101
。
所以,在memset使用时要千万小心,在给char以外的数组赋值时,只能初始化为0
或者-1
。
12.4 ANSI C类型限定符
通常用类型和存储类别来描述一个变量。
C90 还新增了两个属性∶恒常性(constancy)和易变性(volatility)。这两个属性可以分别用关键字 const
和 volatile[ˈvɑːlətl]
来声明,以这两个关键字创建的类型是限定类型(qualified type)。
C99标准新增了第3个限定符∶restrict
,用于提高编译器优化。
C11标准新增了第4个限定符∶_Atomic
。C11提供一个可选库,由stdatomic.h管理,以支持并发程序设计,而且 Atomic是可选支持项。
12.4.1 const类型限定符
以const关键字声明的对象,其值不能通过赋值或递增、递减来修改。在ANSI兼容的编译器中,以下代码∶
1 | const int nochange; // 不允许修改nochange |
但是可以初始化const变量:
1 | const int nochange = 12; // 允许 |
① 指针和形参使用const
指针:要注意区分是限定指针本身,还是限定指针指向的值。
- 示例1
1 | int main() { |
- 示例2
1 | int * const ptr; // ptr指向的地址不能改变 |
const 关键字的常见用法是声明为函数形参的指针。例如,假设有一个函数要调用 display 显示一个数组的内容。要把数组名作为实际参数传递给该函数,但是数组名是一个地址。该函数可能会更改主调函数中的数据,但是下面的原型保证了数据不会被更改∶
1 | void display(const int array[], int limit); |
② 全局数据使用const
前面讲过,使用全局变量是一种冒险的方法,因为这样做暴露了数据,程序的任何部分都能更改数据。如果把数据设置为 const,就可避免这样的危险,因此用 const 限定符声明全局数据很合理。
然而,在文件间共享const 数据要小心。可以采用两个策略。
- 第一,遵循外部变量的常用规则,即在一个文件中使用定义式声明,在其他文件中使用引用式声明(用extern 关键字)∶
1 | // file1.c 定义了一些外部const变量 |
- 第二,把const变量放在一个头文件中
1 | // constant.h |
注意:static使得变量具有内部链接(只在本文件中可见);多个源文件引用该头文件,相当于在不同源文件中定义同名static变量,换句话说,这种方案相当于给每个文件提供了一个单独的数据副本。
示例
1 |
|
1 |
|
1 |
|
打印结果:发现PI的地址并不相同
1 | test01: PI address=00847B30 |
补充1
如果将全局变量定义在头文件中,然后又在多处include该头文件,会导致全局变量的重复定义,从而无法编译:multiple definition of xxx
1 | //b.h |
这一规则有一个例外:如果这个全局变量是const型的,那么可以在头文件中对其进行定义,且不会导致重复定义。
因此,对于const类型变量,可以不用加static进行修饰。
1 |
|
补充2:头文件相关知识
include作用:在include的地方,把头文件里的内容原封不动的复制到引用该头文件的地方。
头文件引用有两种形式:
#include < stdio.h>
和include "main.h"
。- 用
< >
引用的一般是编译器提供的头文件,编译时会在指定的目录中去查找头文件。具体是哪个目录,编译器知道,我们不用关心。 - 用
""
引用的一般是自己写的头文件,编译时,编译器会在项目所在的文件夹中进行查找。
- 用
头文件内容和格式:一般包括宏定义, 全局变量, 函数原型声明。
1 |
|
12.4.2 volatile类型限定符
volatile 限定符告知计算机,代理(而不是变量所在的程序)可以改变该变量的值。通常,它被用于硬件地址以及在其他程序或同时运行的线程中共享数据。例如,一个地址上可能储存着当前的时钟时间,无论程序做什么,地址上的值都随时间的变化而改变。或者一个地址用于接受另一台计算机传入的信息。
1 | volatile int locl; |
引入volatile的原因在于它涉及编译器的优化。
1 | val1 = x; |
编译器会注意到以上代码使用了两次 x,但并未改变它的值。于是编译器把 x 的值临时储存在寄存器(CPU)中,然后在val2需要使用x时,才从寄存器中(而不是从原始内存位置上)读取x 的值,以节约时间。这个过程被称为高速缓存(caching)。通常,高速缓存是个不错的优化方案,但是如果一些其他代理在以上两条语句之间改变了x的值,就不能这样优化了。
如果没有volatile关键字,编译器就不知道这种事情是否会发生(易变或者不易变)。因此,为安全起见,编译器不会进行高速缓存。这是在 ANSI 之前的情况。现在,如果声明中没有volatile关键字,编译器会假定变量的值在使用过程中不变,然后再尝试优化代码(使用高速缓存)。
即volatile的作用是:
- 变量声明为volatile时,不使用高速缓存,因为编译器认为该变量易变
- 变量不声明为volatile时,编译器认为该变量不会改变,使用高速缓存存储该变量的值。
13 文件输入/输出
13.1 与文件通信
13.1.1 文件
文件通常是在磁盘或固态硬盘上的一段已命名的存储区。
13.1.2 文件模式
C把文件看作是一系列连续的字节,每个字节都能被单独读取。这与UNIX环境中(C的发源地)的文件结构相对应。由于其他环境中可能无法完全对应这个模型,C提供两种文件模式∶文本模式和二进制模式。
所有文件的内容都以二进制形式(0或1)储存。
如果文件最初使用二进制编码的字符(例如,ASCII或Unicode)表示文本(就像C字符串那样),该文件就是文本文件,其中包含文本内容。
如果文件中的二进制值代表机器语言代码或数值数据(使用相同的内部表示,假设,用于long或double类型的值)或图片或音乐编码,该文件就是二进制文件,其中包含二进制内容。
13.1.3 IO的级别
除了选择文件的模式,大多数情况下,还可以选择I/O的两个级别(即处理文件访问的两个级别)。
底层I/O(low-levelI/O)使用操作系统提供的基本I/O服务。
标准高级I/O(standard high-level I/O)使用C库的标准包和stdio.h头文件定义。
因为无法保证所有的操作系统都使用相同的底层I/O模型,C标准只支持标准I/O包。有些实现会提供底层库,但是C标准建立了可移植的I/O模型,我们主要讨论这些I/O。
13.1.4 标准文件
C程序会自动打开3个文件,它们被称为标准输入(standard input)、标准输出(standard output)和标准错误输出(standard error output)。输入输出的中心是程序。
在默认情况下,标准输入是系统的普通输入设备,通常为键盘;标准输出和标准错误输出是系统的普通输出设备,通常为显示屏。
通常,标准输入为程序提供输入,它是 getchar和 scanf使用的文件。程序通常输出到标准输出,它是 putchar、puts和printf使用的文件。
13.2 标准IO
一个小程序:
1 |
|
打印结果:
1 | Hello World!File E:\develop\study\project-study\C++_learn\a.txt has 12 characters. |
13.2.1 检查命令行参数
首先,上述程序检查argc
的值,查看是否有命令行参数。如果没有,程序将打印一条消息并退出程序。
exit
函数关闭所有打开的文件并结束程序。exit
的参数被传递给一些操作系统,包括UNIX、Linux、Windows和MS-DOS,以供其他程序使用。
通常的惯例是∶正常结束的程序传递0,异常结束的程序传递非零值。C标准要求0或宏EXIT SUCCESS
用于表明成功结束程序,宏EXIT FAILURE
用于表明结束程序失败。这些宏和exit原型都位于stdlib.h
头文件中,如下所示:
13.2.2 fopen
fopen函数用于打开文件,函数原型为:
1 | FILE *fopen(char *filename, *type); |
filename
:待打开文件的名称type
:指定待打开文件的模式
程序成功打开文件后,fopen将返回文件指针(file pointer),其他I/O函数可以使用这个指针指定该文件。文件指针(该例中是fp
)的类型是指向FILE
的指针,FILE是一个定义在stdio.h
中的派生类型。
文件指针fp并不指向实际的文件,它指向一个包含文件信息的数据对象,其中包含操作文件的I/O函数所用的缓冲区信息。因为标准库中的I/O函数使用缓冲区,所以它们不仅要知道缓冲区的位置,还要知道缓冲区被填充的程度以及操作哪一个文件。标准I/O函数根据这些信息在必要时决定再次填充或清空缓冲区。fp指向的数据对象包含了这些信息(该数据对象是一个C结构)。
13.2.3 getc和putc
getc()
和putc()
函数其实和getchar()
与putchar()
函数十分相似,所不同的是,要告诉getc()
和putc()
函数使用哪一个文件。
C 库函数 getc
从指定的流 stream 获取下一个字符(一个无符号字符),并把位置标识符往前移动。函数原型:
1 | int getc(FILE *stream) |
- stream – 这是指向 FILE 对象的指针,该 FILE 对象标识了要在上面执行操作的流。
- 该函数以无符号 char 强制转换为 int 的形式返回读取的字符,如果到达文件末尾或发生读错误,则返回 EOF。
C 库函数 getchar
从标准输入 stdin
获取一个字符(一个无符号字符)。这等同于 getc 带有 stdin
作为参数。
1 | getc(stdin); // 等同于getchar(); |
C 库函数 putc()
把参数 char 指定的字符(一个无符号字符)写入到指定的流 stream 中,并把位置标识符往前移动。函数原型:
1 | int putc(int char, FILE *stream) |
当第二个参数为stdout
时,等同于putchar
;
13.2.4 文件结尾
从文件中读取数据的程序在读到文件结尾时要停止。如果 getc函数在读取一个字符时发现是文件结尾,它将返回一个特殊值EOF
。所以C程序只有在读到超过文件末尾时才会发现文件的结尾(一些其他语言用一个特殊的函数在读取之前测试文件结尾,C语言不同)。
为了避免读到空文件,应该使用入口条件循环(不是do while循环)进行文件输入。鉴于getc(和其他C输入函数)的设计,程序应该在进入循环体之前先尝试读取。如下面设计所示∶
1 | int ch; // 存储读取文件得到的字符 |
以上代码可以简化为:
1 | int ch; // 存储读取文件得到的字符 |
13.2.5 fclose
fclose(fp)
函数关闭fp指定的文件,必要时刷新缓冲区。对于较正式的程序,应该检查是否成功关闭文件。如果成功关闭,fclose
函数返回0,否则返回EOF
∶
1 | if(fclose(fp) != 0) |
13.2.6 指向标准文件的指针
stdio.h
头文件把3个文件指针与3个标准文件相关联,C程序会自动打开这3个标准文件:
这些文件指针都是指向FILE的指针,所以它们可用作标准I/O函数的参数,如fclose
中的fp。
13.3 一个简单的文件压缩程序
1 |
|
13.4 文件IO
文件I/O函数要用FILE指针指定待处理的文件。与 getc、putc类似,这些函数都要求用指向 FILE 的指针(如,stdout)指定一个文件,或者使用fopen的返回值。
13.4.1 fprintf和fscanf
文件I/O函数fprintf和fscanf函数的工作方式与printf和scanf类似,区别在于前者需要用第1个参数指定待处理的文件。当第一个参数是stdout/stderr
和stdin
时,二者对应相同。
C 库函数fprintf
发送格式化输出到流 stream 中。函数原型为:
1 | int fprintf(FILE *stream, const char *format, ...) |
- stream – 这是指向 FILE 对象的指针,该 FILE 对象标识了流。
- format – 这是 C 字符串,包含了要被写入到流 stream 中的文本。
- 如果成功,则返回写入的字符总数,否则返回一个负数。
代码示例
1 | int main(int argc, char *argv []) { |
这将创建文件 a.txt,它的内容如下:
1 | We are in 2022 |
13.4.2 fgets和fputs
fgets
和fputs
的第二个参数为stdin
和stdout
时,等同于gets
和puts
C 库函数 fgets
从指定的流 stream 读取一行,并把它存储在 str 所指向的字符串内。当读取 (n-1) 个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。函数原型:
1 | char *fgets(char *str, int n, FILE *stream) |
- str – 这是指向一个字符数组的指针,该数组存储了要读取的字符串。
- n – 这是要读取的最大字符数(包括最后的空字符)。通常是使用以 str 传递的数组长度。
- stream – 这是指向 FILE 对象的指针,该 FILE 对象标识了要从中读取字符的流。
- 如果成功,该函数返回相同的 str 参数。如果到达文件末尾或者没有读取到任何字符,str 的内容保持不变,并返回一个空指针。如果发生错误,返回一个空指针。
代码示例
1 | int main() |
C 库函数fputs
把字符串写入到指定的流 stream 中,但不包括空字符。函数原型:
1 | int fputs(const char *str, FILE *stream) |
- str – 这是一个数组,包含了要写入的以空字符终止的字符序列。
- stream – 这是指向 FILE 对象的指针,该 FILE 对象标识了要被写入字符串的流。
- 该函数返回一个非负值,如果发生错误则返回 EOF。
代码示例
1 | int main() |
13.5 标准IO原理
通常,使用标准I/O的第1步是调用fopen打开文件(C程序会自动打开3种标准文件)。fopen函数不仅打开一个文件,还创建了一个缓冲区(在读写模式下会创建两个缓冲区)以及一个包含文件和缓冲区数据的结构。另外,fopen返回一个指向该结构的指针,以便其他函数知道如何找到该结构。
假设把该指针赋给一个指针变量fp,我们说fopen函数“打开一个流”。如果以文本模式打开该文件,就获得一个文本流;如果以二进制模式打开该文件,就获得一个二进制流。
这个结构通常包含一个指定流中当前位置的文件位置指示器。除此之外,它还包含错误和文件结尾的指示器、一个指向缓冲区开始处的指针、一个文件标识符和一个计数(统计实际拷贝进缓冲区的字节数)。
通常,使用标准I/O的第2步是调用一个定义在stdio.h中的输入函数,如fscanf、getc或 fgets。一调用这些函数,文件中的数据块就被拷贝缓冲区中。缓冲区的大小因实现而异。最初调用函数,除了填充缓冲区外,还要设置fp所指向的结构中的值。尤其要设置流中的当前位置和拷贝进缓冲区的字节数。通常,当前位置从字节0开始。
在初始化结构和缓冲区后,输入函数按要求从缓冲区中读取数据。在它读取数据时,文件位置指示器被设置为指向刚读取字符的下一个字符。由于stdio.h系列的所有输入函数都使用相同的缓冲区,所以调用任何一个函数都将从上一次函数停止调用的位置开始。
当输入函数发现已读完缓冲区中的所有字符时,会请求把下一个缓冲大小的数据块从文件拷贝到该缓冲区中。以这种方式,输入函数可以读取文件中的所有内容,直到文件结尾。函数在读取缓冲区中的最后一个字符后,把结尾指示器设置为真。于是,下一次被调用的输入函数将返回EOF。
输出函数以类似的方式把数据写入缓冲区。当缓冲区被填满时,数据将被拷贝至文件中。
13.6 其他标准IO函数
13.6.1 fflush
刷新流 stream 的输出缓冲区。
1 | int fflush(FILE *stream) |
调用fflush函数引起输出缓冲区中所有的未写入数据被发送到fp指定的输出文件。这个过程称为刷新缓冲区。如果fp是空指针,所有输出缓冲区都被刷新。
13.6.2 fread和fwrite
这是两个二进制的标准IO函数。
实际上,所有的数据都是以二进制形式储存的,甚至连字符都以字符码的二进制表示来储存。如果文件中的所有数据都被解释成字符码,则称该文件包含文本数据。如果部分或所有的数据都被解释成二进制形式的数值数据,则称该文件包含二进制数据(另外,用数据表示机器语言指令的文件都是二进制文件)。
fread
从给定流 stream 读取数据到 ptr 所指向的数组中。函数原型:
1 | size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream) |
- ptr – 这是指向带有最小尺寸
size*nmemb
字节的内存块的指针。 - size – 这是要读取的每个元素的大小,以字节为单位。
- nmemb – 这是元素的个数,每个元素的大小为 size 字节。
- stream – 这是指向 FILE 对象的指针,该 FILE 对象指定了一个输入流。
fwrite
把 ptr 所指向的数组中的数据写入到给定流 stream 中。函数原型:
1 | size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream) |
- ptr – 这是指向要被写入的元素数组的指针。
- size – 这是要被写入的每个元素的大小,以字节为单位。
- nmemb – 这是元素的个数,每个元素的大小为 size 字节。
- stream – 这是指向 FILE 对象的指针,该 FILE 对象指定了一个输出流。
14 结构和其它数据形式
14.1 示例:创建图书目录
字段:书名、作者和价格。
14.2 建立结构声明
结构声明(structure declaration)描述了一个结构的组织布局。声明类似下面这样∶
1 | struct book { |
struct
是关键字,book
是可选的标记。可以使用该标记引用该结构:
1 | struct book library; |
这样把library
声明为一个使用book结构布局的结构变量。
14.3 定义结构变量
14.3.1 概述
1 | struct book library; |
编译器执行这行代码便创建了一个结构变量library。编译器使用book模板为该变量分配空间∶一个内含MAXTITL个元素的char数组、一个内含MAXAUTL个元素的char数组和一个float类型的变量。这些存储空间都与一个名称library结合在一起。
在结构变量的声明中,struct book
所起的作用相当于一般声明中的int 或float。例如,可以定义两个struct book类型的变量,或者甚至是指向struct book类型结构的指针∶
1 | struct book doyle, panshin, * ptbook; |
声明的简化形式:
1 | struct book { |
14.3.2 初始化结构
1 | struct book library { |
14.3.3 访问结构成员
使用结构成员运算符.
来访问结构中的成员。例如:
1 | library.title; |
14.3.4 结构的初始化器
例如:
1 | // 只初始化一个成员变量 |
14.4 结构数组
14.4.1 声明结构数组
显然,每本书的基本信息都可以用一个book
类型的结构变量来表示。因此,可以创建结构数组来存储这些书籍的信息:
1 | struct book library[MAXBKS]; // book类型结构的数组 |
14.4.2 标识结构数组的成员
例如:
1 | library[0].value; // 第一个数组元素(结构)的value |
14.5 嵌套结构
有时,在一个结构中包含另一个结构(嵌套结构)很方便,例如创建一个朋友的信息,结构中需要一个成员表示朋友的名字,而名字可以用一个结构来表示,包含姓和名两个成员:
1 | struct names { // 第一个结构 |
14.6 指向结构的指针
程序实例
1 |
|
打印结果:
1 | address #1: 00BBFB78 #2: 00BBFBCC |
程序解析详见下面。
14.6.1 声明和初始化结构指针
声明结构指针:
1 | struct guy * him; |
初始化:
1 | him = &barney; // 如果barney是一个guy类型的结构 |
注意,和数组名不同,结构名不是结构的地址。
在本例中,fellow
是一个结构数组,因此fellow[0]
仍然是一个结构,所以:
1 | him = &fellow[0]; |
输出的前两行说明赋值成功。比较这两行发现,him
指向fellow[0]
,him + 1
指向fellow[1]
。注意,him加1相当于him指向的地址加84。在十六进制中,874-820=54(十六进制)=84(十进制)
,因为每个guy 结构都占用84字节的内存∶names.first 占用20字节,names.last 占用20 字节,favfood占用20字节,job占用20字节,income占用4字节(假设系统中float占用4字节)。
顺带一提,在有些系统中,一个结构的大小可能大于它各成员大小之和。这是因为系统对数据进行校准的过程中产生了一些“缝隙”。例如,有些系统必须把每个成员都放在偶数地址上,或 4的倍数的地址上。在这种系统中,结构的内部就存在未使用的”缝隙”。
14.6.2 用指针访问成员
有两种方法。
- 方法一:使用
->
运算符
1 | 如果him == &barney,那么him -> income等价于barney.income |
- 方法二:
1 | 如果him == &fellow[0],那么*him == fellow[0] |
*him
对him
解引用,就是fellow[0]
结构
14.7 向函数传递结构的信息
14.7.1 传递结构成员
只要结构成员是一个具有单个值的数据类型(即,int及其相关类型、char、float、double或指针),便可把它作为参数传递给接受该特定类型的函数。
程序实例
1 |
|
打印结果:
1 | Stan has a total of 12468.00. |
14.7.2 传递结构的地址
1 |
|
14.7.3 传递结构
1 |
|
调用sum时,编译器根据funds模板创建了一个名为moolah的自动结构变量。然后,该结构的各成员被初始化为 stan 结构变量相应成员的值的副本。因此,程序使用原来结构的副本进行计算。
14.7.4 其他结构特性
C允许把一个结构赋值给另一个结构:
1 | struct names right_field = {"Mark", "George"}; |
14.7.5 结构和结构指针的选择
假设要编写一个与结构相关的函数,是用结构指针作为参数,还是用结构作为参数和返回值?两者各有优缺点。
- 把结构指针作为参数:执行效率高,但是无法保护数据,可以使用
const
限定符来保护主调函数的数据。 - 把结构作为参数:函数处理的是原始数据的副本,缺点是占用内存空间
14.7.6 结构中的字符数组和字符指针
1 |
|
对于结构变量veep
,总共要分配40个字节来存储字符,而对于结构变量treas
,只存储了两个char类型的地址。地址1,是字符串Brad
首字母的存储地址,地址2是字符串Pitt
首字母的存储地址。
1 | struct names accountant; |
对于accountant
变量,他的成员last
字符数组将被赋值;而对于attorney
变量,scanf
将接收到的字符串,放置在指针变量last
所指向的地址里,但是last
并未进行初始化,因此输入的字符会放在内存中的任何一个地方,可能会导致程序崩溃。
建议:如果要用结构存储字符串,建议使用字符数组,而不是字符指针。
14.7.7 使用结构数组的函数
假设一个函数要处理一个结构数组。由于数组名就是该数组的地址,所以可以把它传递给函数。
1 | #include <stdio.h> |
因此指针money的初始值相当于通过下面的表达式获得:
1 | money = &jones[0]; |
14.8 联合
14.8.1 简介
联合(union)是一种数据类型,它能在同一个内存空间中储存不同的数据类型(不是同时储存)。其典型的用法是,设计一种表以储存既无规律、事先也不知道顺序的混合类型。使用联合类型的数组,其中的联合都大小相等,每个联合可以储存各种数据类型。
- 定义带标记的联合模板:
1 | union hold { |
根据以上形式声明的结构可以储存一个int类型、一个double类型和char类型的值。然而,声明的联合只能储存一个int类型的值或一个double类型的值或char类型的值。
- 定义与hold类型相关的变量
1 | union hold fit; |
- 可以初始化联合,但要注意的是,联合只能存储一个值,这与结构不同。
1 | union hold valA; |
14.8.2 使用联合
下面是联合的一些用法:
1 | fit.digit = 23; // 把23存储在fit,占用2字节 |
和指针访问结构使用->
运算符一样,用指针访问联合时也要使用->
:
1 | pu = &fit; |
14.9 枚举类型
14.9.1 简介
可以用杖举类型(emumerated type)声明符号名称来表示整型常量。使用enum
关键字,可以创建一个新“类型”并指定它可具有的值(实际上,enum常量是int类型,因此,只要能使用int类型的地方就可以使用枚举类型)。
枚举类型的目的是提高程序的可读性。它的语法与结构的语法相同。例如,可以这样声明:
1 | enum spectrum {red, orange, yellow, green, blue, violet}; // 这里的各个符号名称代表了各个整型常量 |
第1个声明创建了spetrum
作为标记名,允许把enum spetrum
作为一个类型名使用。花括号内的标识符枚举了spectrum变量可能有的值。因此,color 可能的值是 red、orange、yellow 等。这些符号常量被称为枚举符(emumerator)。
第2个声明使color作为该类型的变量。
1 | color = blue; |
14.9.2 enum常量
blue 和 red到底是什么?从技术层面看,它们是int类型的常量。例如,假定有前面的枚举声明,可以这样写∶
1 | printf("red = %d, orange = %d\n", red, orange); // 0 1 |
red 成为一个有名称的常量,代表整数0。类似地,其他标识符都是有名称的常量,分别代表1~5。只要是能使用整型常量的地方就可以使用枚举常量。例如,在声明数组时,可以用枚举常量表示数组的大小;在switch语句中,可以把枚举常量作为标签。
14.9.3 赋值
在枚举声明中,可以为枚举常量指定整数值:
1 | enum levels {low = 100, medium = 500, high = 1000}; |
14.9.4 共享命名空间
C语言使用名称空间(namespace
)标识程序中的各部分,即通过名称来识别。作用域是名称空间概念的一部分∶两个不同作用域的同名变量不冲突;两个相同作用域的同名变量冲突。
名称空间是分类别的。在特定作用域中的结构标记、联合标记和枚举标记都共享相同的名称空间,该名称空间与普通变量使用的空间不同。这意味着在相同作用域中变量和标记的名称可以相同,不会引起冲突,但是不能在相同作用域中声明两个同名标签或同名变量。例如,在C中,下面的代码不会产生冲突∶
1 | struct rect { |
尽管如此,以两种不同的方式使用相同的标识符会造成混乱。另外,C++不允许这样做,因为它把标记名和变量名放在相同的名称空间中。
14.10 typedef
typedef
工具是一个高级数据特性,利用typedef可以为某一类型自定义名称。这方面与#define
类似,但是两者有3处不同∶
- 与#define 不同,typedef 创建的符号名只受限于类型,不能用于值
- typedef由编译器解释,不是预处理器
- 在其受限范围内,typedef比#define更灵活
假设要用BYTE
这样的符号名表示1字节的数组,则:
1 | typedef unsigned char BYTE; |
该定义的作用域取决于typedef
定义所在的位置。如果定义在函数中,就具有局部作用域,受限于定义所在的函数。如果定义在函数外面,就具有文件作用域。
通常,typedef定义中用大写字母表示被定义的名称,以提醒用户这个类型名实际上是一个符号缩写。当然,也可以用小写。
为现有类型创建一个名称,看上去真是多此一举,但是它有时的确很有用。使用typedef可以提高程序的可移植性。之前提到的sizeof运算符的返回类型∶size_t
类型,以及time函数的返回类型∶time_t
类型。C标准规定sizeof和time返回整数类型,但是让实现来决定具体是什么整数类型。其原因是,C标准委员会认为没有哪个类型对于所有的计算机平台都是最优选择。所以,标准委员会决定建立一个新的类型名(如,time_t),并让实现使用typedef
来设置它的具体类型。
以这样的方式,C标准提供以下通用原型∶
1 | time_t time(time_t *); |
还可以把typedef用于结构:
1 | typedef struct complex { |
然后便可使用COMPLEX类型代替complex结构来表示复数。
使用typedef时要记住,typedef并没有创建任何新类型,它只是为某个已存在的类型增加了一个方便使用的标签。
通过结构、联合和typedef,C提供了有效处理数据的工具和处理可移植数据的工具。
14.11 函数指针
函数指针:假设有一个指向int类型变量的指针,该指针储存着这个int类型变量储存在内存位置的地址。同样,函数也有地址,因为函数的机器语言实现由载入内存的代码组成。指向函数的指针中储存着函数代码的起始处的地址。此外,函数名就是函数的首地址。
声明一个函数指针时,必须声明指针指向的函数类型,为了指明函数类型,要指明函数签名,即函数的返回类型和形参类型。
- 声明和赋值
1 | void ToUpper(char *); // 函数原型 |
- 使用1
1 | void ToUpper(char *); |
- 使用2:将函数指针作为函数的形参
1 | void show(void (*fp)(char *), char * str); // 一个参数即为函数指针 |
15 位操作
15.1 按位运算符
C 提供按位逻辑运算符和移位运算符。
15.1.1 按位逻辑运算符
按位逻辑运算符都用于整型数据,包括char。
- 二进制反码或按位取反:
~
1 | ~(10011010) // 表达式 |
假设val的类型是unsigned char
,已被赋值为2。在二进制中,00000010
表示2。那么,~val
的值是11111101
,即253。注意,该运算符不会改变val的值,就像3*va1不会改变val的值一样,val仍然是2。
- 按位与:
&
- 按位或:
|
- 按位异或:
^
,两个比较的位,只有一个为1,则结果为1
① 掩码
按位与运算符常用于掩码(mask)。所谓掩码指的是一些设置为开(1)或关(0)的位组合。要明白称其为掩码的原因,先来看通过&
把一个量与掩码结合后发生什么情况。例如,假设定义符号常量MASK
为2 (即,二进制形式为00000010),只有1号位是1,其他位都是0。下面的语句∶
1 | flags = flags & MASK; |
把flags中除1号位以外的所有位都设置为0,因为使用按位与运算符(&)任何位与0组合都得0。1号位的值不变。这个过程叫作“使用掩码”,因为掩码中的0隐藏了flags中相应的位。
② 打开位(设置位)
有时,需要打开一个值中的特定位,同时保持其他位不变。例如,一台IBM PC通过向端口发送值来控制硬件。例如,为了打开内置扬声器,必须打开1号位(设置为1),同时保持其他位不变。这种情况可以使用按位或运算符(|
)。
1 | flags |= MASK; // MASK 00000010 |
将flags的1号位设置为1,其余位不变。
③ 关闭位(清空位)
和打开特定的位类似,有时也需要在不影响其他位的情况下关闭指定的位(设置为0)。假设要关闭变量flags中的1号位。同样,MASK只有1号位为1(即,打开)。可以这样做∶
1 | flags &= ~MASK; // MASK=00000010 ~MASK=11111101 |
④ 切换位
切换位指的是打开已关闭的位,或关闭已打开的位。可以使用按位异或运算符(^
)切换位。
假设b是一个位,先看一下b与MASK某一个位做异或操作的结果:
- 如果MASK对应的位是1:b=1,则
1^b=0
;b=0,则1^b=1
- 如果MASK对应的位是0:b不管为1还是0,异或结果都是b本身
因此要切换flags的某一位,MASK对应的位应该设置为1,反之不切换则设置为0
1 | flags ^= MASK; |
15.1.2 移位运算符
- 左移:
<<
,将其左侧运算对象每一位的值向左移动其右侧运算对象指定的位数。左侧运算对象移出左末端位的值丢失,用0填充空出的位置。下面的例子中,每一位都向左移动两个位置∶
1 | (10001010)<<2; |
1 | int stonk = 1; |
- 右移:
>>
,左侧运算对象移出右末端位的值丢。对于无符号类型,用0填充空出的位置;对于有符号类型,其结果取决于机器。空出的位置可用0填充,或者用符号位(即,最左端的位)的副本填充
15.2 位字段
略
16 C预处理器和C库
C预处理器在程序执行之前查看程序(故称之为预处理器)。根据程序中的预处理器指令,预处理器把符号缩写替换成其表示的内容。预处理器可以包含程序所需的其他文件,可以选择让编译器查看哪些代码。预处理器并不知道C。基本上它的工作是把一些文本转换成另外一些文本。
16.1 明示常量:#define
16.1.1 简介
使用#define
指令来定义明示常量(manifest constam)(也叫做符号常量),但是该指令还有许多其他用途。预处理器指令从#开始运行,到后面的第1个换行符为止。也就是说,指令的长度仅限于一行。然而,前面提到过,在预处理开始前,编译器会把多行物理行处理为一行逻辑行。
程序实例
1 |
|
类对象宏定义组成部分:
每行#define(逻辑行)都由3部分组成。
- 第1部分是#define指令本身。
- 第2部分是选定的缩写,也称为宏。有些宏代表值(如本例),这些宏被称为类对象宏(object-like macro)。C 语言还有类函数宏(finction-like macro)。宏的名称中不允许有空格,而且必须遵循C变量的命名规则。
- 第3部分(指令行的其余部分)称为替换列表或替换体。一旦预处理器在程序中找到宏的示实例后,就会用替换体代替该宏(也有例外,就是处在双引号中的宏)。从宏变成最终替换文本的过程称为宏展开(macro expansion)。注意,可以在#define行使用标准C注释。如前所述,每条注释都会被一个空格代替。
打印结果:
1 | X is 2. |
16.1.2 记号
从技术角度来看,可以把宏的替换体看作是记号(token)型字符串,而不是字符型字符串。C 预处理器记号是宏定义的替换体中单独的“词”。用空白把这些词分开。例如∶
1 |
该宏定义有一个记号:2*2
。
1 |
该宏定义有3个记号:4
,*
和8
。
替换体中有多个空格时,字符型字符串和记号型字符串的处理方式不同。
如果预处理器把该替换体解释为字符型字符串,将用4 * 8
替换EIGHT
。即,额外的空格是替换体的一部分。如果预处理器把该替换体解释为记号型字符串,则用3个的记号4*8
(分别由单个空格分隔)来替换EIGHT
。换而言之,解释为字符型字符串,把空格视为替换体的一部分;解释为记号型字符串,把空格视为替换体中各记号的分隔符。在实际应用中,一些C编译器把宏替换体视为字符串而不是记号。在比这个例子更复杂的情况下,两者的区别才有实际意义。
16.2 在#define
中使用参数
16.2.1 简介
在#define 中使用参数可以创建外形和作用与函数类似的类函数宏。带有参数的宏看上去很像函数,因为这样的宏也使用圆括号。类函数宏定义的圆括号中可以有一个或多个参数,随后这些参数出现在替换体中。
例如:
1 |
|
16.2.2 用宏参数创建字符串:#运算符
1 |
|
输出结果为:
1 | The square of X is 64 |
注意双引号字符串中的X被视为普通文本,而不是一个可以被替换的记号。
C允许在字符串中包含宏参数。在类函数宏的替换体中,#
号作为一个预处理运算符,可以把记号转换成字符串。例如,如果x是一个宏形参,那么#x
就是转换为字符串"x"
的形参名。这个过程称为字符串化(stringizing)。
1 |
|
打印结果:
1 | The square of y is 25. |
调用第1个宏时,用"y"
替换#x
。调用第2个宏时,用"2 + 4"
替换#x
。
16.2.3 预处理器粘合剂:##运算符
与#运算符类似,##
运算符可用于类函数宏的替换部分。而且,##还可用于对象宏的替换部分。##运算符把两个记号组合成一个记号。例如,可以这样做∶
1 |
然后宏XNAME(4)
将展开为x4
。
1 |
|
打印结果:
1 | x1 = 14 |
PRINT_XN宏用#运算符组合字符串,##运算符把记号组合为一个新的标识符。
16.3 宏和函数的选择
宏和函数的选择实际上是时间和空间的权衡。宏生成内联代码,即在程序中生成语句。如果调用20次宏,即在程序中插入20行代码。如果调用函数20次,程序中只有一份函数语句的副本,所以节省了空间。然而另一方面,程序的控制必须跳转至函数内,随后再返回主调程序,这显然比内联代码花费更多的时间。
宏的一个优点是,不用担心变量类型(这是因为宏处理的是字符串,而不是实际的值)。因此,只要能用int或float类型都可以使用SQUARE(x)
宏。
16.4 文件包含:#include
16.4.1 简介
当预处理器发现#include 指令时,会查看后面的文件名并把文件的内容包含到当前文件中,即替换源文件中的#include指令。这相当于把被包含文件的全部内容输入到源文件#include指令所在的位置。
#include指令有两种形式∶
#include <stdio.h>
#include "mystuff.h"
在 UNIX 系统中,尖括号告诉预处理器在标准系统目录中查找该文件。双引号告诉预处理器首先在当前目录中(或文件名中指定的其他目录)查找该文件,如果未找到再查找标准系统目录。
16.4.2 头文件的使用
头文件中最常用的形式如下:
- 明示常量:例如,stdio.h中定义的EOF、NULL和BUFSIZE(标准I/O缓冲区大小)。
- 宏函数
- 函数声明:例如,string.h头文件(一些旧的系统中是strings.h)包含字符串函数系列的函数声明。在ANSIC和后面的标准中,函数声明都是函数原型形式。
- 结构模版定义:标准I/O函数使用FILE 结构,该结构中包含了文件和与文件缓冲区相关的信息。FILE 结构在头文件stdio.h中。
- 类型定义:标准 I/O 函数使用指向 FILE 的指针作为参数。通常,stdio.h 用#define 或typedef把FILE定义为指向结构的指针。
程序实例
1 | // name_st.h |
1 | // names_st.c |
1 | // user_header.c |
#include
和define
指令是最常用的两个C预处理器特性。
16.5 其他指令
16.5.1 #undef
作用:用于取消已定义的#define指令。
1 |
如果想使用一个名称,又不确定之前是否已经用过,为安全起见,可以用#undef 指令取消该名字的定义。
16.5.2 条件编译
① #ifdef
,#else
和#endif
这三条指令很像C的条件判断,但是预处理器不识别用于标记块的花括号。
程序示例
1 |
有些较新的编译器可以支持缩进,否则必须左对齐所有指令。
② #ifndef
#ifndef
指令判断后面的标识符是否是未定义的,常用于定义之前未定义的常量。
1 |
通常,包含多个头文件时,其中的文件可能包含了相同宏定义。**#ifndef指令可以防止相同的宏被重复定义**。在首次定义一个宏的头文件中用#ifndef指令激活定义,随后在其他头文件中的定义都被忽略。
#ifndef指令通常用于防止多次包含一个文件。也就是说,应该像下面这样设置头文件∶
1 | // test.h |
假设该文件被包含了多次。当预处理器首次发现该文件被包含时,TEST_H_
是未定义的,所以定义了TEST_H_
,并接着处理该文件的其他部分。当预处理器第2次发现该文件被包含时,TEST_H_
是已定义的,所以预处理器跳过了该文件的其他部分。
为何要多次包含一个文件?最常见的原因是,许多被包含的文件中都包含着其他文件,所以显式包含的文件中可能包含着已经包含的其他文件。
但是,这样存在一个问题∶如何确保待测试的标识符没有在别处定义。通常,实现的供应商使用这些方法解决这个问题∶用文件名作为标识符、使用大写字母、用下划线字符代替文件名中的点字符、用下划线字符做前缀或后缀(可能使用两条下划线)。例如,查看stdio.h头文件,可以发现许多类似的代码∶
1 |
|
一般来说,自己定义头文件时,在前面和最后用两个下划线:
1 | // test.h |
③ if
和elif
略
16.5.3 预定义宏
C标准规定了一些预定义宏:
代码示例
1 |
|
打印输出:
1 | The file is E:\develop\study\project-study\C++_learn\predef.c. |
16.6 C库
最初,并没有官方的C库。后来,基于UNIX的C实现成为了标准。ANSIC委员会主要以这个标准为基础,开发了一个官方的标准库。在意识到C语言的应用范围不断扩大后,该委员会重新定义了这个库,使之可以应用于其他系统。
库和头文件的区别
- 头文件:在编程过程中,程序代码往往被拆成很多部分,每部分放在一个独立的源文件中,而不是将所有的代码放在一个源文件中。考虑一个简单的小例子:程序中有两个函数main()和abc()。main()函数位于main.cpp,abc()函数位于abc.cpp,main()函数中调用abc()函数。在编译阶段,由于编译是对单个文件进行编译,所以编译main.cpp时,编译器不知道是否存在abc()函数以及abc()调用是否正确,因此需要头文件辅助。也就是说,在编译命令:
cl.exe /c main.cpp
运行时,编译器不知道abc的用法是否正确(因为abc在另一个文件abc.cpp中),只有借助头文件中的函数声明来判断。对main.cpp进行编译时,不会涉及abc.cpp文件,只会涉及main.cpp 和abc.h(因为abc.h被include)文件。- 头文件以
.h
结尾,可以用文本编辑器查看内容。是ASCII的。
- 头文件以
- 库文件:包含一系列的子程序。例如abc.cpp源文件中实现了abc()函数,我们假设abc()函数是包含重要算法的函数,我们需要将abc()函数提供给客户使用,但是不希望客户看到算法源代码。为了达到这一目的,我们可以将abc.cpp编译成库文件,库文件是二进制的,在库文件中是看不到原始的源代码的。库和可执行文件的区别是,库不是独立程序,他们是向其他程序提供服务的代码。 当然使用库文件的好处不仅仅是对源代码进行保密,使用库文件还可以减少重复编译的时间,增强程序的模块化。
- 将库文件链接到程序中,有两种方式,一种是静态连接库
.a
,另一种是动态连接库.so
。库文件是二进制的。两种库的区别在于静态库被调用时直接加载到内存,而动态库再是在需要的时候加载到内存,不使用的时候再从内存释放。
- 将库文件链接到程序中,有两种方式,一种是静态连接库
比如,printf
函数。使用时应包括stdio.h
,打开stdio.h
只能看到,printf这个函数的声明,却看不到printf具体是怎么实现的,而函数的实现在相应的C库中。而库文件一般是以二进制形式而不是C源文件形式提供给用户使用的。程序中包括了stdio.h
这个头文件,链接器就能根据头文件中的信息找到printf这个函数的实现并链接进这个程序代码段里。