文件输入和输出
大多数计算机程序都使用了文件。字处理程序创建文档文件;数据库程序创建和搜索信息文件;编译器读取源代码文件并生成可执行文件。文件本身是存储在某种设备(磁带、光盘、软盘或硬盘)上的一系列字节。通常,操作系统管理文件,跟踪它们的位置、大小、创建时间等。除非在操作系统级别上编程,否则通常不必担心这些事情。需要的只是将程序与文件相连的途径、让程序读取文件内容的途径以及让程序创建和写入文件的途径。重定向(本章前面讨论过)可以提供一些文件支持,但它比显式程序中的文件 I/O 的局限性更大。另外,重定向来自操作系统,而非 C++,因此并非所有系统都有这样的功能。本书前面简要地介绍过文件 I/O,本章将更详细地探讨这个主题。
C++ I/O 类软件包含处理文件输入和输出的方式与处理标准输入和输出的方式非常相似。要写入文件,需要创建一个 ofstream 对象,并使用 ostream 方法,如 << 插入运算符或 write()。要读取文件,需要创建一个 ifstream 对象,并使用 istream 方法,如 >> 抽取运算符或 get()。然而,与标准输入和输出相比,文件的管理更为复杂。例如,必须将新打开的文件和流关联起来。可以以只读模式、只写模式或读写模式打开文件。写文件时,可能想创建新文件、取代旧文件或添加到旧文件中,还可能想在文件中来回移动。为帮助处理这些任务,C++ 在头文件 fstream(以前为fstream.h)中定义了多个新类,其中包括用于文件输入的 ifstream 类和用于文件输出的 ostream 类。C++ 还定义了一个 fstream 类,用于同步文件 I/O。这些类都是从头文件 iostream 中的类派生而来的,因此这些新类的对象可以使用前面介绍过的方法。
简单的文件 I/O
要让程序写入文件,必须这样做:
- 创建一个 ofstream 对象来管理输出流;
- 将该对象与特定的文件关联起来;
- 以使用 cout 的方式使用该对象,唯一的区别是输出将进入文件,而不是屏幕。
要完成上述任务,首先应包含头文件 fstream。对于大多数(但不是全部)实现来说,包含该文件便自动包括 iostream 文件,因此不必显式包含 iostream。然后声明一个 ofstream 对象:
ofstream fout; // create an ofstream object named fout
对象名可以是任意有效的 C++ 名称,如 fout、outFile、cgate 或 didi。
接下来,必须将这个对象与特定的文件关联起来。为此,可以使用 open() 方法。例如,假设要打开文件 jar.txt 进行输出,则可以这样做:
fout.open("jar.txt"); // associate fout with jar.txt
可以使用另一个构造函数将这两步(创建对象和关联到文件)合并成一条语句:
ofstream fout("jar.txt"); // create fout object, associate it with jar.txt
然后,以使用 cout 的方式使用 fout(或选择的其他名称)。例如,要将 Dull Data 放到文件中,可以这样做:
fout << "Dull Data";
由于 ostream 是 ofstream 类的基类,因此可以使用所有的 ostream 方法,包括各种插入运算符定义、格式化方法和控制符。ofstream 类使用被缓冲的输出,因此程序在创建像 fout 这样的 ofstream 对象时,将为输出缓冲区分配空间。如果创建了两个 ofstream 对象,程序将创建两个缓冲区,每个对象各一个。像 fout 这样的 ofstream 对象从程序那里逐字节地收集输出,当缓冲区填满后,它便将缓冲区内容一同传输给目标文件。由于磁盘驱动器被设计成以大块的方式传输数据,而不是逐字节地传输,因此通过缓冲可以大大提高从程序到文件传输数据的速度。
以这种方式打开文件来进行输出时,如果没有这样的文件,将创建一个新文件;如果有这样的文件,则打开文件将清空文件,输出将进入到一个空文件中。本章后面将介绍如何打开已有的文件,并保留其内容。
警告:以默认模式打开文件进行输出将自动把文件的长度截短为零,这相当于删除已有的内容。
读取文件的要求与写入文件相似:
- 创建一个 ifstream 对象来管理输入流;
- 将该对象与特定的文件关联起来;
- 以使用 cin 的方式使用该对象。
上述读文件的步骤类似于写文件。首先,当然要包含头文件 fstream。然后声明一个 ifstream 对象,将它与文件名关联起来。可以使用一两条语句来完成这项工作:
// two statements
ifstream fin; // create ifstream object called fin
fin.open("jellyjar.txt"); // open jellyjar.txt for reading
// one statements
ifstream fis("jamjar.txt"); // create fis and associate with jamjar.txt
现在,可以像使用 cin 那样使用 fin 或 fis。例如,可以这样做:
char ch;
fin >> ch; // read a character from the jellyjar.txt file
char buf[80];
fin >> buf; // read a word from the file
fin.getline(buf, 80); // read a line from the file
string line;
getline(fin, line); // read from a file to a string object
输入和输出一样,也是被缓冲的,因此创建 ifstream 对象与 fin 一样,将创建一个由 fin 对象管理的输入缓冲区。与输出一样,通过缓冲,传输数据的速度比逐字节传输要快得多。
当输入和输出流对象过期(如程序终止)时,到文件的连接将自动关闭。另外,也可以使用 close() 方法来显式地关闭到文件的连接:
fout.close(); // close output connection to file
fin.close(); // close input connection to file
关闭这样的连接并不会删除流,而只是断开流到文件的连接。然而,流管理装置仍被保留。例如,fin 对象与它管理的输入缓冲区仍然存在。您稍后将知道,可以将流重新连接到同一个文件或另一个文件。
我们来看一下简短的例子。下面的程序要求输入文件名,然后创建一个名称为输入名的文件,将一些信息写入到该文件中,然后关闭该文件。关闭文件将刷新缓冲区,从而确保文件被更新。然后,程序打开文件,读取并显式其内容。注意,该程序以使用 cin 和 cout 的方式使用 fin 和 fout。另外,该程序将文件名读取到一个 string 对象中,然后使用方法 c_str() 来给 ofstream 和 ifstream 的构造函数提供一个 C-风格字符串参数。
// fileio.cpp -- saving to a file
#include<iostream> // not needed for many systems
#include<fstream>
#include<string>
int main() {
using namespace std;
string filename;
cout << "Enter name for new file: ";
cin >> filename;
// create output stream object for new file and call it fout
ofstream fout(filename.c_str());
fout << "For your eyes only!\n"; // write to file
cout << "Enter your secret number: "; // write to screen
float secret;
cin >> secret;
fout << "Your secret number is " << secret << endl;
fout.close(); // close file
// create input stream object for new file and call it fin
ifstream fin(filename.c_str());
cout << "Here are the contents of " << filename << ":\n";
char ch;
while(fin.get(ch)){ // read character from file and
cout << ch; // wirte it to screen
}
cout << "Done\n";
fin.close();
return 0;
}
下面是程序的运行情况:
Enter name for new file: pythag
Enter your secret number: 3.14159
Here are the contents of pythag:
For your eyes only!
Your secret number is 3.14159
Done
如果查看该程序所在的目录,将看到一个名为 pythag 的文件,使用文本编辑器打开该文件,其内容将与程序输出相同。
流状态检查和 is_open()
C++ 文件流类从 ios_base 类那里继承了一个流状态成员。正如前面指出的,该成员存储了指出流状态的信息:一切顺利、已到达文件尾、I/O 操作失败等。如果一切顺利,则流状态为零(没有消息就是好消息)。其他状态都是通过将特定为设置为1来记录的。文件流类还继承了 ios_base 类中报告流状态的方法,下表对这些方法进行了总结。可以通过检查流状态来判断最后一个流操作是否成功。例如,试图打开一个不存在的文件进行输入时,将设置 failbit 位,因此可以这样进行检查:
fin.open(argv[file]);
if (fin.fail()) { // open attempt failed
...
}
由于 ifstream 对象和istream 对象一样,被放在需要 bool 类型的地方时,将被转换为 bool 值,因此您也可以这样做:
fin.open(argv[file]);
if (!fin) { // open attempt failed
...
}
然而,较新的 C++ 实现提供了一种更好的检查文件是否被打开的方法——is_open() 方法:
if (!fin.is_open()) { // open attempt failed
...
}
这种方式之所以更好,是因为它能够检测出其他方法不能检测出的微妙问题,接下来的“警告”将讨论这一点。
警告:以前,检查文件是否成功打开的常见方式如下:
if(fin.fail()) ... // failed to open
if(!fin.good() ) ... // failed to open
if (!fin) ... // failed to open
fin 对象被用于测试条件中时,如果 fin.good() 为 false,将被转换为 false;否则将被转换为 true。因此上面三种方式等价。然而,这些测试无法检测到这样一种情形:试图以不合适的文件模式(参见本章后面的“文件模式”一节)打开文件时失败。方法 is_open() 能够检测到这种错误以及 good() 能够检测到的错误。然而,老式 C++ 实现没有 is_open()。
打开多个文件
程序可能需要打开多个文件。打开多个文件的策略取决于它们将如何使用。如果需要同时打开两个文件,则必须为每个文件创建一个流。例如,将两个排序后的文件拼接成第三个文件的程序,需要为两个输入文件创建两个 ifstream 对象,并为输出文件创建一个 ofstream 对象。可以同时打开的文件数取决于操作系统。
然而,可能要依次处理一组文件。例如,可能要计算某个名称在 10 个文件中出现的次数。在这种情况下,可以打开一个流,并将它依次关联到各个文件。这在节省计算机资源方面,比为每个文件打开一个流的效率高。使用这种方法,首先需要声明一个 ifstream 对象(不对它进行初始化),然后使用 open() 方法将这个流与文件关联起来。例如,下面是依次读取两个文件的代码:
ifstream fin; // create stream using default constructor
fin.open("fat.txt"); // associate stream with fat.txt file
... // do stuff
fin.close(); // terminate association with fat.txt
fin.clear(); // reset fin (may not be needed)
fin.open("rat.txt"); // associate stream with rat.txt file
...
fin.close();
稍后将介绍一个例子,但先来看这样一种将一系列文件输入给程序的技术,即让程序能够使用循环来处理文件。
命令行处理技术
文件处理程序通常使用命令行参数来指定文件。命令行参数是用户在输入命令时,在命令行中输入的参数。例如,要在 UNIX 或 Linux 系统中计算文件包含的字数,可以在命令行提示符下输入下面的命令:
wc report1 report2 report3
其中,we 是程序名,report1、report2 和 report3 是作为命令行参数传递给程序的文件名。
C++ 有一种让在命令行环境中运行的程序能够访问命令行参数的机制,方法是使用下面的 main() 函数:
int main(int argc, char *argv[])
argc 为命令行中的参数个数,其中包括命令名本身。argc 变量为一个指针,它指向一个指向 char 的指针。这过于抽象,但可以将 argv 看作一个指针数组,其中的指针指向命令行参数,argv[0] 是一个指针,指向存储第一个命令行参数的字符串中的第一个字符,依此类推。也就是说,argv[0] 是命令行中的第一个字符串,依次类推。例如,假设有下面的命令行:
wc report1 report2 report3
则 argc 为 4,argv[0] 为 wc,argv[1] 为 report1,依次类推。下面的循环将把每个命令行参数分别打印在单独一行上:
for(int i = 1; i < argc; i++ ) {
cout << argv[i] << endl;
}
以 i = 1 开头将只打印命令行参数;以 i = 0 开头将同时打印命令名。
当然,命令行参数与命令行操作系统(如 Windows 命令提示符模式、UNIX 和 Linux)紧密相关。其他程序也可能允许使用命令行参数。
- 很多 Windows IDE(集成开发环境)都有一个提供命令行参数的选项。通常,必须选择一系列菜单,才能打开一个可以输入命令行参数的对话框。具体的步骤随厂商和升级版本而异,因此请查看文档。
- 很多 Windows IDE 都可以生成可执行文件,这些文件能够在 Windows 命令提示符模式下运行。下面的程序结合使用命令行技术和文件流技术,来计算命令行上列出的文件包含的字符数。
// count.cpp -- counting characters in a list of files
#include <iostream>
#include <fstream>
#include <cstdlib> // for exit()
int main(int argc, char * argv[]) {
using namespace std;
if (argc == 1) { // quit if no arguments
cerr << "Usage: " << argv[0] << " filename[s]\n";
exit(EXIT_FAILURE);
}
ifstream fin; // open stream
long count;
long total = 0;
char ch;
for (int file = 1; file < argc; file++ ){
fin.open(argv[file]); // connect stream to argc[file]
if (!fin.is_open()){
cerr << "Could not open " << argv[file] << endl;
fin.clear();
continue;
}
count = 0;
while(fin.get(ch)){
count++;
}
cout << count << " characters in " << argv[file] << endl;
total += count;
fin.clear(); // needed for some implementations
fin.close(); // disconnect file
}
cout << total << " characters in all files\n";
return 0;
}
注意:有些 C++ 实现要求在该程序末尾使用 fin.clear(),有些则不要求,这取决于将文件与 ifstream 对象关联起来时,是否自动重置流状态。使用 fin.clear() 是无害的,即使在不必使用它的时候使用。
该程序的运行情况如下:
/workspace/ch17$ /workspace/ch17/.c_bin/17.17_count 17.1_write.cpp 17.2_defaults.cpp 17.3_manip.cpp
703 characters in 17.1_write.cpp
559 characters in 17.2_defaults.cpp
562 characters in 17.3_manip.cpp
1824 characters in all files
注意,该程序使用 cerr 表示错误消息。另外,消息使用 argv[0]:
cerr << "Usage: " << argv[0] << " filename[s]\n";
如果修改了可执行文件的名称,则程序将自动使用新的名称。
该程序使用 is_open() 方法来确定能够打开指定的文件,下面更深入地探讨这一主题。
文件模式
文件模式描述的是文件将被如何使用:读、写、追加等。将流与文件关联时(无论是使用文件名初始化文件流对象,还是使用 open() 方法),都可以提供指定文件模式的第二个参数:
ifstream fin("banjo", model); // constructor with mode argument
ofstream fout();
fout.open("harp", mode2); // open() with mode arguments
ios_base 类定义了一个 openmode 类型,用于表示模式;与 fmtflags 和 iostate 类型一样,它也是一种 bitmask 类型(以前,其类型为 int)。可以选择 ios_base 类中定义的多个常量来指定模式,下表列出了这些常量及其含义。C++ 文件 I/O 作了一些改动,以便与 ANSI C 文件 I/O 兼容。
文件模式常量
常量 | 含义 |
---|---|
ios_base::in | 打开文件,以便读取 |
ios_base::out | 打开文件,以便写入 |
ios_base::ate | 打开文件,并移到文件尾 |
ios_base::app | 追加到文件尾 |
ios_base::trunc | 如果文件存在,则截短文件 |
ios_base::binary | 二进制文件 |
如果 ifstream 和 ofstream 构造函数以及 open() 方法都接受两个参数,为什么前面的例子只使用一个参数就可以调用它们呢?您可能猜到了,这些类成员函数的原型为第二个参数(文件模式参数)提供了默认值。例如,ifstream open() 方法和构造函数用 ios_base::in(打开文件以读取)作为模式参数的默认值,而 ofstream open() 方法和构造函数用 ios_base::out | ios_base::trunc (打开文件,以写入并截短文件)作为默认值。位运算符 OR(1) 用于将两个位值合并成一个可用于设置两个位的值。fstream 类不提供默认的模式值,因此在创建这种类的对象时,必须显式地提供模式。
注意,ios_base::trunc 标记意味着打开已有的文件,以接收程序输出时将被截短;也就是说,其以前的内容将被删除。虽然这种行为极大地降低了耗尽磁盘空间的危险,但您也许能够想象到这样的情形,即不希望打开文件时将其内容删除。当然,C++ 提供了其他的选择。例如,如果要保留文件内容,并在文件尾添加(追加)等新信息,则可以使用 ios_base::app 模式:
ofstream fout("bagels", ios_base::out | ios_base::app);
上述代码也使用 | 运算符来合并模式,因此 ios_base::out | ios_base::app 意味着启用模式 out 和 app。
老式 C++ 实现之间可能有一些差异。例如,有些实现允许省略前一例子中的 ios_base::out,有些则不允许。如果不使用默认模式,则最安全的方法是显式地提供所有的模式元素。有些编译器不支持上表中的所有选项,或者提供了上表列出的其他选项。这些差异导致的后果之一是,可能必须作一些修改,使之能够在所用的系统中运行。好在 C++ 标准提供了更高的统一性。
标准 C++ 根据 ANSI C 标准 I/O 定义了部分文件 I/O。实现像下面这样的 C++ 语句时:
ifstream fin(filename, c++mode);
就像它使用了 C 的 fopen() 函数一样:
fopen(filename, cmode);
其中,c++mode 是一个 openmode 值,如 ios_base::in;而 cmode 是相应的 C 模式字符串,如 “r”。下表列出了 C++模式和 C 模式的对应关系。注意,ios_base::out 本身将导致文件被截短,但与 ios_base::in 一起使用时,不会导致文件被截短。没有列出的组合,如 ios_base::in | ios_base::trunc,将禁止文件被打开。is_open() 方法用于检测这种故障。
C++ 和 C 的文件打开模式
C++ 模式 | C 模式 | 含义 |
---|---|---|
ios_base::in | “r” | 打开以读取 |
ios_base::out | “w” | 等价于 ios_base::out |
ios_base::out | ios_base::trunc | “w” | 打开以写入,如果已经存在,则截短文件 |
ios_base::out | ios_base::app | “a” | 打开以写入,只追加 |
ios_base::int | ios_base::out | “r+” | 打开以读写,在文件允许的位置写入 |
ios_base::in | ios_base::out |ios_base::trunc | “w+” | 打开以读写,如果已经存在,则首先截短文件 |
c++mode | ios_base::binary | “cmodeb” | 以相应的 cmode 和二进制模式打开;例如,ios_base::in | ios_base::binary 成为 “rb” |
c++mode | ios_base::ate | “cmode” | 以指定的模式打开,并移到文件尾。C 使用一个独立的函数调用,而不是模式编码。例如,ios_base::in | ios_base::ate 被转换为 “r” 模式和 C 函数调用 fseek(file, 0, SEEK_END) |
注意,ios_base::ate 和 ios_base::app 都将文件指针指向打开的文件尾。二者的区别在于,ios_base::app 模式只允许将数据添加到文件尾,而 ios_base::ate 模式将指针放到文件尾。
显然,各种模式的组合很多,我们将介绍几种有代表性的组合。
-
追加文件
来看一个在文件尾追加数据的程序。该程序维护一个存储来客清单的文件。该程序首先显示文件当前的内容(如果有的话)。在尝试打开文件后,它使用 is_open() 方法来检查该文件是否存在。接下来,程序以 ios_base::app 模式打开文件,进行输出。然后,它请求用户从键盘输入,并将其添加到文件中。最后,程序显示修订后的文件内容。下面的程序演示了如何实现这些目标。请注意程序是如何使用 is_open() 方法来检测文件是否被成功打开的。
注意:在早期,文件 I/O 可能是 C++ 最不标准的部分,很多老式编译器都不遵守当前的标准。例如,有些编译器使用诸如 nocreate 等模式,而这些模式不是当前标准的组成部分。另外,只有一部分编译器要求在第二次打开同一个文件进行读取之前调用 fin.clear()。
// append.cpp -- appending information to a file
#include<iostream>
#include<fstream>
#include<string>
#include<cstdlib> // (for exit())
const char * file = "quests.txt";
int main() {
using namespace std;
char ch;
// show initial contents
ifstream fin;
fin.open(file);
if(fin.is_open()){
cout << "Here are the current contents of the " << file << " file:\n";
while( fin.get(ch) ){
cout << ch;
}
fin.close();
}
// add new names
ofstream fout(file, ios::out | ios::app);
if (!fout.is_open()){
cerr << "Can't open " << file << " file for output.\n";
exit(EXIT_FAILURE);
}
cout << "Enter guest names (enter a blank line to quit):\n";
string name;
while(getline(cin,name)&&name.size()>0){
fout << name << endl;
}
fout.close();
// show revised file
fin.clear(); // not necessary for some compiliers
fin.open(file);
if (fin.is_open()){
cout << "Here are the new contents of the " << file << " file:\n";
while (fin.get(ch)){
cout << ch;
}
fin.close();
}
cout << "Done.\n";
return 0;
}
下面是第一次运行程序的情况:
Enter guest names (enter a blank line to quit):
Genghis Kant
Hank Attila
Charles Bigg
Here are the new contents of the quests.txt file:
Genghis Kant
Hank Attila
Charles Bigg
Done.
此时, guests.txt 文件还没有创建,因此程序不能预览该文件。
但第二次运行该程序时,guests.txt 文件已经存在,因此程序将预览该文件。另外,新数据被追加到旧文件的后面,而不是取代它们。
Here are the current contents of the quests.txt file:
Genghis Kant
Hank Attila
Charles Bigg
Enter guest names (enter a blank line to quit):
Greta Greppo
LaDonna Mobile
Fannie Mae
Here are the new contents of the quests.txt file:
Genghis Kant
Hank Attila
Charles Bigg
Greta Greppo
LaDonna Mobile
Fannie Mae
Done.
可以使用任何文本编辑器来读取 guest.txt 的内容,包括用来编写源代码的编辑器。
-
二进制文件
将数据存储在文件中时,可以将其存储为文本格式或二进制格式。文本格式指的是将所有内容(甚至数字)都存储为文本。例如,以文本格式存储值 -2.324216e+07 时,将存储该数字包含的 13 个字符。这需要将浮点数的计算机内部表示转换为字符格式,这正是 << 插入运算符完成的工作。另一方面,二进制格式指的是存储值的计算机内部表示。也就是说,计算机不是存储字符,而是存储这个值的 64 位 double 表示。对于字符来说,二进制表示与文本表示是一样的,即字符的 ASCII 码的二进制表示。对于数字来说,二进制表示与文本表示有很大的差别。
每种格式都有自己的优点。文本格式便于读取,可以使用编辑器或字处理器来读取和编辑文本文件,可以很方便地将文本文件从一个计算机系统传输到另一个计算机系统。二进制格式对于数字来说比较精确,因为它存储的是值的内部表示,因此不会有转换误差或舍入误差。以二进制格式保存数据的速度更快,因为不需要转换,并可以大块地存储数据。二进制格式通常占用的空间较小,这取决于数据的特征。然而,如果另一个系统使用另一种内部表示,则可能无法将数据传输给该系统。同一系统上不同地编译器也可能使用不同的内部结构布局表示。在这种情况下,则必须编写一个将一种数据转换成另一种的程序。
来看一个更具体的例子。考虑下面的结构定义和声明:
const int LIM = 20; struct planet { char name[LIM]; // name of planet double population; // its population double g; // its acceleration of gravity }; planet pl;
要将结构 pl 的内容以文本格式保存,可以这样做:
ofstream fout("planets.dat", ios_base::out | ios_base::app); fout << pl.name << " " << pl.population << " " << pl.g << "\n";
必须使用成员运算符显式地提供每个结构成员,还必须将相邻地数据分隔开,以便区分。如果结构有 30 个成员,则这项工作将很乏味。
要用二进制格式存储相同的信息,可以这样做:
ofstream fout("planets.dat", ios_base::out | ios_base::app | ios_base::binary); fout.write( (char *) &pl, sizeof(p1) );
上述代码使用计算机的内部数据表示,将整个结构作为一个整体保存。不能将该文件作为文本读取,但与文本相比,信息的保存更为紧凑、精确。它确实更便于键入代码。这种方法作了两个修改:
- 使用二进制文件模式;
- 使用成员函数 write()。
下面更详细的介绍这两项修改。
有些系统(如 Windows)支持两种文件格式:文本格式和二进制格式。如果要用二进制格式保存数据,应使用二进制文件格式。在 C++ 中,可以将文件模式设置为 ios_base::binary 常量来完成。要知道为什么在 Windows 系统上需要完成这样的任务,请参见后面的旁注“二进制文件和文本文件”。二进制文件和文本文件
使用二进制文件模式时,程序将数据从内存传输给文件(反之亦然)时,将不会发生任何隐藏的转换,而默认的文本模式并非如此。例如,对于 Windows 文本文件,它们使用两个字符的组合(回车和换行)表示换行符;Macintosh 文本文件使用回车来表示换行符;而 UNIX 和 Linux 文件使用换行(linefeed)来表示换行符。C++ 是从 UNIX 系统上发展而来的,因此也使用换行(linefeed)来表示换行符。为增加可移植性,Windows C++ 程序在写文本模式文件时,自动将C++换行符转换为回车和换行;Macintosh C++ 程序在写文件时,将换行符转换为回车。在读取文本文件时,这些程序将本地换行符转换为 C++ 格式。对于二进制数据,文本格式会引起问题,因此 double 值中间的字节可能与换行符的 ASCII 码有相同的位模式。另外,在文件尾的检测方式也有区别。因此以二进制格式保存数据时,应使用二进制文件模式(UNIX 系统只有一种文件模式,因此对于它来说,二进制模式和文本模式是一样的)。
要以二进制格式(而不是文本格式)存储数据,可以使用 write() 成员函数。前面说过,这种方法将内存中指定数目的字节复制到文件中。本章前面用它复制过文本,但它只逐字节地复制数据,而不进行任何转换。例如,如果将一个 long 变量的地址传递给它,并命令它复制 4 个字节,它将复制 long 值中的 4 个字节,而不会将它转换为文本。唯一不方便的地方是,必须将地址强制转换为指向 char 的指针。也可以用同样的方式来复制整个 planet 结构。要获得字节数,可以使用 sizeof 运算符:
fout.write( (char *) &pl, sizeof(pl));
这条语句导致程序前往 pl 结构的地址,并将开始的 36 个字节(sizeof(pl) 表达式的值)复制到与 fout 相关联的文件中。
要使用文件恢复信息,请通过一个 ifstream 对象使用相应的 read() 方法:
ifstream fin("planets.dat", ios_base::in | ios_base::binary); fin.read((char *) &pl, sizeof(pl) );
这将从文件中复制 sizeof(pl) 个字节到 pl 结构中。同样的方法也适用于不使用虚函数的类。在这种情况下,只有数据成员被保存,而方法不会被保存。如果类有虚方法,则也将复制隐藏指针(该指针指向虚函数的指针表)。由于下一次运行程序时,虚函数表可能在不同的位置,因此将文件中的旧指针信息复制到对象中,将可能造成混乱。
提示:read() 和 write() 成员函数的功能是相反的。请用 read() 来恢复用 write() 写入的数据。
下面的程序使用这些方法来创建和读取二进制文件。从形式上看,该程序与之前的程序相似,但它使用的是 write() 和 read(),而不是插入运算符和 get() 方法。另外,它还使用控制符来格式化屏幕输出。
注意:虽然二进制文件概念是 ANSI C 的组成部分,但一些 C 和 C++ 实现并没有提供对二进制文件模式的支持。原因在于:有些系统只有一种文件类型,因此可以将二进制操作( 如read() 和 write() )用于标准文件格式。因此,如果实现认为 ios_base::binary 是非法常量,只要删除它即可。如果实现不支持 fixed 和 right 控制符,则可以使用 cout.setf(ios_base::fixed, ios_base::floatfield) 和 cout.setf(ios_base::right, ios_base::adjustfield)。另外,也可能必须用 ios 替换 ios_base。其他编译器(特别是老式编译器)可能还有其他特征。
// binary.cpp -- binary file I/O #include<iostream> // not required by most systems #include<fstream> #include<iomanip> #include<cstdlib> // for exit() inline void eatline() { while(std::cin.get() != '\n'){ continue; } } struct planet { char name[20]; // name of planet double population; // its population double g; // its acceleration of gravity }; const char * file = "planets.dat"; int main() { using namespace std; planet pl; cout << fixed << right; // show initial contents ifstream fin; fin.open(file, ios_base::in | ios_base::binary); // binary file // NOTE: some systems don't accept the ios_base::binary mode if (fin.is_open()){ cout << "Here are the current contents of the " << file << " file:\n"; while (fin.read((char*)&pl, sizeof(pl))){ cout << setw(20) << pl.name << ": " << setprecision(0) << setw(12) << pl.population << setprecision(2) << setw(6) << pl.g << endl; } fin.close(); } // add new data ofstream fout(file, ios_base::out | ios_base::app | ios_base::binary ); //NOTE: some system don't accept the ios::binary mode if(!fout.is_open()) { cerr << "Can't open " << file << " file for output:\n"; exit(EXIT_FAILURE); } cout << "Enter planet name (enter a blank line to quit):\n"; cin.get(pl.name, 20); while(pl.name[0] != '\0'){ eatline(); cout << "Enter planetary population: "; cin >> pl.population; cout << "Enter planet's acceleration of gravity: "; cin >> pl.g; eatline(); fout.write((char*) &pl, sizeof(pl)); cout << "Enter planet name (enter a blank line to quit):\n"; cin.get(pl.name, 20); } fout.close(); // show revised file fin.clear(); fin.open(file, ios_base::in | ios_base::binary); if(fin.is_open()){ cout << "Here are the new contents of the " << file << " file:\n"; while (fin.read((char*)&pl, sizeof(pl))){ cout << setw(20) << pl.name << ": " << setprecision(0) << setw(12) << pl.population << setprecision(2) << setw(6) << pl.g << endl; } fin.close(); } cout << "Done.\n"; return 0; }
下面是首次运行程序的情况:
Enter planet name (enter a blank line to quit): Earth Enter planetary population: 6928198253 Enter planet's acceleration of gravity: 9.81 Enter planet name (enter a blank line to quit): Here are the new contents of the planets.dat file: Earth: 6928198253 9.81 Done.
下面是再次运行该程序时的情况:
Here are the current contents of the planets.dat file: Earth: 6928198253 9.81 Enter planet name (enter a blank line to quit): Jenny's World Enter planetary population: 32155648 Enter planet's acceleration of gravity: 8.93 Enter planet name (enter a blank line to quit): Here are the new contents of the planets.dat file: Earth: 6928198253 9.81 Jenny's World: 32155648 8.93 Done.
看到该程序的主要特征后,下面再次讨论前面提高的几点。程序在读取行星的 g 值后,将使用下面的代码(以内联的 eatline() 函数的形式):
while (std::cin.get() != '\n' ) continue;
这将读取并丢弃输入中换行符之前的内容。考虑循环中的下一条输入语句:
cin.get(pl.name, 20);
如果保留换行符,该语句将换行符作为空行读取,然后终止循环。
您可能会问,如果该程序是否可以使用 string 对象而不是字符数组来表示 planet 结构的 name 成员?答案是否定的,至少在不对设计做重大修改的情况下是否定的。问题在于,string 对象本身实际上并没有包含字符串,而是包含一个指向其中存储了字符串的内存单元的指针。因此,将结构复制到文件中时,复制的将不是字符串数据,而是字符串的存储地址。当您再次运行该程序时,该地址将毫无意义。
随机存取
在最后一个文件示例中,将探讨随机存取。随机存取指的是直接移动(不是依次移动)到文件的任何位置。随机存取常被用于数据库文件,程序维护一个独立的索引文件,该文件指出数据在主数据文件中的位置。这样,程序便可以直接跳到这个位置,读取(还可能修改)其中的数据。如果文件由长度相同的记录组成,这种方法实现起来最简单。每条记录表示一组相关的数据。例如,在之前的程序中,每条文件记录将表示关于特定行星的全部数据。很自然,文件记录对应于程序结构或类。
我们将以上面程序中的二进制文件程序为基础,充分利用 planet 结构为文件的记录模式,来创建这个例子。为使编程更具创造性,该示例将以读写模式打开文件,以便能够读取和修改记录。为此,可以创建一个 fstream 对象。fstream 类是从 iostream 类派生而来的,而后者基于 istream 和 ostream 两个类,因此它继承了它们的方法。它还继承了两个缓冲区,一个用于输入,一个用于输出,并能同步化这两个缓冲区的处理。也就是说,当程序读写文件时,它将协调地移动输入缓冲区中的输入指针和输出缓冲区中的输出指针。
该示例将完成以下工作:
- 显式 planets.dat 文件当前的内容;
- 询问要修改哪条记录;
- 修改该记录;
- 显示修改后的文件。
更复杂的程序将使用菜单和循环,使得能在操作列表中不断地进行选择。但这里的版本只能执行每种操作一次。这种简化让您能够检验读写文件的多个方便,而不陷入程序设计事务之中。
警告:这个程序假设 planets.dat 文件已经存在,该文件是由之前一个程序 binary.cpp 创建的。
要回答的第一个问题是:应使用哪种文件模式。为读取文件,需要使用 ios_base::in 模式。为执行二进制 I/O,需要使用 ios_base::binary 模式(在某些非标准系统上,可以省略这种模式,事实上,可能必须省略这种模式)。为写入文件,需要 ios_base::out 或 ios_base::app 模式。然而,追加模式只允许程序将数据添加到文件尾,文件的其他部分是只读的;也就是说,可以读取原始数据,但不能修改它;要修改数据,必须使用 ios_base::out。同时使用 in 模式和out模式将得到读/写模式,因此只需添加二进制元素即可。如前所述,要使用 | 运算符来组合模式。因此,需要使用下面的语句:
finout.open(file, ios_base::in | ios_base::out | ios_base::binary);
接下来,需要一种在文件中移动的方式。fstream 类为此继承了两个方法:seekg() 和 seekp(),前者将输入指针移到指定的文件位置,后者将输出指针移到指定的文件位置(实际上,由于 fstream 类使用缓冲区来存储中间数据,因此指针指向的是缓冲区中的位置,而不是实际的文件)。也可以将 seekg() 用于 ifstream 对象,将 seekp() 用于 ofstream 对象。下面是 seekg() 的原型:
basic_istream<charT, traits>& seekg(off_type, ios_base::seekdir);
basic_istream<charT, traits>& seekg(pos_type);
正如您看到,它们都是模板。本章将使用 char 类型的模板具体化。对于 char 具体化,上面两个原型等同于下面的代码:
istream & seekg(streamoff, ios_base::seekdir);
istream & seekg(streampos);
第一个原型定位到离第二个参数指定的文件位置特定距离(单位为字节)的位置;第二个原型定位到离文件开头特定距离(单位为字节)的位置。
类型升级
在 C++ 早期,seekg() 方法比较简单。streamoff 和 streampos 类型是一些标准整型(如 long)的 typedef。但为创建可移植标准,必须处理这样的现实情况:对于有些文件系统,整数参数无法提供足够的信息,因此 streamoff 和 streampos 允许是结构或类类型,条件是它们允许一些基本的操作,如使用整数值作为初始值等。随后,老版本的 istream 类被 basic_istream 模板取代,streampos 和 streamoff 被 basic_istream 模板取代。然而,streampos 和 streamoff 继续存在,作为 pos_type 和 off_type 的 char 的具体化。同样,如果将 seekg() 用于 wistream 对象,可以使用 wstreampos 和 wstreamoff 类型。
来看 seekg() 的第一个原型的参数。streamoff 值被用来度量相对于文件特定位置的偏移量(单位为字节)。streamoff 参数表示相对于三个位置之一的偏移量为特定值(以字节为单位)的文件位置(类型可定义为整型或类)。seek_dir 参数是 ios_base 类中定义的另一种整型,有 3 个可能的值。常量 ios_base::beg 指相对于文件开始处的偏移量。常量 ios_base::cur 指相对于当前位置的偏移量;常量 ios_base::end 指相对于文件尾的偏移量。下面是一些调用示例,这里假设 fin 是一个 ifstream 对象:
fin.seekg(30, ios_base::beg); // 30 bytes beyond the beginning
fin.seekg(-1, ios_base::cur); // back up one byte
fin.seekg(0, ios_base::end); // go to the end of the file
下面来看第二个原型。streampos 类型的值定位到文件中的一个位置。它可以是类,但如果是这样的话,这个类将包含一个接受 streamoff 参数的构造函数和一个接受整数参数的构造函数,以便将两种类型转换为 strempos 值。 streampos 值表示文件中的绝对位置(从文件开始处算起)。可以将 streampos 位置看作是相对于文件开始处的位置(以字节为单位,第一个字节的编号为 0)。因此下面的语句将文件指针指向第 112 个字节,这是文件中的第 113 个字节:
fin.seekg(112);
如果要检查文件指针的当前位置,则对于输入流,可以使用 tellg() 方法,对于输出流,可以使用 tellp() 方法。它们都返回一个表示当前位置的 streampos 值(以字节为单位,从文件开始处算起)。创建 fstream 对象时,输入指针和输出指针将一前一后地移动,因此 tellg() 和 tellp() 返回的值相同。然而,如果使用 istream 对象来管理输入流,而是用 ostream 对象来管理同一个文件的输出流,则输入指针和输出指针将彼此独立地移动,因此 tellg() 和 tellp() 将返回不同的值。
然后,可以使用 seekg() 移动到文件的开头。下面是打开文件、移到文件开头并显示文件内容的代码片段:
fstream finout; // read and write streams
finout.open(file, ios::in | ios::out | ios::binary);
// NOTE: Some Unix systems require omitting | ios::binary
int ct = 0;
if (finout.is_open() ) {
finout.seekg(0); // go to beginning
cout << "Here are the current contents of the " << file << " file:\n";
while(finout.read((char*) &pl, sizeof(pl) ){
cout << ct++ << ": " << setw(LIM) << pl.name << ": "
<< setprecision(0) << setw(12) << pl.population
<< setprecision(2) << setw(6) << pl.g << endl;
}
if (finout.eof() ){
finout.clear(); // clear eof flag
}
else {
cerr << "Error in reading " << file << ".\n";
exit(EXIT_FAILURE);
}
}
else {
cerr << file << " could not be opened -- bye.\n";
exit(EXIT_FAILURE);
}
这与之前一个程序的开头很相似,但也修改和添加了一些内容。首先,程序以读/写模式使用一个 fstream 对象,并使用 seekg() 将文件指针放在文件开头(对于这个例子而言,这其实不是必须的,但它说明了如何使用 seekg()。接下来,程序在给记录编号方面做了一些小的改动。然后添加了以下重要的代码:
if (finout.eof()) {
finout.clear(); // clear eof flag
}
else {
cerr << "Error in reading " << file << ".\n";
exit(EXIT_FAILURE);
}
上述代码解决的问题是,程序读取并显示整个文件后,将设置 eofbit 元素。这使程序相信,它已经处理完文件,并禁止对文件做进一步的读写。使用 clear() 方法重置流状态,并打开 eofbit 后,程序便可以再次访问该文件。else 部分处理程序因到达文件尾之外的其他原因(如硬件故障)而停止读取的情况。
接下来需要确定要修改的记录,并修改它。为此,程序让用户输入记录号。将该编号与记录包含的字节数相乘,得到该记录第一个字节的编号。如果 record 是记录号,则字节编号为 record * sizeof pl:
cout << "Enter the record number you wish to change: ";
long rec;
cin >> re;
eatline(); // get rid of newline
if (rec < 0 || rec >= ct) {
cerr << "Invalid record number -- bye\n";
exit(EXIT_FAILURE);
}
streampos place = rec * sizeof(pl); // convert to streampos type
finout.seekg(place); // random access
变量 ct 表示记录号。如果试图超过文件尾,程序将退出。
接下来,程序显示当前的记录:
finout.read((char *) &pl, sizeof(pl) );
cout << "Your selection:\n";
cout << rec ": " << setw(LIM) << pl.name << ": "
<< setprecision(0) << setw(12) << pl.population
<< setprecision(2) << setw(6) << pl.g << endl;
if (finout.eof() ) {
finout.clear(); // clear eof flag
}
显示记录后,程序让您修改记录:
cout << "Enter planet name: ";
cin.get(pl.name, LIM);
eatline();
cout << "Enter planetary population: ";
cin >> pl.population;
cout << "Enter planet's acceleration of gravity: ";
cin >> pl.g;
finout.seekp(place); // go back
finout.write((char*) &pl, sizeof(pl)) << flush;
if (finout.fail() ) {
cerr << "Error on attempted write\n";
exit(EXIT_FAILURE);
}
程序刷新输出,以确保进入下一步之前,文件被更新。
最后,为显示修改后的文件,程序使用 seekg() 将文件指针重新指向开头。下面是完整的程序,不要忘了,该程序假设 binary.cpp 创建的 planets.dat 文件是可用的。
注意:实现越旧,与 C++ 标准相冲突的可能性越大。一些系统不能识别二进制标记、fixed 和 right 控制符以及 ios_base。
// random.cpp -- random access to a binary file
#include<iostream> // not required by most systems
#include<fstream>
#include<iomanip>
#include<cstdlib> // for exit()
const int LIM = 20;
struct planet {
char name[LIM]; // name of planet
double population; // its population
double g; // its acceleration of gravity
};
const char * file = "planets.dat"; // ASSUMED TO EXIST (binary.cpp example)
inline void eatline(){
while(std::cin.get()!='\n'){
continue;
}
}
int main(){
using namespace std;
planet pl;
cout << fixed;
// show initial contents
fstream finout; // read and write streams
finout.open(file, ios_base::in | ios_base::out | ios_base::binary);
// NOTE: Some Unix systems require omitting | ios_base::binary
int ct = 0;
if (finout.is_open()) {
finout.seekg(0); // go to beginning
cout << "Here are the current contents of the " << file << " file:\n";
while (finout.read((char*) &pl, sizeof(pl))) {
cout << ct++ << ": " << setw(LIM) << pl.name << ": "
<< setprecision(0) << setw(12) << pl.population
<< setprecision(2) << setw(6) << pl.g << endl;
}
if (finout.eof()){
finout.clear(); // clear eof flag
}
else {
cerr << "Error in reading " << file << ".\n";
exit(EXIT_FAILURE);
}
}
else {
cerr << file << " could not be opened -- bye.\n";
exit(EXIT_FAILURE);
}
// change a record
cout << "Enter the record number your wish to change: ";
long rec;
cin >> rec;
eatline(); // get rid of newline
if ( rec<0 || rec >= ct){
cerr << "Invalid record number -- bye\n";
exit(EXIT_FAILURE);
}
streampos place = rec * sizeof(pl); // convert to streampos type
finout.seekg(place); // random access
if (finout.fail()){
cerr << "Error on attempted seek\n";
exit(EXIT_FAILURE);
}
finout.read((char*) &pl, sizeof(pl));
cout << "Your selection:\n";
cout << rec << ": " << setw(LIM) << pl.name << ": "
<< setprecision(0) << setw(12) << pl.population
<< setprecision(2) << setw(6) << pl.g << endl;
if (finout.eof()) {
finout.clear(); // clear eof flag
}
cout << "Enter planet name: ";
cin.get(pl.name, LIM);
eatline();
cout << "Enter planetary population: ";
cin >> pl.population;
cout << "Enter planet's acceleration of gravity: ";
cin >> pl.g;
finout.seekp(place); // go back
finout.write((char*) &pl, sizeof(pl) ) << flush;
if (finout.fail()) {
cerr << "Error on attempted write\n";
exit(EXIT_FAILURE);
}
// show revised file
ct = 0;
finout.seekg(0); // go to beginning of file
cout << "Here are the new contents of the " << file << " file:\n";
while (finout.read((char*) &pl, sizeof(pl))) {
cout << ct++ << ": " << setw(LIM) << pl.name << ": "
<< setprecision(0) << setw(12) << pl.population
<< setprecision(2) << setw(6) << pl.g << endl;
}
finout.close();
cout << "Done.\n";
return 0;
}
下面是程序基于 planets.dat 文件的运行情况,该文件比上次见到时多了一些条目:
Here are the current contents of the planets.dat file:
0: Earth: 6928198253 9.81
1: Jenny's World: 32155648 8.93
Enter the record number your wish to change: 1
Your selection:
1: Jenny's World: 32155648 8.93
Enter planet name: Trantor
Enter planetary population: 89521844777
Enter planet's acceleration of gravity: 10.53
Here are the new contents of the planets.dat file:
0: Earth: 6928198253 9.81
1: Trantor: 89521844777 10.53
Done.
通过使用该程序中的技术,对其进行扩展,使之能够让用户添加新信息和删除记录。如果打算扩展该程序,最好通过使用类和函数来重新组织它。例如,可以将 planet 结构转换为一个类定义,然后对 << 插入 运算符进行重载,使得 cout << pl 按示例的格式显示类的数据成员。另外,该示例没有对输入进行检查,您可以添加代码来检查数值输入是否合适。
使用临时文件
开发应用程序时,经常需要使用临时文件,这种文件的存在是短暂的,必须受程序控制。您是否考虑过,在 C++ 中如何使用临时文件呢?创建临时文件、复制另一个文件的内容并删除文件其实都很简单。首先,需要为临时文件制定一个命名方案,但如何确保每个文件都被指定了独一无二的文件名呢?cstdio 中声明的 tmpnam() 标准函数可以帮助您。
char * tmpnam( char * pszName );
tmpnam() 函数创建一个临时文件名,将它放在 pszName 指向的 C-风格字符串中。常量 L_tmpnam 和 TMP_MAX ( 二者都是在 cstdio 中定义的)限制了文件名包含的字符数以及在确保当前目录中不生成重复文件名的情况下 tmpnam() 可被调用的最多次数。下面是生成 10 个临时文件名的代码。
#include<cstdio>
#include<iostream>
int main() {
using namespace std;
cout << "This system can generate up to " << TMP_MAX
<< " temporary names of up to " << L_tmpnam
<< " characters.\n"
char pszName[ L_tmpnam ] = { '\0' };
cout << "Here are ten names:\n";
for ( int i = 0; 10 > i; i ++ ) {
tmpnam( pszName );
cout << pszName << endl;
}
return 0;
}
更具体地说,使用 tmpnam() 可以生成 TMP_NAM 个不同的文件名,其中每个文件名包含的字符不超过 L_tmpnam 个。生成什么样的文件名取决于实现,您可以运行该程序,来看看编译器给您生成的文件名。