结构体很重要,一定要掌握。但是在很多C语言书籍中结构体的内容讲得非常少,因为从结构体开始,后面介绍的内容已经超出C语言基础的范畴,属于C高级编程部分了。仅仅具备前面的知识是远远不够的,因为在实际编程中,真正核心的部分正是自本章开始的C高级部分。
而且结构体会影响到对数据结构和面向对象语言的学习。首先数据结构里面都是链表,所以必须要学结构体。其次如果以后要学习C++或Java的话,那么就必须了解C语言中的结构体,因为面向对象的思想就是从结构体升华出来的。而且学完C语言中的结构体有助于理解C++和Java的“类”。
下面举一个例子,看看为什么需要结构体。
比如存储一个班级学生的信息,肯定包括姓名、学号、性别、年龄、成绩、家庭地址等项。这些项都是具有内在联系的,它们是一个整体,都表示同一个学生的信息。但如果将它们定义成相互独立的变量的话,就无法反映它们的内在联系。
而且问题是这样写的话,只是定义了一个学生,如果要定义第二个学生就要再写一遍。这样不仅麻烦,而且很容易混淆。要是能定义一个变量,而且这个变量正好包含这六个项,即将它们合并成一个整体的话就好了。结构体就是为了解决这个问题而产生的。结构体是将不同类型的数据按照一定的功能需求进行整体封装,封装的数据类型与大小均可以由用户指定。
声明一个结构体类型的一般形式为:
struct 结构体名
{
成员列表
};
比如将学生的信息定义成结构体:
struct STUDENT
{
char name[20];
int num;
char sex;
int age;
float score;
char addr[30];
}; //最后的分号千万不能省略
我们要对结构体做一些说明:
(1)struct STUDENT与系统提供的int、char、float、double等标准类型名一样,都是数据类型,具有同样的作用,都是用来定义变量的。但结构体类型和系统提供的标准类型又有所不同:“结构体类型”不仅要求指定该类型为“结构体类型”,即struct,而且要求指定该类型为某一“特定的”结构体类型,即“结构体名”。因为只有struct才是关键字,而“结构体名”是由编程人员自己命名的。所以说,“结构体类型”不是由系统提供的,而是由编程人员自己指定的。这也就意味着,根据“结构体名”的不同,可以定义无数种“具体的”、“特定的”结构体类型。所以结构体类型并非是固定的一种类型。而int型、char型、float型、double型都是固定的类型。
(2)“结构体名”的命名规范是全部使用大写字母。
(3)声明结构体类型仅仅是声明了一个类型,系统并不为之分配内存,就如同系统不会为类型int分配内存一样。只有当使用这个类型定义了变量时,系统才会为变量分配内存。所以在声明结构体类型的时候,不可以对里面的变量进行初始化。
定义结构体类型变量:
定义结构体类型变量有两种方法。
第一种方法是先声明“结构体类型”,再定义“结构体类型变量”。这种方式比较自由!
比如在所有函数前定义了一个结构体类型struct STUDENT,那么就可以在所有函数中使用它来定义局部的结构体类型变量。如:
struct STUDENT stud1, stud2;
stud1和stud2就是我们定义的结构体变量名。定义了结构体变量之后,系统就会为之分配内存单元。与前面讲的局部变量一样,如果stud1和stud2是在某个函数中定义的局部变量,那么就只能在该函数中使用。在其他函数中可以定义重名的结构体变量而不会相互产生影响。
而第二种方法正好与其相反,它是一种很矛盾很纠结的方法。它是在声明结构体类型的同时定义结构体变量!这就意味着,如果你在所有函数前声明结构体类型,那么定义的变量就是全局变量;那么声明的时候是如何定义变量的呢?我们知道,声明的时候最后有个一分号,就在那个分号前写上你想定义的变量名就行了,如:
struct STUDENT
{
char name[20];
int num;
char sex;
int age;
float score;
char addr[30];
}stud;
这样就声明了一个结构体类型,并用这个类型定义了一个结构体变量stud。这个变量是一个全局变量。
“结构体类型”的声明和使用与函数的定义和使用有所不同,函数的定义可以放在调用处的后面,只需在前面声明一下即可。但是“结构体类型”的声明必须放在“使用结构体类型定义结构体变量”的前面。
如果程序规模比较大,往往会将结构体类型的声明集中放到一个以.h为后缀的头文件中。哪个源文件需要用到此结构体类型,只要用#include命令将该头文件包含到该文件中即可,这样做便于修改和使用。
结构体变量可进行哪些运算:
结构体变量不能相加、不能相减,也不能相互乘除,但结构体变量可以相互赋值。也就是说,可以将一个结构体变量赋给另一个结构体变量。但前提是这两个结构体变量的结构体类型必须相同。
结构体变量的初始化——定义时初始化:
在定义结构体变量时对其进行初始化,只要用大括号“{}”括起来,然后按结构体类型声明时各项的顺序进行初始化即可。各项之间用逗号分隔。如果结构体类型中的成员也是一个结构体类型,则要使用若干个“{}”一级一级地找到成员,然后对其进行初始化。
例如:
#include
struct AGE {
int year;
int month;
int day;
};
struct STUDENT {
char name[20];
int num;
struct AGE birthday;
float score;
};
int main(void) {
struct STUDENT student1 = {"小明", 1207041, {1989, 3, 29}, 100};
return 0;
}
此外再次强调一下,只有在定义时初始化才能这样写。如果是定义之后再初始化,那么就只能一项一项地进行初始化。这个同数组的初始化是一样的。
结构体变量的引用:
定义了结构体变量之后就可以在程序中对它进行引用,但是结构体变量的引用同一般变量的引用不一样。因为结构体变量中有多个不同类型的成员,所以结构体变量不能整体引用,只能一个成员一个成员地进行引用。
1)不能将一个结构体变量作为一个整体进行引用,只能分别单独引用它内部的成员,引用方式为:
结构体变量名.成员名
如果成员名是一个变量名,那么引用的就是这个变量的内容;如果成员名是一个数组名,那么引用的就是这个数组的首地址。
“. ”是“成员运算符”,它在所有运算符中优先级最高,因此可以将student1.num作为一个整体来看待。我们可以直接对变量的成员进行操作,例如:
student1.num = 1207041;
2)如果结构体类型中的成员也是一个结构体类型,则要用若干个“. ”,一级一级地找到最低一级的成员。因为只能对最低级的成员进行操作。这种“结构体成员也是结构体变量”的形式就有一些C++中“封装”的味道了。
3)可以引用“结构体变量成员”的地址,也可以引用“结构体变量”的地址。如“&student1.num”和“&student1”,前者表示student1.num这个成员在内存中的首地址,后者表示结构体变量student1在内存中的首地址。在C语言中,结构体变量的首地址就是结构体第一个成员的首地址。所以&student1就等价于第一个成员name的首地址,而name是一个数组,数组名表示的就是数组的首地址。所以&student1和student1.name是等价的。但是要注意的是,它们的等价指的仅仅是“它们表示的是同一个内存空间的地址”,但它们的类型是不同的。&student1是结构体变量的地址,它是struct STUDENT *型的;而student1.name是数组名,所以是char *型的。类型的不同导致它们在程序中不能相互替换,这点在后面的编程中会有深刻体会。
4)结构体变量的引用方式决定了:
①“结构体变量名”可以与“结构体成员名”同名。
②“结构体变量名”可以与“结构体名”同名。
③“两个结构体类型定义的结构体变量中的成员可以同名”。就比如定义了一个结构体类型用于存放学生的信息,里面有成员“char name[20]; ”,那么如果又定义了一个结构体类型用于存放老师的信息,那么里面也可以有成员“char name[20]; ”。
因为结构体成员在引用时,必须要使用“结构体变量名.成员名”的方式来引用,通过引用就可以区分它们,所以不会产生冲突,因此可以同名!只要不冲突,都可以重名!!但是两个结构体变量名就不可以重名了,因为无法区分它们,就会产生冲突。当然这里说的是在同一个作用域内,如果在一个函数中定义一个局部变量a,那么在另一个函数中当然也可以定义一个局部变量a。它们互不影响!
结构体变量的初始化——先定义后初始化:
了解结构体变量的引用之后,下面演示一下如何定义后再初始化:
#include
#include
struct AGE {
int year;
int month;
int day;
};
struct STUDENT {
char name[20]; //姓名
int num; //学号
struct AGE birthday; /*用struct AGE结构体类型定义结构体变量birthday,即生日*/
float score; //分数
};
int main(void) {
struct STUDENT student1; /*用struct STUDENT结构体类型定义结构体变量student1*/
strcpy(student1.name, "小明"); //不能写成&student1
student1.num = 1207041;
student1.birthday.year = 1989;
student1.birthday.month = 3;
student1.birthday.day = 29;
student1.score = 100;
printf("name : %s\n", student1.name); //不能写成&student1
printf("num : %d\n", student1.num);
printf("birthday : %d-%d-%d\n", student1.birthday.year, student1.birthday.
month, student1.birthday.day);
printf("score : %.1f\n", student1.score);
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
name : 小明
num : 1207041
birthday : 1989-3-29
score : 100.0
--------------------------------------
*/
对于这个程序有人会有如下疑问:
1)为什么“strcpy(student1.name, "小明"); ”不能写成“student1.name="小明"; ”?这一点我们在前面讲过。如果要给字符数组赋汉字的话,只能整体赋值。而要想整体赋值,要么在定义时,要么调用strcpy函数。所以即使赋的不是汉字也不能那么写。
2)前面不是说&student1和student1.name是等价的吗?为什么strcpy中不能写成“strcpy(&student1, "小明"); ”呢?同样,&student1和student1.name的等价指的仅仅是“它们表示的是同一个内存空间的地址”,但它们的类型是不同的。&student1是结构体变量的地址,是struct STUDENT *型的;而student1.name是char *型的。我们在前面学习strcpy的时候知道,strcpy的两个参数都是char *型的,所以不能将struct STUDENT *型的变量赋给char *型。如果要赋的话就必须要进行强制类型转换,即“strcpy((char *)&student1, "小明"); ”。
用scanf从键盘输入进行初始化:
下面尝试一下用scanf手动从键盘输入信息对结构体变量进行初始化:
#include
struct AGE {
int year;
int month;
int day;
};
struct STUDENT {
char name[20];
int num;
struct AGE birthday;
float score;
}; //分号不能省
int main(void) {
struct STUDENT student1; /*用struct STUDENT结构体类型定义结构体变量student1*/
printf("请输入姓名:");
scanf("%s", student1.name); //不能写成&student1
printf("请输入学号:");
scanf("%d", &student1.num);
printf("请输入生日:");
scanf("%d", &student1.birthday.year);
scanf("%d", &student1.birthday.month);
scanf("%d", &student1.birthday.day);
printf("请输入成绩:");
scanf("%f", &student1.score);
printf("name: %s\n", student1.name); //不能写成&student1
printf("num: %d\n", student1.num);
printf("birthday: %d-%d-%d\n", student1.birthday.year, student1.birthday.
month, student1.birthday.day);
printf("score: %.1f\n", student1.score);
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
请输入姓名:小明
请输入学号:1207041
请输入生日:1989 3 29
请输入成绩:100
name: 小明
num: 1207041
birthday: 1989-3-29
score: 100.0
--------------------------------------
*/
假如有一个数组,数组名是a。我们知道a表示的就是这个数组的首地址,但是有些编译器会对数组名a取地址,即&a也等同于数组的首地址。虽然这么写从语法的角度是没有意义的,但程序却是正确的。所以上面程序中“scanf("%s",student1.name); ”也可以这么写:
scanf("%s", &student1.name);
虽然在编译器中,&student1.name代表的也是name的首地址,但是不建议你们这么写,原因如下:
1)这么写没有语法意义。
2)它只是编译器自己规定的,并不是所有的编译器都会这样定义,所以这么写不具备通用性。
3)这么写可读性很差,让人感到困惑且郁闷。
下面再问大家一个问题:“scanf("%s", student1.name); ”可以改成“gets(&student1); ”吗?不可以!同样,&student1是struct STUDENT *型的,而gets()函数的参数只能是char*型的。所以不能将struct STUDENT *型的变量赋给char *型变量。如果要使用gets(),要么写成“gets(student1.name); ”,要么进行强制类型转换即“gets((char *)&student1); ”。
结构体字节对齐:
下面问大家一个问题:
struct STUDENT
{
char a;
int b;
}data;
如上结构体变量data占多少字节?因为char占1字节,int占4字节,所以总共占5字节吗?
我们不妨写程序测试一下:
#include
struct STUDENT {
char a;
int b;
} data;
int main(void) {
printf("%p, %p\n", &data.a, &data.b); //%p是取地址输出控制符
printf("%d\n", sizeof(data));
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
00427E68, 00427E6C
8
--------------------------------------
*/
我们看到data不是占5字节,而是占8字节。变量a的地址是从00427E68到00427E6B,占4字节;变量b的地址是从00427E6C到00427E6F,也占4字节。b占4字节我们能理解,但a是char型,char型不是占1字节吗,这里为什么占4字节?其实不是它占了4字节,它占的还是1字节,只不过结构体中有一个字节对齐的概念。
什么叫字节对齐?我们知道结构体是一种构造数据类型,里面可以有不同数据类型的成员。在这些成员中,不同的数据类型所占的内存空间是不同的。那么系统是怎么给结构体变量的成员分配内存的呢?或者说这些成员在内存中是如何存储的呢?通过上面这个例子我们知道肯定不是顺序存储的。
也就是说,虽然char只占1字节,但是为了与4字节的长度对齐,它后面的3字节都会空着,所谓空着其实也不是里面真的什么都没有,它就同定义了一个变量但没有初始化一样,里面是一个很小的、负的填充字。为了便于表达,我们就暂且称之为空好了。如果结构体成员为:
struct STUDENT
{
char a;
char b;
int c;
}data;
那么这三个成员是怎么对齐的?a和b后面都是空3字节吗?不是!如果没有b,那么a后面就空3字节,有了b则b就接着a后面填充。所以这时候结构体变量data仍占8字节。
我们说,所有的成员在分配内存时都要与所有成员中占内存最多的数据类型所占内存空间的字节数对齐。假如这个字节数为N,那么对齐的原则是:理论上所有成员在分配内存时都是紧接在前一个变量后面依次填充的,但是如果是“以N对齐”为原则,那么,如果一行中剩下的空间不足以填充某成员变量,即剩下的空间小于某成员变量的数据类型所占的字节数,则该成员变量在分配内存时另起一行分配。
现在大家应该能掌握字节对齐的精髓了吧!下面给大家出一个题目试试掌握情况。我们再来尝试求一下data占用的字节数,给出下面结构体:
struct STUDENT
{
char a;
int b;
char c;
}data;
即将原来第二个和第三个声明交换了位置,大家看看现在data变量占多少字节?没错,是12字节。
首先最长类型所占字节数为4,所以是以4对齐。分配内存的时候a占1字节,然后b想紧接着a后面存储,但a后面还剩3字节,小于b的4字节,所以b另起一行分配。然后c想紧接着b后面分配,但是b后面没空了,所以c另起一行分配。所以总共12字节。
我们看到,同样三个数据类型,只不过交换了一下位置,结构体变量data所占的内存空间就由8字节变成12字节,多了4字节。这就告诉我们,在声明结构体类型时,各类型成员的前后位置会对该结构体类型定义的结构体变量所占的字节数产生影响。没有规律的定义会增加系统给结构体变量分配的字节数,降低内存分配的效率。但这种影响对操作系统来说几乎是可以忽略不计的!所以我们在写程序的时候,如果有心的话,声明结构体类型时就按成员类型所占字节数从小到大写,或从大到小写。但是如果没有按规律书写的话也不要紧,声明结构体类型时并非一定要从小到大声明,只是为了说明“字节对齐”这个概念!而且有时候为了增强程序的可读性我们就需要没有规律地写。
一个结构体变量可以存放一个学生的一组信息,可是如果有10个学生呢?难道要定义10个结构体变量吗?难道上面的程序要复制和粘贴10次吗?很明显不可能,这时就要使用数组。结构体中也有数组,称为结构体数组。
定义结构体数组的方法很简单,同定义结构体变量是一样的,只不过将变量改成数组。或者说同前面介绍的普通数组的定义是一模一样的,如:
struct STUDENT stu[10];
结构体数组的引用与引用一个结构体变量在原理上是一样的。只不过结构体数组中有多个结构体变量,我们只需利用for循环一个一个地使用结构体数组中的元素。
下面编写一个程序。编程要求:从键盘输入5个学生的基本信息,如姓名、年龄、性别、学号,然后将学号最大的学生的基本信息输出到屏幕。
#include
#include
struct STU {
char name[20];
int age;
char sex;
char num[20];
};
void OutputSTU(struct STU stu[5]); //函数声明,该函数的功能是输出学号最大的学生信息
int main(void) {
int i;
struct STU stu[5];
for (i=0; i<5; ++i) {
printf("请输入第%d个学生的信息:\n", i+1);
scanf ("%s%d %c%s", stu[i].name, &stu[i].age, &stu[i].sex, stu[i].
num); /*%c前面要加空格,不然输入时会将空格赋给%c*/
}
OutputSTU(stu);
return 0;
}
void OutputSTU(struct STU stu[5]) {
struct STU stumax = stu[0];
int j;
for (j=1; j<5; ++j) {
if (strcmp(stumax.num, stu[j].num) < 0) { //strcmp函数的使用
stumax = stu[j];
}
}
printf("学生姓名:%s 学生年龄:%d 学生性别:%c 学生学号:%s\n", stumax.name,
stumax.age, stumax.sex, stumax.num);
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------------------------------------------------
请输入第1个学生的信息:
小红 22 F Z1207031
请输入第2个学生的信息:
小明 21 M Z1207035
请输入第3个学生的信息:
小七 23 F Z1207022
请输入第4个学生的信息:
小欣 20 F Z1207015
请输入第5个学生的信息:
小天 19 M Z1207024
学生姓名:小明 学生年龄:21 学生性别:M学生学号:Z1207035
--------------------------------------------------------------------------------------------
*/
结构体数组定义时初始化:
结构体数组的初始化与前面讲的数值型数组的初始化是一模一样的,数值型数组初始化的方法和需要注意的问题在结构体数组的初始化中同样适用,因为不管是数值型数组还是结构体数组都是数组。
前面讲过,&student1表示结构体变量student1的首地址,即student1第一个项的地址。如果定义一个指针变量p指向这个地址的话,p就可以指向结构体变量student1中的任意一个成员。那么这个指针变量定义成什么类型呢?只能定义成结构体类型,且指向什么结构体类型的结构体变量,就要定义成什么样的结构体类型。比如指向struct STUDENT类型的结构体变量,那么指针变量就一定要定义成struct STUDENT *类型。下面将前面的程序用指针的方式修改一下:
#include
#include
struct AGE {
int year;
int month;
int day;
};
struct STUDENT {
char name[20]; //姓名
int num; //学号
struct AGE birthday; //生日
float score; //分数
};
int main(void) {
struct STUDENT student1; /*用struct STUDENT结构体类型定义结构体变量student1*/
struct STUDENT *p = NULL; /*定义一个指向struct STUDENT结构体类型的指针变量p*/
p = &student1; /*p指向结构体变量student1的首地址,即第一个成员的地址*/
strcpy((*p).name, "小明"); //(*p).name等价于student1.name
(*p).birthday.year = 1989;
(*p).birthday.month = 3;
(*p).birthday.day = 29;
(*p).num = 1207041;
(*p).score = 100;
printf("name : %s\n", (*p).name); //(*p).name不能写成p
printf("birthday : %d-%d-%d\n", (*p).birthday.year, (*p).birthday.month, (*p).
birthday.day);
printf("num : %d\n", (*p).num);
printf("score : %.1f\n", (*p).score);
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
name : 小明
birthday : 1989-3-29
num : 1207041
score : 100.0
--------------------------------------
*/
/*我们看到,用指针引用结构体变量成员的方式是:
(*指针变量名).成员名 */
注意,*p两边的括号不可省略,因为成员运算符“. ”的优先级高于指针运算符“*”,所以如果*p两边的括号省略的话,那么*p.num就等价于*(p.num)了。
从该程序也可以看出:因为指针变量p指向的是结构体变量student1第一个成员的地址,即字符数组name的首地址,所以p和(*p).name是等价的。但同样需要注意的是,“等价”仅仅是说它们表示的是同一个内存单元的地址,但它们的类型是不同的。指针变量p是struct STUDENT *型的,而(*p).name是char *型的。所以在strcpy中不能将(*p).name改成p。用%s进行输入或输出时,输入参数或输出参数也只能写成(*p).name而不能写成p。
此外为了使用的方便和直观,用指针引用结构体变量成员的方式:
(*指针变量名).成员名
可以直接用:
指针变量名->成员名
来代替,它们是等价的。“->”是“指向结构体成员运算符”,它的优先级同结构体成员运算符“. ”一样高。p->num的含义是:指针变量p所指向的结构体变量中的num成员。p->num最终代表的就是num这个成员中的内容。
但是要注意的是,只有“指针变量名”后面才能加“->”,千万不要在成员名如birthday后面加“->”。
指向结构体数组的指针:
在前面讲数值型数组的时候可以将数组名赋给一个指针变量,从而使该指针变量指向数组的首地址,然后用指针访问数组的元素。结构体数组也是数组,所以同样可以这么做。
我们知道,结构体数组的每一个元素都是一个结构体变量。如果定义一个结构体指针变量并把结构体数组的数组名赋给这个指针变量的话,就意味着将结构体数组的第一个元素,即第一个结构体变量的地址,也即第一个结构变量中的第一个成员的地址赋给了这个指针变量。比如:
#include
struct STU {
char name[20];
int age;
char sex;
char num[20];
};
int main(void) {
struct STU stu[5] = {{"小 红", 22, ' F' , "Z1207031"}, {"小 明", 21, ' M' ,
"Z1207035"
}, {"小七", 23, ' F' , "Z1207022"}
};
struct STU *p = stu;
return 0;
}
此时指针变量p就指向了结构体数组的第一个元素,即指向stu[0]。我们知道,当一个指针指向一个数组后,指针就可以通过移动的方式指向数组的其他元素。这个原则对结构体数组和结构体指针同样适用,所以p+1就指向stu[1]的首地址;p+2就指向stu[2]的首地址……所以只要利用for循环,指针就能一个个地指向结构体数组元素。这样指针指向结构体数组本质上就与15.4.1节的指向结构体变量一样了。同样需要注意的是,要将一个结构体数组名赋给一个结构体指针变量,那么它们的结构体类型必须相同。
下面编写一个程序:
#include
struct STU {
char name[20];
int age;
char sex;
char num[20];
};
int main(void) {
struct STU stu[3] = {{"小 红", 22, ' F' , "Z1207031"}, {"小 明", 21, ' M' ,
"Z1207035"
}, {"小七", 23, ' F' , "Z1207022"}
};
struct STU *p = stu;
for (; pname, p->age, p->sex, p->num);
}
return 0;
}
/*
在VC++ 6.0中的输出结果是:
-------------------------------------------------------
name:小红;age:22; sex:F; num:Z1207031
name:小明;age:21; sex:M; num:Z1207035
name:小七;age:23; sex:F; num:Z1207022
-------------------------------------------------------
*/
此外同前面“普通数组和指针的关系”一样,当指针变量p指向stu[0]时,p[0]就等价于stu[0]; p[1]就等价于stu[1]; p[2]就等价于stu[2]……所以stu[0].num就可以写成p[0]. num,其他同理。下面将上面的程序用p[i]的方式修改一下:
#include
struct STU {
char name[20];
int age;
char sex;
char num[20];
};
int main(void) {
struct STU stu[3] = {{"小 红", 22, ' F' , "Z1207031"}, {"小 明", 21, ' M' ,
"Z1207035"
}, {"小七", 23, ' F' , "Z1207022"}
};
struct STU *p = stu;
int i = 0;
for (; i<3; ++i) {
printf("name:%s; age:%d; sex:%c; num:%s\n", p[i].name, p[i].age, p[i].sex,
p[i].num);
}
return 0;
}
/*
在VC++ 6.0中的输出结果是:
-------------------------------------------------------
name:小红;age:22; sex:F; num:Z1207031
name:小明;age:21; sex:M; num:Z1207035
name:小七;age:23; sex:F; num:Z1207022
-------------------------------------------------------
*/
下面再来看看如何通过函数完成对结构体变量的输入和输出。我们讲这个实际上讲的是指针的优点——发送数据很快。这时候有一个问题:完成结构体变量输入的函数需要返回值吗?不管是输入还是输出都不需要返回值。因为输入的话,用户直接从键盘上进行输入;而输出就是将变量的值从显示器上输出,所以也不需要返回值。
下面编写一个程序:
#include
#include
struct AGE {
int year;
int month;
int day;
}; //分号不能省略
struct STUDENT {
char name[20];
int num;
struct AGE birthday;
float score;
}; //分号不能省
void InputStudent(struct STUDENT *p); //输入函数声明
void OutputStudent(struct STUDENT st); //输出函数声明
int main(void) {
struct STUDENT student1;
InputStudent(&student1); /*调用输入函数,对结构体变量输入,必须传递地址*/
OutputStudent(student1); /*调用输出函数,对结构体变量输出,也可传递地址*/
return 0;
}
void InputStudent(struct STUDENT *p) { /*指针变量p只占4字节*/
strcpy(p->name, "小明"); //等价于strcpy((*p).name, "小明");
(*p).birthday.year = 1989;
(*p).birthday.month = 3;
(*p).birthday.day = 29;
(*p).num = 1207041;
(*p).score = 100;
return;
}
void OutputStudent(struct STUDENT st) {
printf("name : %s\n", st.name);
printf("birthday : %d-%d-%d\n", st.birthday.year, st.birthday.month,
st.birthday.day);
printf("num : %d\n", st.num);
printf("score : %.1f\n", st.score);
return;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
name : 小明
birthday : 1989-3-29
num : 1207041
score : 100.0
--------------------------------------
*/
那么现在有一个问题:到底是传递地址好,还是传递内容好?
如果是传递地址的话,那么就意味着这个函数可以被修改,则该函数就不安全。我设计这个函数的目的只是为了输出,也就是说希望这个函数的功能很单一。但如果传递的是地址的话,那么从理论上讲不但可以输出,还可以被修改。万一程序写错了,函数内部就会修改内存中的数据,这也会成为黑客攻击的漏洞。传递内容也有缺陷。原因是结构体变量中通常有很多的成员,这就导致整个结构体变量往往占用很多字节的内存空间。所以如果发送的是内容的话,那么就意味着接收这些内容的形参也要定义成占用这么多内存空间的变量。这样耗用内存太多,而且系统在执行时也浪费时间。而如果传递地址就不存在这个问题,因为传递地址就可以直接对同一个内存空间进行操作,而不是复制。而且传递地址的话只需要定义4字节的指针变量,也只需要发送第一字节的地址,而不是发送所有的内容,也就不需要占用那么多的内存空间。这样占用内存小了,执行速度就快了,这些都是指针的优点:传递数据快、耗用内存小、执行速度快。但前面讲过,传递地址有缺陷、不安全。那么怎样才能屏蔽这个缺陷呢?就是我们前面讲的const。如果在定义形参的时候在前面添加const,就意味着可以接收地址,但是不能对它的内容进行修改。综上所述,为了减少内存的耗费,提高执行的速度,建议发送地址,即推荐使用结构体指针作为函数参数来传递。
下面将上面程序修改一下,OutputStudent函数改成传递地址:
#include
#include
struct AGE {
int year;
int month;
int day;
}; //分号不能省略
struct STUDENT {
char name[20];
int num;
struct AGE birthday;
float score;
}; //分号不能省略
void InputStudent(struct STUDENT *p); //输入函数声明
void OutputStudent(struct STUDENT const *p); /*输出函数声明。const加在*p前面表示修
饰的是*p*/
int main(void) {
struct STUDENT student1;
InputStudent(&student1); /*调用输入函数,对结构体变量输入,必须传递地址*/
OutputStudent(&student1); /*调用输出函数,对结构体变量输出,也可传递地址*/
return 0;
}
void InputStudent(struct STUDENT *p) {
strcpy(p->name, "小明"); //等价于strcpy((*p).name, "小明");
p->birthday.year = 1989; //“p->”和“(*p).”等价
p->birthday.month = 3;
p->birthday.day = 29;
p->num = 1207041;
p->score = 100;
return;
}
void OutputStudent(struct STUDENT const *p) { //用const进行修饰
printf("name : %s\n", p->name);
printf("birthday : %d-%d-%d\n", p->birthday.year, p->birthday.month,
p->birthday.day);
printf("num : %d\n", p->num);
printf("score : %.1f\n", p->score);
return;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
name : 小明
birthday : 1989-3-29
num : 1207041
score : 100.0
--------------------------------------
*/
使用文件操作可以将数据都存储到一个文件中,如.txt文件,然后在程序中对该文件进行读写操作,将需要的数据读到程序中,或再保存(写入)到文件中。而如果直接保存在变量、数组或链表中,那么程序结束后数据就没有了,就都被释放掉了。
总之,一般大型的程序都要用到文件,所以文件操作非常重要,而且事实上也不难。
那么文件存储在什么地方呢?文件通常存储在外部介质上,比如存储在U盘或计算机的硬盘中。只有在使用时才会被调入内存,即复制一份到内存中。事实上所有存储在外部介质上的数据都是文件。
文件的分类:
从不同的角度可以对文件进行不同的分类。从用户的角度看,文件可以分为特殊文件和普通文件。特殊文件就是我们平时所接触不到的系统文件,比如标准输入输出文件、标准设备文件等。普通文件就是我们常见常用的文件。
而从操作系统的角度,每一个与主机相连的输入、输出设备都是一个文件,如键盘——输入文件、显示器和打印机——输出文件。所以对于操作系统而言,一切皆文件。也就是说,在操作系统“眼”里,没有一样不是文件。操作系统就是以文件为单位对数据进行管理的。
按数据的组织形式,文件又可分为ASCII码文件和二进制文件。ASCII码文件又叫文本文件。存放到硬盘中的形式是由ASCII码转换来的,每一字节存放一个ASCII码。二进制文件是将内存中的数据按其在内存中的存储形式原样输出到硬盘上存放。.exe文件就是二进制文件,用UE打开它看到的都是十六进制,其实它是二进制的,只是为了方便我们观看所以UE将它转换成了十六进制。
关于文件的分类我们重点要掌握ASCII码文件和二进制文件,因为这两种文件在后面经常用到。
ASCII码文件和二进制文件的比较如下:
1)ASCII码文件便于对字符进行逐个处理,也便于输出字符。比如说处理数字10000,用这种方式处理的时候不是将它当成一个整体进行处理,而是分成'1'、'0' 、'0' 、'0' 、'0'五个字符分别进行处理。但缺点是需要5字节,这种方式一般占内存比较多,而且要进行转换。
2)对于二进制文件,由于内存中都是以二进制形式存储的,所以不需要转换。而且二进制文件是将10000当成一个数字进行处理,所以只需要2字节就够了。
什么是文件类型指针变量呢?比如:
FILE *fp;
其中FILE是一个结构体类型,用这个结构体类型定义一个指针变量fp,那么这个指针变量就可以指向FILE结构体类型的数据(但现在只是定义了一个指针变量,还没有对它进行初始化)。指针变量fp就称为文件类型指针变量,简称文件指针。那么这个FILE结构体类型“长”什么样呢?里面有哪些成员呢?既然指向该结构体的指针变量称为“文件类型指针变量”,那么说明这个FILE结构体类型肯定与文件有关系。
这个结构体类型不是我们定义的,而是编译器已经定义好的,我们直接拿过来用就行了。它就定义在stdio.h头文件中。你可以进入这个头文件,在里面就可以找到FILE结构体的定义,如下所示。我给每一个成员都做了注释。
struct _iobuf {
char *_ptr; //位置指针,指向缓冲区中下一个待操作的字节
int _cnt; /*缓冲区中还有多少非空字节的数据未读,或者还有多少空的字节可以写*/
char *_base; //位置指针指向的第一个地址
int _flag; //文件标志
int _file; //文件的有效性验证
int _charbuf; //检查缓冲区状况,如果无缓冲区则不读取
int _bufsiz; //缓冲区的大小
char *_tmpfname; //临时文件名
};
typedef struct _iobuf FILE; //用typedef给这个结构体取别名叫FILE
io就是input、output的缩写,buf是buffer的缩写,即“缓冲区”的意思。缓冲区是内存中临时存放数据的一块区域。前面说过,文件都是存储在硬盘中的,只有在使用时才会被调入内存中,调入内存后就是放在这个缓冲区中的。也就是说,当使用一个文件的时候,系统就会在内存中开辟一定字节的空间来存放从硬盘中读取过来的数据。所开辟的内存空间通常为4096字节,因为文件在硬盘中也是以4096字节为单元进行存储的,这个了解一下就行了。
那么这个结构体与读到内存中的文件数据有什么关系呢?这个结构体中存放的就是读到内存中的文件数据的相关信息,比如这些数据存储在内存中的位置、数据的大小等。其中最重要的就是第一个“位置指针”_ptr。这个位置指针并不是由我们操作的,事实上FILE结构体中所有的成员都不是由我们操作的,都是由系统自动操作的。但这个位置指针在后面会经常提到,所以这里有必要先介绍一下。
位置指针指向的是缓冲区中下一个待操作的字节,或读取或写入。这是什么意思呢?比如向一个空的文件中写数据,那么位置指针指向的就是缓冲区的第一字节,每写入一字节,_ptr都会自动加1指向下一个空的字节。而如果是向一个已经有数据的文件的末尾追加数据,那么_ptr指向的就是缓冲区中文件末尾第一个空的字节,同样每写入一字节,_ptr都会自动加1指向下一个空的字节。也就是说,向文件中写入的数据都是写到_ptr所指向的字节中的。那么读数据呢?如果是从一个有数据的文件中读取数据,则_ptr指向的就是缓冲区的第一字节,每读取一字节_ptr都会自动加1,然后读取下一字节。当然,读写数据时也可以通过编程指定_ptr所指向的起始位置,这个稍后再讲。所以对文件的读写操作实际上操作的都是这个位置指针_ptr,所以说它很重要。
但再次说明,对_ptr的操作不是由我们做的,而是由系统完成的。我们只需要知道这个结构体中存放的是“复制到内存缓冲区中的文件数据的相关信息”,如果定义一个文件指针fp指向这个结构体的话,那么通过fp就可找到该结构体,然后通过该结构体中的文件信息就可以找到该文件,并对它进行访问和操作。
最后再来整理一下fp是指向哪的。fp指向文件吗?不是!文件在硬盘上。前面说过,当要使用某个文件的时候,就会将硬盘上的该文件复制到内存缓冲区中。那么fp是指向内存缓冲区的吗?不是!fp指向的是存储这个内存缓冲区中文件信息的结构体。fp是通过这个结构体找到缓冲区中的文件信息的,而不是直接指向缓冲区的。也就是说,它不是直接指向文件,而是间接指向文件。为了便于描述,后面就简称fp是指向文件的指针。同样,位置指针_ptr也称之为是文件的位置指针,就不说是缓冲区的了。
此外需要注意的是,指针变量fp指向的是结构体,结构体在内存中是不会“跑来跑去”的。所以fp一旦指向了一个结构体,那么fp的指向就不会改变。而位置指针_ptr的指向是不停变化的,这是文件指针fp和位置指针_ptr不同的地方。事实上文件指针fp指向的就是位置指针_ptr的地址。因为fp指向的是结构体,而指向结构体的指针变量指向的就是结构体中第一个成员的地址,而FILE结构体的第一个成员就是位置指针_ptr,所以fp指向的就是_ptr的地址。但是需要注意的是,虽然_ptr指向的地址是不断变化的,但_ptr本身的地址是不变的,而fp指向的就是_ptr本身的地址,所以fp的指向是不会改变的。所以这两个地址一定要区分开来,即一个是fp所指向的_ptr本身的地址,另一个是_ptr所指向的地址。此外,文件指针和位置指针这两个指针也一定要区分,这个在后面还会反复强调。所以总结起来就是两个地址和两个指针变量。
前面一直在讲使指针变量fp指向存储文件信息的结构体,那么如何使fp指向存储文件信息的结构体呢?使用fopen();当用fopen()函数打开一个文件的时候,该函数的返回值就是存储该文件信息的结构体的首地址。将这个返回值赋给fp,那么fp就指向这个结构体了。
如果有n个文件,一般要定义n个指针变量,使它们分别指向n个文件,以实现对各个文件的访问。比如:
FILE *fp1;
FILE *fp2;
……
也可以定义FILE *型的指针数组,比如:
FILE *fp[5];
这表示定义了一个有5个元素的结构体指针数组fp,它可以分别指向存放5个文件信息的5个FILE型结构体。
打开文件是用fopen()函数,它也定义在stdio.h头文件中。f是file的缩写,open就是“打开”的意思。该函数的原型是:
#include
FILE *fopen(const char *path, const char *mode);
翻译一下就是:
FILE * fopen(文件名,文件的使用方式)
返回值:如果打开成功则返回FILE *型的文件指针,这个文件指针指向的就是存储所打开文件的信息的结构体。如果将这个指针赋给fp,则fp就指向这个结构体了。比如:
FILE *fp; //首先定义一个指向文件结构体类型的文件指针
fp = fopen(路径/文件名,文件的使用方式); /*然后用fopen()打开一个文件,并把返回的存储该文
件信息的结构体的地址赋给fp。返回值类型为FILE *型,这也是为什么fp要定义成FILE *型的原因*/
如果fopen()打开文件失败则返回空指针NULL。
说明:
1)不要忘记文件的路径,除非该文件是在当前路径下,此时路径可省略,因为省略路径默认的就是当前路径。那么当前路径指的是哪个路径呢?就是你在哪个.c文件中写程序,那么就是该.c文件所在的路径。在Linux中也是一样,假如在用户主目录中定义了一个文件夹test,然后在这个文件夹中创建文件并编写程序,那么当前路径指的就是这个文件夹test,而不是用户主目录。而且此时路径只能写绝对路径,不能写相对路径,比如不能写成~/test,只能写成/home/wmj/test。顺便说一下,Linux中编写C程序文件的后缀名为.c,而编写C++程序文件的后缀名为.cc或.cpp。
2)“文件的使用方式”指的是打开这个文件后,是“只读”还是“只写”,或者是“可读可写”等。
3)打开文件时如果文件有后缀,那么后缀一定要加上,加后缀和不加后缀是不同的文件。
文件的使用方式:
一个打开的文件具有哪些读写权限是在用fopen()打开它的时候指定的。那么有哪些读写权限呢?
权限中r即read, “读”的意思;w即write, “写”的意思;a即append, “追加”的意思;+即“可读可写”的意思;b即binary, “二进制”的意思;只要有r就不会自动创建文件,其他的都会自动创建文件;只要有b就是打开二进制文件,没有b就是打开文本文件。
关于“文件使用方式”有以下几点说明:
1)当使用r和w的时候,读取文件时打开文件的方式一定要与写入文件时打开文件的方式相同。r是读,w是写,这个没法相同,但r和w后面的一定要相同。
即如果以文本文件写入,那么就以文本文件读出;如果以二进制文件写入,那么就以二进制文件读出。打开方式要对应,不要混着用。如果不对应很容易出错,而且出错还不容易检查出来。
2)凡打开方式中有"r"的,那么该文件必须已经存在,否则怎么读呢!
3)凡打开方式中有"w"的,若打开的文件不存在,则系统自动创建该文件。文件可加后缀也可不加后缀,不加后缀默认创建的是二进制文件。此时最好是以带"b"的方式打开,不然可能会出现问题。如果不带"b",那么最好加上后缀.txt。注意,加后缀和不加后缀是两个不同的文件。
4)凡打开方式中有"w"的,若打开的文件已经存在,则系统将该文件删除然后重新创建一个文件。这是"w"最需要注意的地方,这也是"w"的好处。假如学生注册信息都写在一个文件中,而现在管理员想要将其中的一个学生信息删掉,那么怎么操作呢?方法是将文件中的所有数据块全部取出来放到链表中,然后将要删除的那个结点删除,再将链表写回文件中。这时就有一个问题:“原来文件中有三个数据块,现在删除一个还剩两个,这时将这两个数据块写回到原文件中不就只能覆盖前两个数据块吗?最后一个怎么办?”这时用"w"的好处就出来了,用"w"方式打开一个文件时,不管那个文件原来存不存在,打开的都是一个空的文件。因为如果不存在,则新建一个空的;如果存在,则是先将原来的删除,再新建一个空的。所以如果你只想单纯地打开一个文件的话,一定不要使用"w",不然就将里面的数据毁掉了。这时只能用"r"打开,即只能以“只读”的方式打开!
5)若要向一个已存在的文件末尾追加数据,那么只能用带"a"的方式打开文件。如果打开的文件不存在,那么系统会自动创建该文件。此时就等价于以"w"方式打开,因为文件是空的,不用追加,就相当于直接在空文件里面写数据。
练习:
首先在.c文件所在的路径下新建一个文件test.txt,然后通过编程的方式打开这个文件。
#include
int main(void) {
FILE *fp = fopen("test.txt", "r"); /*“文件名”和“使用方式”一定要加双引号*/
if (NULL == fp) {
printf("can not open the file! \n");
} else {
printf("open success! \n");
}
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
open success!
--------------------------------------
*/
文件的关闭是用fclose()函数。文件的关闭和打开是一对,有打开就必须有关闭。
fclose()函数的原型是:
#include
int fclose(FILE *fp);
它的功能很明显,就是关闭文件指针fp所指向的文件。
在前面学习了文件的打开和关闭,但文件打开之后总得对它进行一些操作,这也是打开文件的目的。对文件的读和写是最常用的文件操作。读就是将文件中的内容读出来,写就是将新的内容写入文件中。C语言中提供了多种文件读写函数:
1)字符读写函数:fgetc()和fputc()。
2)格式化读写函数:fscanf()和fprintf()。
3)字符串读写函数:fgets()和fputs()。
4)数据块读写函数:fread()和fwrite()。
其中最重要也最常用的是fread()和fwrite(),这两个函数的使用必须要掌握。其他函数只需要了解一下就行了,对文件读写而言它们用得不多。而fscanf()、fprint()与前面讲的scanf、printf的各种特性一模一样,可以说scanf和printf是fscanf()和fprint()的特殊形式,所以不要觉得陌生。
fputc()
fputc()函数的原型是:
#include
int fputc(int c, FILE *stream);
功能是将字符写到文件指针所指向的文件中。比如:
fputc(ch, fp);
功能是将字符变量ch中的字符写到fp所指向的文件中。
下面有几点需要说明一下:
1)在fputc()函数调用中,要输入内容的文件必须是以“写”或“读写”的方式打开。
2)用写或读写方式打开一个已存在的文件时将清除原有文件的内容,写入字符从文件首开始。如需保留原有文件内容,希望写入的字符从文件末尾开始存放,则必须以追加方式打开文件。
3)写入一个字符后,位置指针_ptr就会自动指向下一字节,然后再向该字节中写入数据。所以写入的第二个字符不会覆盖第一个字符,而是自动排到后面。我们前面说过,位置指针不是人为在程序中定义的,而是由系统定义的,而且不是由我们操作的,是由系统操作的。再次强调一下,大家千万不要将位置指针_ptr和文件指针fp混淆了。当用fopen()打开一个文件时,除了追加写之外,位置指针_ptr都会自动指向文件的第一字节,每读写一次,该指针都会自动向后移动一字节。而fp指向的是位置指针的地址,指向是不会发生改变的。
4)fputc()函数的返回值是:如果写入成功则返回写入字符的ASCII码值,否则返回EOF,也就是“-1”。EOF是定义在stdio.h头文件中的宏,它表示的值就是“-1”。通过fputc()的返回值就可以判断是否写入成功。但是我们一般不会判断函数调用是否成功或出错,除了要求你们进行判断的函数,如malloc()和fopen(),因为它们出错的可能性很大。其实前面讲的很多函数如scanf、printf、getchar()、gets()等都有调用出错时的返回值,但我们都没有考虑它们调用出错的情况,因为这些函数调用出错的可能性太低了,而且就算编程判断了,该发生错误时还是会发生错误,并不会因为你做了错误判断它就不会发生错误,只不过做了错误判断后它会告诉你出错了而已。所以本章后面讲的很多函数如果没有特殊说明,我们都不讨论它们调用出错的判断和处理。
下面写一个程序。编程要求:通过编译创建一个名为hello.txt的文件,并将“i love you”写到这个文件里。
#include
#include
int main(void) {
FILE *fp;
char ch; //用于接收从键盘输入的字符,然后将其写入文件中
char filename[20]; //filename用于存储我们将要创建的文件的名字
printf("please input the filename you want to write:");
scanf("%s", filename);
getchar();
/*将scanf遗留的回车清除,不然下面读入字符的时候直接把回车读进去并直接退出了*/
if (! (fp = fopen(filename, "w+"))) /*在当前路径下新建一个我们自定义名称的文件,因为
要系统自动创建文件,所以我们用w+以可读可写形式打开*/ {
printf("can not open the file! \n");
exit(-1); //终止程序,要使用exit就必须要包含头文件stdio.h
}
printf("please input the sentences you want to write:");
while ((ch = getchar()) != ' \n' ) /*这条语句很经典,既能判断有没有结束,又能读取写入
到文件中的字符,一举两得*/ {
fputc(ch, fp);
}
fclose(fp); //记得将文件关闭
return 0;
}
/*
在VC++ 6.0中的输出结果是:
---------------------------------------------------------------------
please input the filename you want to write:
hello.txt
please input the sentences you want to write:
i love you
---------------------------------------------------------------------
*/
fgetc():
fgetc()函数的原型是:
#include
int fgetc(FILE *stream);
fgetc()的功能与fputc()的功能正好是反过来的。fgetc()的功能是从文件指针指向的文件中读取一个字符,返回值就是读取到的字符,如果读到文件末尾则返回EOF。fgetc()常用的使用格式为:
ch = fgetc(fp);
关于fgetc()有几点需要说明:
1)在fgetc()函数调用中,读取的文件必须是以“读”或“读写”的方式打开。
2)文件分为文本文件和二进制文件。我们知道,在文本文件中,数据都是以字符的ASCII码值的形式存放的。所以读取文本文件时,fgetc()的返回值就是读取到的字符的ASCII码值。而ASCII码值的范围是0~255,不可能出现“-1”。所以以宏值为“-1”的宏EOF作为fgetc()读到文本文件末尾的返回值是非常合理的。但是如果读取的是二进制文件,那就不能用返回值EOF来判断文件是否读到末尾了。因为CPU处理的就是二进制文件,二进制文件里面什么数都有,有正数,有负数,当然也有“-1”。所以读取二进制文件时,任何时候都有可能读到“-1”,所以读到“-1”并不能说明什么问题,因此不能通过返回值EOF判断是否读到二进制文件的末尾。那么该怎么办呢?通常使用feof()函数。feof()函数是专门用来判断是否读到文件末尾的函数。不仅是二进制文件,所有形式的文件都可以用feof()来判断。feof()函数也是包含在stdio.h头文件中的。其中eof是end of file的缩写,即文件结束。feof()函数的原型是:
#include
int feof(FILE *stream);
其功能是判断是否到达文件指针所指向的文件末尾。如果到达文件末尾则返回非0值,否则返回0。
fgetc()程序举例:
编程要求:通过编程将程序新建的文本文件hello.txt打开,并将它里面的字符全部读取出来赋给ch,然后打印出来。因为打开的是文本文件,所以首先用返回值EOF来判断是否读到了文件末尾。
#include
#include
int main(void) {
FILE *fp;
char ch;
int i = 0; //用于测试
if (! (fp = fopen("hello.txt", "r"))) /*以只读方式打开当前路径下的hello.txt文件,
文件名必须用双引号括起来*/
{
printf("can not open the file! \n");
exit(-1);
}
while ((ch = fgetc(fp)) != EOF) /*这种写法很经典,既能从文件中读取字符,又能判断有
没有读到末尾,一举两得*/
{
printf("%c", ch); //将读取到的字符输出
++i;
}
printf("\n");
printf("i = %d\n", i);
fclose(fp); //记得关闭文件
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
i love you
i = 10
--------------------------------------
*/
程序中加了一个用于测试的变量i,这个变量的作用等看完下面这个程序大家就知道了。下面再用feof()来判断是否读到了文件末尾。
#include
#include
int main(void) {
FILE *fp;
int i = 0; //用于测试
if (! (fp = fopen("hello.txt", "r"))) /*以只读方式打开当前路径下的hello.txt文件,
文件名必须用双引号括起来*/
{
printf("can not open the file! \n");
exit(-1);
}
while (! (feof(fp))) { //如果feof()返回非零,说明读到文件末尾
printf("%c", fgetc(fp));
++i;
}
printf("\n");
printf("i = %d\n", i);
fclose(fp); //记得关闭文件
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
i love you
i = 11
--------------------------------------
*/
大家看出什么问题没有?两种写法同样都能判断是否读到了文件的末尾,但用fgetc()的返回值EOF作为循环条件判断时循环体只执行了10次,即文件中有几个字符就读几次;而用feof()的返回值作为循环条件判断时循环体却执行了11次。也就是说,如果用feof()作为循环条件判断是否读到文件末尾的话,那么最后会多读一次,循环体会多执行一次。也就是说,fgetc()最后必须要读一次空的,即读一次末尾,然后feof()才能判断读到了文件末尾。
所以虽然上面这个程序也能将文件中的数据全部读出来,但feof()最后多读的一次、循环体多执行的一次,在不同的编程场合下往往会导致程序出错,无法实现需要的功能。
事实上feof()并不是这么使用的。feof()是必须要用的,省略了feof()程序运行或许是正确的,但是有bug。上面用EOF判断是否读到文件末尾的程序其实是有漏洞的。fgetc()这个函数不仅仅当读到文件末尾时会返回EOF,当出现错误时也会返回EOF。前面之所以没讲这一点就是要突出fgetc()的这个特性,而且如果将“读到文件末尾”和“函数调用出错”混在一起讲的话很难讲清楚。所以当fgetc()返回EOF时并不能断定真的就读到了文件末尾,所以还是需要用feof()来判断。
那么feof()到底该怎么使用呢?因为fgetc()最后必须要读一次末尾,然后feof()才能检测到末尾,而上面第一个程序中:
while ((ch = fgetc(fp)) != EOF) {
printf("%c", ch);
}
如果不是因为函数调用出错而返回EOF的话,那么while循环条件不成立的原因就是因为fgetc()读了一次末尾导致返回值为EOF。这样就正好符合了feof()的使用条件——读了一次末尾。如此再用feof()判断是否真的读到文件末尾就行了,这才是feof()正确的使用方法。下面将程序写下来:
#include
#include
int main(void) {
FILE *fp;
char ch;
if (! (fp = fopen("hello.txt", "r"))) /*以只读方式打开当前路径下的hello.txt文件,
文件名必须用双引号括起来*/
{
printf("can not open the file! \n");
exit(-1);
}
while ((ch = fgetc(fp)) != EOF) /*这种写法很经典,既能从文件中读取字符,又能判断有
没有读到末尾,一举两得*/
{
printf("%c", ch); //输出读取到的字符
}
if (! feof(fp)) { //如果返回0,说明未读到文件末尾
printf("read error");
}
printf("\n");
fclose(fp); //记得将文件关闭
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
i love you
--------------------------------------
*/
所以判断是否读到文件末尾必须将fgetc()的返回值和feof()的返回值配合使用,而不能仅靠fgetc()的返回值或仅靠feof()的返回值。所以feof()正确的使用方法是:不要将feof()作为读的循环条件,而是读完之后再用feof()进行判断。
学完fgetc()和fputc()之后再学fgets()和fputs()就很简单了。fgets()和fputs()是一次读写一行,与一次读写一个字符相比,一次读写一行速度更快、效率更高!
fgets()和fputs()其实在前面讲字符串的时候已经讲过了。只是前面是使用它们从标准输入流(键盘)读取数据,向标准输出流(屏幕)输出数据。但是因为它们可以读写任何流,所以本节再来介绍一下如何使用它们读写文件流。
fgets():
fgets()函数的原型为:
#include
char *fgets(char *s, int size, FILE *stream);
比如:
fgets(str, n, fp);
功能是从fp所指向的文件中读取n-1个字符赋给字符数组str。为什么是n-1呢?因为最后要留个位置给字符串结束标志符'\0',所以是n-1。同样fp所指向的文件必须可读。
对于fgets()需要注意以下几点:
1)fgets()函数是以行为单位进行读取的,文件的每一行最后都有一个换行符'\n'。如果使用fgets()读取某个文件,n为5,而文件一行有10个字符(包括'\n'),那么第一次调用fgets()后,文件位置指针会偏移到当前读取完的这个字符之后的位置。也就是说,第二次调用fgets()时,会接着第一次读取的后面继续读取。而如果使用fgets() 读取文件的时候n大于该行的字符总数加2(多出来的两个,一个保存文件本身的'\n'换行,另一个保存字符串本身的结束标识'\0'),则文件并不会继续读下去,仅仅只是将这一行读取完,随后文件位置指针会自动偏移至下一行。所以只要将n的值设置得足够大,那么每次就能确保读取一行。
2)fgets()调用成功则返回写入的数组的首地址;如果读到文件末尾或调用失败则返回NULL。所以同fgetc()一样,不能仅靠fgets()的返回值判断是否读到文件末尾。要么使用feof()的返回值作为循环条件,要么使用fgets()的返回值作为循环条件,然后用feof()进行进一步的判断。但前者循环体会多读一次,所以建议使用后者。
3)需要注意的是,fgets()函数只能读取文本文件,不能读取二进制文件,因为fgets()会将二进制文件当成文本文件来处理,这势必会产生乱码。
下面将前面fgetc()程序用fgets()改一下:
#include
#include
# define N 20 //最后不能加分号
int main(void) {
FILE *fp;
char str[N];
if (! (fp = fopen("hello.txt", "r"))) /*以只读方式打开当前路径下的hello.txt文件,
文件名必须用双引号括起来*/
{
printf("can not open the file! \n");
exit(-1);
}
while (fgets(str, N, fp) != NULL) {
printf("%s", str);
}
if (! feof(fp)) { //如果返回0,说明未读到文件末尾
printf("read error");
}
printf("\n");
fclose(fp); //记得关闭文件
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
i love you
--------------------------------------
*/
fputs():
fputs()函数的原型为:
#include
int fputs(const char *s, FILE *stream);
比如:
fputs(str, fp);
功能是将数组str中的内容写入fp所指向的文件中,需要注意的是:
1)同样的,fp所指向的文件必须可写。
2)字符串最后的结束标志符'\0'不会被写入文件。成功写入一个字符串后,文件的位置指针会自动后移,函数返回值为0,所以可以直接多次调用fputs()函数连续写入;如果写入失败则返回EOF。3)fputs()的第一个参数也可以直接写字符串,但记得要加双引号。
下面写一个程序,将当前目录下名为“明天一定要幸福”的文本文件中的内容用fgets()读取出,然后用fputs()将取出的数据写入名为“嗯,你也一定要幸福”的文本文件中。并且最后加一个功能,将读入fputs()文件中的内容用printf打印输出到控制台。
#include
#include
# define N 10 //最后不能加分号
int main(void) {
FILE *fp1, *fp2;
/*读取fp1指向的文件中的数据,写入fp2指向的文件中*/
char str[N];
if (! (fp1 = fopen("明天一定要幸福.txt", "r"))) /*以“只读”方式打开当前路径下的
"明天一定要幸福.txt",文件名必须用双引号括起来*/ {
printf("can not open the file! \n");
exit(-1);
}
if (! (fp2 = fopen("嗯,你也一定要幸福.txt", "w+"))) /*以“可读可写”方式新建一个
"嗯,你也一定要幸福.txt"文件,用来存储读取出来的数据*/ {
printf("can not open the file! \n");
exit(-1);
}
while (! (feof(fp1))) {
fgets(str, N, fp1); //读取数据
fputs(str, fp2); //随即将读取到的数据写入新建的文件中
}
rewind(fp2); //注释1
while (! feof(fp2)) {
fgets(str, N, fp2); //读取数据
fputs(str, stdout); //输出数据,用printf("%s", str)也可以
}
printf("\n");
fclose(fp1); //记得关闭文件
fclose(fp2);
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------------------------------------------------
单纯喜欢你的人,他看到的是你的现在;
真正爱你的人,要和你走的是未来;
真正的爱情,不是某一个时刻的承诺和表白,而是之后一起走过的岁月;
真正的缘分,也并非是冥冥注定的安排,而是两个人彼此认定的决心;
真正的爱人,不是每天夸赞你的那个人,而是看你的眼神里那些浓得化不开的深情。
请记住,不是所有人都是真心,所以,不要那么轻易地就去相信;
请记住,不是所有人值得你付出,所以,不要那么傻地就去给予;
请记住,不是伤心就一定要哭泣,所以,不要那么吝啬你的微笑;
请记住,不是只有你一个人在努力,所以,不要轻易地就放弃。
请记住,明天会更加美好,所以,一定要幸福快乐。
--------------------------------------------------------------------------------
*/
注释1:rewind是英文单词“倒回”的意思。rewind()函数的功能是使文件位置指针指回到文件的开头。因为在往fp2所指向的文件中写入数据的时候,该文件中的位置指针是一直往后移的,等写入结束后它就停在了最后。但是下面还要将该文件中的内容输出到控制台,那么就必须要使位置指针再指回到文件最开始的位置。rewind()函数在后面还会详细介绍。
fseek():
前面说过,每个文件中都有一个位置指针。文件刚打开的时候位置指针指向的是文件的开头。读写几字节的数据,位置指针就向后移动多少字节。那么如何人为控制位置指针的移动呢?可以用fseek()函数。那么为什么要人为控制位置指针的移动呢?控制它有什么用?这个函数是非常有用的。比如后面讲的fprintf()和fscanf()。fprintf()向文件中写完数据后位置指针是指向文件末尾的,此时fscanf()要想读取文件的数据就必须先使用fseek()函数将位置指针指回到文件开头。
fseek()函数的原型为:
#include
int fseek(FILE *stream, long offset, int base);
第一个参数stream为文件指针;第二个参数offset为偏移量,整数表示往后偏移,负数表示往前偏移。这个参数通常设置为0,即不偏移;第三个参数base设置从文件的哪里开始偏移,取值可以为:SEEK_SET(文件开头)、SEEK_CUR(当前位置)或SEEK_END(文件结尾)。
其中SEEK_SET、SEEK_CUR和SEEK_END都是在stdio.h头文件中定义的宏,它们的宏值分别为0、1和2。我们在编程的时候可以写宏,也可以写宏值。写宏的话含义更清楚,写宏值的话更方便。fseek()返回值:成功返回0;失败返回-1。
rewind():
rewind()函数很简单。fseek()可以将位置指针移到文件开头、当前位置或文件结尾。而rewind()的功能很单一,就是将位置指针移到文件开头。它的原型为:
#include
void rewind(FILE *stream);
只有一个参数。stream为文件指针,无返回值。因为fseek()也可以将位置指针移到文件开头,即:
fseek(fp, 0, 0);
等价于:
rewind(fp);
所以如果只是将位置指针移到文件开头的话,那么直接用rewind()就可以了,这样更方便。
fprintf()和fscanf()同前面学习的printf和scanf一模一样,可以说后者是前者的特殊形式。后者只能对标准输入输出文件流进行读写,而前者可以对任何文件流进行读写。它们的使用方式和各种特性也是一模一样的。唯一的区别是,因为后者只能对标准输入输出文件流进行读写,所以函数设计时无需指定读写哪个流,默认就是输入输出文件流;而前者因为可以读写任何流,所以多了一个参数用于指定读写哪个流。
fprintf()和fscanf()的调用形式如下所示。与printf和scanf相比只是多了一个文件指针,表示向哪个文件流中写入数据、从哪个文件流中读出数据。
#include
int fprintf(FILE *stream, "输出控制符", 输出参数);
int fscanf (FILE *stream, "输入控制符", 输入参数);
我们说过,printf、scanf是fprintf()、fscanf()的特殊形式,所以:
printf("输出控制符", 输出参数);
scanf("输入控制符", 输入参数);
fscanf()现在不是从键盘中读取数据了,而是从文件中,所以必须先用fprintf()将数据写入文件中然后由fscanf()读取。这里有一个地方需要注意,fprintf()将数据写入之后文件的位置指针移到了文件末尾,所以fscanf()要想读取文件必须先用fseek()将文件指针移到文件开头。也就是说,scanf是依赖键盘给它的数据,而fscanf()是依赖fprintf()给它的数据。那么非得是fprintf()吗?其他函数也可以向文件中写入数据,文件本身也可以有数据啊!因为fscanf()是需要知道数据的类型的,一个文件中的数据有很多种类型,如果直接让fscanf()读取一个文件的话,它怎么知道要读取的是什么格式的数据呢?所以fprintf()和fscanf()是配套使用的,先由fprintf()写入数据,而后再由fscanf()读取数据。
同scanf一样,如果用fscanf()读取一个字符串,那么空格是字符串的分隔符,比如“i love you”表示的是三个字符串"i"、"love"、"you",一个%s只能读取到一个字符串。
下面写一个程序:
#include
#include
int main(void) {
FILE * fp;
char str[10]; //fscanf读取字符串给它
int i; //fscanf读取整数给它
float j; //fscanf读取实数给它
char ch; //fscanf读取字符给它
fp = fopen("hello.txt", "w+"); //w+可读可写,文件不存在时创建文件
if (NULL == fp) {
printf("can not open the file! \n");
exit(-1);
} else {
fprintf(fp, "%s %d %f%c", "hello", 520, 3.14159, ' x' ); /*注意%s、%d和%f
之间要用空格隔开,不然"hello5203.14159x"会被当成一个字符串赋给str, %f后面不能加空格,不
然%c收到的不是字符x,而是空格*/
fseek(fp, 0, 0); //将位置指针移到文件开头
fscanf(fp, "%s%d%f%c", str, &i, &j, &ch); /*fscanf从文件中读取数据赋给各变量*/
printf("str = %s\ni = %d\nj = %f\nch = %c\n", str, i, j, ch); //输出变量
fclose(fp); //关闭文件
}
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
str = hello
i = 520
j = 3.141590
ch = x
--------------------------------------
*/
在实际编程开发中,fread()和fwrite()用得非常多。为什么前面讲了fgetc()、fputc()、fgets()、fputs(),这里还要讲fread()和fwrite()呢?既然创造了它肯定有它的价值,而且事实上fread()和fwrite()要比fgetc()、fputc()、fgets()、fputs()用得更多。
前面讲的fgetc()和fputc()是字符变量和文件之间的读写,fgets()和fputs()是字符数组和文件之间的读写。但是如果定义了一个结构体变量stud存放学生的姓名、年龄、性别、学号,如下所示:
struct STUDENT
{
char name[20];
int age;
char sex;
char num[20];
}stud = {"周琴琴", 25, ' F' , "Z1207041"};
那么这时候如何将结构体变量stud中的数据写入文件中呢?又如何将文件中的学生信息读取出来赋给结构体变量呢?如果还像前面那样一字节一字节进行读写的话,就太麻烦了,而且也很容易出错。原因是结构体变量中有多个成员,而且每个成员的类型都是不一样的,此外结构体还涉及内存对齐的问题,这就注定一字节一字节读取很容易出问题。那么该怎么办呢?于是就有了fread()和fwrite()。fread()和fwrite()是数据块读写函数。它们不再是以字节为单位进行读写,而是将结构体数据看成一个整体,看成一整块,以整块为单位进行读写。这样即方便,又快速,又不容易出错。下面就来讲一下fread()和fwrite(),首先它们都是包含在stdio.h头文件中的。
fwrite()函数:
fwrite()是将结构体变量中的数据以块的形式,看成一个整体一次性地写入文件中。该函数的原型为:
#include
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
其中size_t即unsigned long型。比如:
fwrite(&stud, sizeof(struct STUDENT), count, fp);
其中,&stud表示要写入文件的结构体变量的地址;sizeof(struct STUDENT)表示这个结构体类型占多少字节;count表示这么大的结构体变量你想写入多少“块”,count通常都取1,即一块一块地写入;fp表示要写入的文件的文件指针。
fwrite()的返回值是写入文件中的结构体的“块数”count。如果出错则返回比count小的数或返回0,总之不等于count。所以通过fwrite()的返回值就可以判断是否成功写入。
下面写一个程序,要求手动从键盘对三个结构体变量进行初始化,然后通过fwrite()将这三个结构体变量中的数据写入文件中。
#include
# include //exit()函数包含在该头文件中
struct STUDENT {
char name[20];
int age;
char sex;
char num[20];
};
int main(void) {
int i; //循环变量
int cnt; //学生的个数
FILE *fp;
struct STUDENT stud[100];
printf("您想输入几个学生的信息:");
scanf("%d", &cnt);
if (NULL == (fp=fopen("stu_list", "wb"))) {
printf("cant open the file");
exit(-1);
}
printf("请分别输入学生的姓名、年龄、性别、学号:\n");
for (i=0; i
这时我们看到在当前路径下就多了一个stu_list文件,但它是一个二进制文件。那么我们怎么验证这三个结构体变量中的数据是否写入了该文件中呢?下面就用fread()读取这个文件,看读取的数据是不是写入的数据就能验证了。
fread()函数:
fread()是将文件中的结构体数据块读取出来,再以块的形式,作为整体一次性地赋给结构体变量。该函数的原型与fwrite()函数的原型是一模一样的:
#include
size_t fread(void *ptr, size_t size, size_t count, FILE *stream);
其中size_t即unsigned long型。
比如:
fread(&stud, sizeof(struct STUDENT), count, fp);
我们看到,它与fwrite()函数的参数是完全一样的,只是解释的时候正好反过来。&stud表示要赋给的结构体变量的地址;sizeof(struct STUDENT)表示这个结构体类型占多少字节;count表示这么大的结构体变量你想读取多少“块”, count通常都取1,即一块一块地读取;fp表示读取的那个文件的文件指针。
说明:
1)在文件和结构体变量之间读写数据时,通常count都取1,即一块一块地读取。
2)fread()的返回值是实际读取的“块数”count。如果读到文件末尾或发生错误则返回比count小的数或返回0,总之不等于count。所以与fgetc()、fgets()一样,要用fread()的返回值和feof()的返回值配合判断是否读到文件末尾。你也可以用feof()的返回值作为循环条件来判断,但是使用的时候要小心,因为循环体会多执行一次。所以还是建议使用前者。
3)fread()和fwrite()只能以二进制的形式打开文件,所以打开方式中要有b。fwrite()将结构体变量中的非二进制数据转换成二进制写入文件中;fread()又从文件中将这些二进制数据通过输出控制符转换成相应的%s、%c或%d型数据赋给结构体变量。
4)既然是以二进制形式打开,如果是自动创建文件,则文件无需加后缀,不加后缀默认就是创建二进制文件。
ftell():
在前面使用fread()的返回值和feof()的返回值配合判断是否读到文件末尾,本节再给大家介绍一种方法,就是使用ftell()函数。它的原型为:
#include
long ftell(FILE *stream);
这个函数很简单,但是用处很大。它的功能是返回文件指针所指向的文件位置指针的当前位置相对于文件开头的偏移字节数。所以只要使用fseek()将文件的位置指针移到文件末尾,然后用ftell()就能得出该文件总的字节数。
下面写一个程序,我们先在当前目录下新建一个test.txt文件,然后在里面写上“i love you”。这句话加上中间的空格总共10字节。我们用ftell()看看其能不能准确计算。
#include
#include
int main(void) {
int count; //用于存储文件的字节数
FILE * fp;
if (NULL == (fp=fopen("test.txt", "r"))) { //以只读方式打开
printf("cant open the file");
exit(-1);
}
fseek(fp, 0, 2); //将文件指针移到文件末尾
count = ftell(fp); //计算文件的字节数
printf("count = %d\n", count);
fclose(fp);
return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
count = 10
--------------------------------------
*/
ftell()算出的就是10,即文件中所有数据的字节数。但是这种通过使用fseek()和ftell()计算文件字节数然后读取文件的方法有两点需要注意:
1)用fseek()将文件的位置指针移动到文件末尾,然后用ftell()计算完文件总的字节数之后,必须要再用fseek()或rewind()将位置指针指回到文件开头。不然读的时候就从末尾开始读了。
2)ftell()计算出来的是文件总的字节数,但fread()读取文件时不是按字节单位读取的,而是按块读的。所以还必须要将得到的总的字节数转换为块数。