C++二進位制檔案的讀取和寫入(精華版)

2020-07-16 10:04:23
我們先來說一下為什麼要使用二進位制檔案,它比文字檔案有哪些好處。

用文字方式儲存資訊不但浪費空間,而且不便於檢索。例如,一個學籍管理程式需要記錄所有學生的學號、姓名、年齡資訊,並且能夠按照姓名查詢學生的資訊。程式中可以用一個類來表示學生:
class CStudent
{
    char szName[20];  //假設學生姓名不超過19個字元,以 '' 結尾
    char szId[l0];  //假設學號為9位,以 '' 結尾
    int age;  //年齡
};
如果用文字檔案儲存學生的資訊,檔案可能是如下樣子:
Micheal Jackson 110923412 17
Tom Hanks 110923413 18

這種儲存方式不但浪費空間,而且查詢效率低下。因為每個學生的資訊所占用的位元組數不同,所以即使檔案中的學生資訊是按姓名排好序的,要用程式根據名字進行查詢仍然沒有什麼好辦法,只能在檔案中從頭到尾搜尋。

如果把全部的學生資訊都讀入記憶體並排序後再查詢,當然速度會很快,但如果學生數巨大,則把所有學生資訊都讀人記憶體可能是不現實的。

可以用二進位制的方式來儲存學生資訊,即把 CStudent 物件直接寫入檔案。在該檔案中,每個學生的資訊都占用 sizeof(CStudent) 個位元組。物件寫入檔案後一般稱作“記錄”。本例中,每個學生都對應於一條記錄。該學生記錄檔案可以按姓名排序,則使用折半查詢的效率會很高。

讀寫二進位制檔案不能使用前面提到的類似於 cin、cout 從流中讀寫資料的方法。這時可以呼叫 ifstream 類和 fstream 類的 read 成員函數從檔案中讀取資料,呼叫 ofstream 和 fstream 的 write 成員函數向檔案中寫入資料。

用 ostream::write 成員函數寫檔案

ofstream 和 fstream 的 write 成員函數實際上繼承自 ostream 類,原型如下:

ostream & write(char* buffer, int count);

該成員函數將記憶體中 buffer 所指向的 count 個位元組的內容寫入檔案,返回值是對函數所作用的物件的參照,如 obj.write(...) 的返回值就是對 obj 的參照。

write 成員函數向檔案中寫入若干位元組,可是呼叫 write 函數時並沒有指定這若干位元組要寫入檔案中的什麼位置。那麼,write 函數在執行過程中到底把這若干位元組寫到哪裡呢?答案是從檔案寫指標指向的位置開始寫入。

檔案寫指標是 ofstream 或 fstream 物件內部維護的一個變數。檔案剛開啟時,檔案寫指標指向檔案的開頭(如果以 ios::app 方式開啟,則指向檔案末尾),用 write 函數寫入 n 個位元組,寫指標指向的位置就向後移動 n 個位元組。

下面的程式從鍵盤輸入幾名學生的姓名和年齡(輸入時,在單獨的一行中按 Ctrl+Z 鍵再按確認鍵以結束輸入。假設學生姓名中都沒有空格),並以二進位制檔案形式儲存,成為一個學生記錄檔案 students.dat。

例子,用二進位制檔案儲存學生記錄:
#include <iostream>
#include <fstream>
using namespace std;
class CStudent
{
public:
    char szName[20];
    int age;
};
int main()
{
    CStudent s;
    ofstream outFile("students.dat", ios::out | ios::binary);
    while (cin >> s.szName >> s.age)
        outFile.write((char*)&s, sizeof(s));
    outFile.close();
    return 0;
}
輸入:
Tom 60↙
Jack 80↙
Jane 40↙
^Z↙

則形成的 students.dat 為 72 位元組,用“記事本”程式開啟呈現亂碼:

Tom燙燙燙燙燙燙燙燙 Jack燙燙燙燙燙燙燙? Jane燙燙燙燙燙燙燙?


第 13 行指定檔案的開啟模式是 ios::out|ios::binary,即以二進位制寫模式開啟。在 Windows平台中,用二進位制模式開啟是必要的,否則可能出錯,原因會在《檔案的文字開啟方式和二進位制開啟方式的區別》一節中介紹。

第 15 行將 s 物件寫入檔案。s 的地址就是要寫入檔案的記憶體緩衝區的地址。但是 &s 不是 char * 型別,因此要進行強制型別轉換。

第 16 行,檔案使用完畢一定要關閉,否則程式結束後檔案的內容可能不完整。

用 istream::read 成員函數讀檔案

ifstream 和 fstream 的 read 成員函數實際上繼承自 istream 類,原型如下:

istream & read(char* buffer, int count);

該成員函數從檔案中讀取 count 個位元組的內容,存放到 buffer 所指向的記憶體緩衝區中,返回值是對函數所作用的物件的參照。

如果想知道一共成功讀取了多少個位元組(讀到檔案尾時,未必能讀取 count 個位元組),可以在 read 函數執行後立即呼叫檔案流物件的 gcount 成員函數,其返回值就是最近一次 read 函數執行時成功讀取的位元組數。gcount 是 istream 類的成員函數,原型如下:

int gcount();

read 成員函數從檔案讀指標指向的位置開始讀取若干位元組。檔案讀指標是 ifstream 或 fstream 物件內部維護的一個變數。檔案剛開啟時,檔案讀指標指向檔案的開頭(如果以ios::app 方式開啟,則指向檔案末尾),用 read 函數讀取 n 個位元組,讀指標指向的位置就向後移動 n 個位元組。因此,開啟一個檔案後連續呼叫 read 函數,就能將整個檔案的內容讀取出來。

下面的程式將前面建立的學生記錄檔案 students.dat 的內容讀出並顯示。
#include <iostream>
#include <fstream>
using namespace std;
class CStudent
{
    public:
        char szName[20];
        int age;
};
int main()
{
    CStudent s;       
    ifstream inFile("students.dat",ios::in|ios::binary); //二進位制讀方式開啟
    if(!inFile) {
        cout << "error" <<endl;
        return 0;
    }
    while(inFile.read((char *)&s, sizeof(s))) { //一直讀到檔案結束
        int readedBytes = inFile.gcount(); //看剛才讀了多少位元組
        cout << s.szName << " " << s.age << endl;   
    }
    inFile.close();
    return 0;
}
程式的輸出結果是:
Tom 60
Jack 80
Jane 40

第 18 行,判斷檔案是否已經讀完的方法和 while(cin>>n) 類似,歸根到底都是因為 istream 類過載了 bool 強制型別轉換運算子。

第 19 行只是演示 gcount 函數的用法,刪除該行對程式執行結果沒有影響。

思考題:關於 students.dat 的兩個程式中,如果 CStudent 類的 szName 的定義不是“char szName[20] ”而是“string szName”,是否可以?為什麼?

用檔案流類的 put 和 get 成員函數讀寫檔案

可以用 ifstream 和 fstream 類的 get 成員函數(繼承自 istream 類)從檔案中一次讀取一個位元組,也可以用 ofstream 和 fstream 類的 put 成員函數(繼承自 ostream 類) 向檔案中一次寫入一個位元組。

例題:編寫一個 mycopy 程式,實現檔案複製的功能。用法是在“命令提示字元”視窗輸入:

mycopy 原始檔名 目標檔名

就能將原始檔複製到目標檔案。例如:

mycopy src.dat dest.dat

即將 src.dat 複製到 dest.dat。如果 dest.dat 原本就存在,則原來的檔案會被覆蓋。

解題的基本思路是每次從原始檔讀取一個位元組,然後寫入目標檔案。程式如下:
#include <iostream>
#include <fstream>
using namespace std;
int main(int argc, char* argv[])
{
    if (argc != 3) {
        cout << "File name missing!" << endl;
        return 0;
    }
    ifstream inFile(argv[l], ios::binary | ios::in);  //以二進位制讀模式開啟檔案
    if (!inFile) {
        cout << "Source file open error." << endl;
        return 0;
    }
    ofstream outFile(argv[2], ios::binary | ios::out);  //以二進位制寫模式開啟檔案
    if (!outFile) {
        cout << "New file open error." << endl;
        inFile.close();  //開啟的檔案一定要關閉
        return 0;
    }
    char c;
    while (inFile.get(c))  //每次讀取一個字元
        outFile.put(c);  //每次寫入一個字元
    outFile.close();
    inFile.close();
    return 0;
}
檔案存放於磁碟中,磁碟的存取速度遠遠低於記憶體。如果每次讀一個位元組或寫一個位元組都要存取磁碟,那麼檔案的讀寫速度就會慢得不可忍受。因此,作業系統在接收到讀檔案的請求時,哪怕只要讀一個位元組,也會把一片資料(通常至少是 512 個位元組,因為磁碟的一個磁區是 512 B)都讀取到一個作業系統自行管理的記憶體緩衝區中,當要讀下一個位元組時,就不需要存取磁碟,直接從該緩衝區中讀取就可以了。

作業系統在接收到寫檔案的請求時,也是先把要寫入的資料在一個記憶體緩衝區中儲存起來,等緩衝區滿後,再將緩衝區的內容全部寫入磁碟。關閉檔案的操作就能確保記憶體緩衝區中的資料被寫入磁碟。

儘管如此,要連續讀寫檔案時,像 mycopy 程式那樣一個位元組一個位元組地讀寫,還是不如一次讀寫一片記憶體區域快。每次讀寫的位元組數最好是 512 的整數倍。