作者 謝恩銘,公眾號「程序員聯盟」(微信號:coderhub)。
轉載請注明出處。
原文:http://www.lxweimin.com/p/4adb95073745
《C語言探索之旅》全系列
內容簡介
- 前言
- 文件的打開和關閉
- 讀寫文件的不同方法
- 在文件中移動
- 文件的重命名和刪除
- 第二部分第八課預告
1. 前言
上一課 C語言探索之旅 | 第二部分第六課:創建你自己的變量類型 之后,我們來學習很常用的文件讀寫。
我們學過了這么多變量的知識,已經知道變量實在是很強大的,可以幫助我們實現很多事情。
變量固然強大,還是有缺陷的,最大的缺陷就是:不能永久保存。
因為 C語言的變量儲存在內存中,在你的程序退出時就被清除了,下次程序啟動時就不能找回那個值了。
“驀然回首,那人不在燈火闌珊處...”
“今天的你我,
怎樣重復昨天的故事?
這一張舊船票,
還能否登上你的破船?”
不能夠啊,“濤聲不能依舊”啊...
如果這樣的話,我們如何在 C語言編寫的游戲中保存游戲的最高分呢?怎么用 C語言寫一個退出時依然保存文本的文本編輯器呢?
幸好,在 C語言中我們可以讀寫文件。這些文件會儲存在我們電腦的硬盤上,就不會在程序退出或電腦關閉時被清除了。
為了實現文件讀寫,我們就要用到迄今為止我們所學過的知識:
指針,結構體,字符串,等等。
也算是復習吧。
2. 文件的打開和關閉
為了讀寫文件,我們需要用到定義在 stdio.h 這個標準庫頭文件中的一些函數,結構,等。
是的,就是我們所熟知的 stdio.h,我們的“老朋友” printf 和 scanf 函數也是定義在這個頭文件里。
下面按順序列出我們打開一個文件,進行讀或寫操作所必須遵循的一個流程:
調用“文件打開”函數 fopen(f 是 file(表示“文件”)的首字母;open 表示“打開”),返回一個指向該文件的指針。
檢測文件打開是否成功,通過第 1 步中 fopen 的返回值(文件指針)來判斷。如果指針為 NULL,則表示打開失敗,我們需要停止操作,并且返回一個錯誤。
如果文件打開成功(指針不為 NULL),那么我們就可以接著用 stdio.h 中的函數來讀寫文件了。
一旦我們完成了讀寫操作,我們就要關閉文件,用 fclose(close 表示“關閉”)函數。
首先我們來學習如何使用 fopen 和 fclose 函數,之后我們再學習如何讀寫文件。
fopen:打開文件
函數 fopen 的原型是這樣的:
FILE* fopen(const char* fileName, const char* openMode);
不難看出,這個函數接收兩個參數:
fileName:文件名(name 表示“名字”)。是一個字符串類型,而且是 const,意味著不能改變其值。
openMode:打開方式(open 表示“打開”,mode 表示“方式”)。表明我們打開文件之后要干什么的一個指標。只讀、只寫、讀寫,等等。
這個函數的返回值,是 FILE *
,也就是一個 FILE(file 表示“文件”)指針。
FILE 定義在 stdio.h 中。有興趣的讀者可以自己去找一下 FILE 的定義。
我們給出 FILE 的一般定義:
typedef struct {
char *fpos; /* Current position of file pointer (absolute address) */
void *base; /* Pointer to the base of the file */
unsigned short handle; /* File handle */
short flags; /* Flags (see FileFlags) */
short unget; /* 1-byte buffer for ungetc (b15=1 if non-empty) */
unsigned long alloc; /* Number of currently allocated bytes for the file */
unsigned short buffincrement; /* Number of bytes allocated at once */
} FILE;
可以看到 FILE 是一個結構體(struct),里面有 7 個變量。當然我們不必深究 FILE 的定義,只要會使用 FILE 就好了,而且不同操作系統對于 FILE 的定義不盡相同。
細心的讀者也許會問:“之前不是說結構體的名稱最好是首字母大寫么,為什么 FILE 這個結構體每一個字母都是大寫呢?怎么和常量的命名方式一樣呢?”
好問題。其實我們之前建議的命名方式(對于結構體,首字母大寫,例如:StructName)只是一個“規范”(雖然大多數程序員都喜歡遵循),并不是一個強制要求。
這只能說明編寫 stdio.h 的前輩并不一定遵循這個“規范”而已。當然,這對我們并沒什么影響。
以下列出幾種可供使用的 openMode :
r
:只讀。r 是 read(表示“讀”)的首字母。這個模式下,我們只能讀文件,而不能對文件寫入。文件必須已經存在。w
:只寫。w 是 write(表示“寫”)的首字母。這個模式下,只能寫入,不能讀出文件的內容。如果文件不存在,將會被創建。a
:追加。a 是 append(表示“追加”)的首字母。這個模式下,從文件的末尾開始寫入。如果文件不存在,將會被創建。r+
:讀和寫。這個模式下,可以讀和寫文件,但文件也必須已經存在。w+
:讀和寫。預先會刪除文件內容。這個模式下,如果文件存在且內容不為空,則內容首先會被清空。如果文件不存在,將會被創建。a+
:讀寫追加。這個模式下,讀寫文件都是從文件末尾開始。如果文件不存在,將會被創建。
上面所列的模式,其實還可以組合上 b
這個模式。b 是 binary 的縮寫,表示“二進制”。 對于上面的每一個模式,如果你添加 b
后,會變成 rb
,wb
,ab
,rb+
,wb+
,ab+
),該文件就會以二進制模式打開。不過二進制的模式一般不是那么常用。
一般來說,r
,w
和 r+
用得比較多。w+
模式要慎用,因為它會首先清空文件內容。當你需要往文件中添加內容時,a
模式會很有用。
下面的例子程序就以 r+
(讀寫)的模式打開文件:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* file = NULL;
file = fopen("test.txt", "r+");
return 0;
}
于是,file 成為了指向 test.txt 文件的一個指針。
你會問:“我們的 test.txt 文件位于哪里呢?”
text.txt 文件和可執行文件位于同一目錄下。
“文件一定要是 .txt 結尾的嗎?”
不是,完全由你決定文件的后綴名。你大可以創建一個文件叫做 xxx.level,用于記錄游戲的關卡信息。
“文件一定要和可執行文件在同一個文件夾下么?”
也不是。理論上可以位于當前系統的任意文件夾里,只要在 fopen 函數的文件名參數里指定文件的路徑就好了,例如:
file = fopen("folder/test.txt", "w");
這樣,文件 test.txt 就是位于當前目錄的文件夾 folder 里。這里的 folder/test.txt
稱為“相對路徑”。
我們也可以這樣:
file = fopen("/home/user/folder/test.txt", "w");
這里的 /home/user/folder/test.txt
是“絕對路徑”。
測試打開文件
在調用 fopen 函數嘗試打開文件后,我們需要檢測 fopen 的返回值,以判斷打開是否成功。
檢測方法也很簡單:如果 fopen 的返回值為 NULL,那么打開失敗;如果不為 NULL,那么表示打開成功。示例如下:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* file = NULL;
file = fopen("test.txt", "r+");
if (file != NULL)
{
// 讀寫文件
}
else
{
// 顯示一個錯誤提示信息
printf("無法打開 test.txt 文件\n");
}
return 0;
}
記得每次使用 fopen 函數時都要對返回值作判斷,因為如果文件不存在或者正被其他程序占用,那可能會使當前程序運行失敗。
fclose:關閉文件
close 表示“關閉”。
如果我們成功地打開了一個文件,那么我們就可以對文件進行讀寫了(讀寫的操作我們下一節再詳述)。
如果我們對文件的操作已經結束,那么我們應該關閉這個文件,這樣做是為了釋放占用的文件指針。
我們需要調用 fclose 函數來實現文件的關閉,這個函數可以釋放內存,也就是從內存中刪除你的文件(指針)。
函數原型:
int fclose(FILE* pointerOnFile);
這個函數只有一個參數:指向文件的指針。
函數的返回值(int)有兩種情況:
- 0 :當關閉操作成功時。
- EOF(是 End Of File 的縮寫,表示“文件結束”。一般等于 -1):如果關閉失敗。
示例如下:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* file = NULL;
file = fopen("test.txt", "r+");
if (file != NULL)
{
// 讀寫文件
// ...
fclose(file); // 關閉我們之前打開的文件
}
return 0;
}
3. 讀寫文件的不同方法
現在,我們既然已經知道怎么打開和關閉文件了,接下來我們就學習如何對文件進行讀出和寫入吧。
我們首先學習如何寫入文件(相比讀出要簡單一些),之后我們再看如何從文件讀出。
對文件寫入
用于寫入文件的函數有好幾個,我們可以根據情況選擇最適合的函數來使用。
我們來學習三個用于文件寫入的函數:
fputc:在文件中寫入一個字符(一次只寫一個)。是 file put character 的縮寫。put 表示“放入”,character 表示“字符”。
fputs:在文件中寫入一個字符串。是 file put string 的縮寫。string 表示“字符串”。
fprintf:在文件中寫入一個格式化過的字符串,用法與 printf 是幾乎相同的,只是多了一個文件指針。
fputc
此函數用于在文件中一次寫入一個字符。
函數原型:
int fputc(int character, FILE* pointerOnFile);
這個函數包含兩個參數:
character:int 型變量,表示要寫入的字符。我們也可以直接寫 'A' 這樣的形式,之前 ASCII 那節的知識點沒有忘吧。
pointerOnFile:指向文件的指針。
函數返回 int 值。如果寫入失敗,則為 EOF;否則,會是另一個值。
示例:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* file = NULL;
file = fopen("test.txt", "w");
if (file != NULL)
{
fputc('A', file); // 寫入字符 A
fclose(file);
}
return 0;
}
上面的程序用于向 test.txt 文件寫入字符 'A'。
fputs
這個函數和 fputc 類似,區別是 fputc 每次是寫入一個字符,而 fputs 每次寫入一個字符串。
函數原型:
int fputs(const char* string, FILE* pointerOnFile);
類似地,這個函數也接受兩個參數:
string:要寫入的字符串。
pointerOnFile:指向文件的指針。
如果出錯,函數返回 EOF;否則,返回不同于 EOF 的值。
示例:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* file = NULL;
file = fopen("test.txt", "w");
if (file != NULL)
{
fputs("你好,朋友。\n最近怎么樣?", file);
fclose(file);
}
return 0;
}
fprintf
這個函數很有用,因為它不僅可以向文件寫入字符串,而且這個字符串是可以由我們來格式化的。用法其實和 printf 函數類似,就是多了一個文件指針。
函數原型:
int fprintf(FILE *stream, const char *format, ...)
示例:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* file = NULL;
int age = 0;
file = fopen("test.txt", "w");
if (file != NULL)
{
// 詢問用戶的年齡
printf("您幾歲了 ? ");
scanf("%d", &age);
// 寫入文件
fprintf(file, "使用者年齡是 %d 歲\n", age);
fclose(file);
}
return 0;
}
從文件中讀出
我們可以用與寫入文件時類似名字的函數,只是略微修改了一些,也有三個:
fgetc:讀出一個字符。是file get character 的縮寫。get 表示“獲取,取得”。
fgets:讀出一個字符串。是 file get string 的縮寫。
fscanf:與 scanf 的用法類似,只是多了一個文件指針。scanf 是從用戶輸入讀取,而 fscanf 是從文件讀取。
這次介紹這三個函數我們會簡略一些,因為如果大家掌握好了前面那三個寫入的函數,那這三個讀出的函數是類似的。只是操作相反了。
fgetc
首先給出函數原型:
int fgetc(FILE* pointerOnFile);
函數返回值是讀到的字符。如果不能讀到字符,那會返回 EOF。
但是如何知道我們從文件的哪個位置讀取呢?是第三個字符處,還是第十個字符處呢?
其實,在我們讀取文件時,有一個“游標”(cursor),會跟隨移動。
這當然是虛擬的游標,你不會在屏幕上看到它。你可以想象這個游標和你用記事本編輯文件時的閃動的光標類似。這個游標指示你當前在文件中的位置。
之后的小節,我們會學習如何移動這個游標,使其位于文件中特定的位置。可以是開頭,也可以是第 7 個字符處。
fgetc 函數每讀入一個字符,這個游標就移動一個字符長度。我們就可以用一個循環來讀出文件所有的字符。例如:
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* file = NULL;
int currentCharacter = 0;
file = fopen("test.txt", "r");
if (file != NULL)
{
// 循環讀取,每次一個字符
do
{
currentCharacter = fgetc(file); // 讀取一個字符
printf("%c", currentCharacter); // 顯示讀取到的字符
} while (currentCharacter != EOF); // 我們繼續,直到 fgetc 返回 EOF(表示“文件結束”)為止
fclose(file);
}
return 0;
}
fgets
此函數每次讀出一個字符串,這樣可以不必每次讀一個字符(有時候效率太低)。
這個函數每次最多讀取一行,因為它遇到第一個 '\n'(換行符)會結束讀取。所以如果我們想要讀取多行,需要用循環。
插入一點回車符和換行符的知識:
關于“回車”(carriage return)和“換行”(line feed)這兩個概念的來歷和區別。
在計算機還沒有出現之前,有一種叫做電傳打字機(Teletype Model 33)的玩意,每秒鐘可以打 10 個字符。
但是它有一個問題,就是打完一行換行的時候,要用去 0.2 秒,正好可以打兩個字符。要是在這 0.2 秒里面,又有新的字符傳過來,那么這個字符將丟失。
于是,研制人員想了個辦法解決這個問題,就是在每行后面加兩個表示結束的字符。一個叫做“回車”,告訴打字機把打印頭定位在左邊界;另一個叫做“換行”,告訴打字機把紙向下移一行。這就是“換行”和“回車”的來歷,從它們的英語名字上也可以看出一二。
后來,計算機被發明了,這兩個概念也就被搬到了計算機上。那時,存儲器很貴,一些科學家認為在每行結尾加兩個字符太浪費了,加一個就可以。于是,就出現了分歧。在 Unix/Linux 系統里,每行結尾只有“<換行>”,即 "\n";在 Windows 系統里面,每行結尾是“<換行><回車>”,即 "\n\r";在 macOS 系統里,每行結尾是“<回車>”,即 "\r"。
一個直接后果是,Unix/Linux/macOS 系統下的文件在Windows里打開的話,所有文字會變成一行;而 Windows 里的文件在 Unix/Linux/macOS 下打開的話,在每行的結尾可能會多出一個^M
符號。
Linux 中遇到換行符會進行“回車 + 換行”的操作,回車符反而只會作為控制字符顯示,不發生回車的操作。
而 Windows 中要“回車符 + 換行符”才會實現“回車+換行",缺少一個控制符或者順序不對都不能正確的另起一行。
函數原型:
char* fgets(char* string, int characterNumberToRead, FILE* pointerOnFile);
示例:
#include <stdio.h>
#define MAX_SIZE 1000 // 數組的最大尺寸 1000
int main(int argc, char *argv[])
{
FILE* file = NULL;
char string[MAX_SIZE] = ""; // 尺寸為 MAX_SIZE 的數組,初始為空
file = fopen("test.txt", "r");
if (file != NULL)
{
fgets(string, MAX_SIZE, file); // 我們讀取最多 MAX_SIZE 個字符的字符串,將其存儲在 string 中
printf("%s\n", string); // 顯示字符串
fclose(file);
}
return 0;
}
這里,我們的 MAX_SIZE 足夠大(1000),保證可以容納下一行的字符數。所以遇到 '\n' 我們就停止讀取,因此以上代碼的作用就是讀取文件中的一行字符,并將其輸出。
那我們如何能夠讀取整個文件的內容呢?很簡單,加一個循環。
如下:
#include <stdio.h>
#define MAX_SIZE 1000 // 數組的最大尺寸 1000
int main(int argc, char *argv[])
{
FILE* file = NULL;
char string[MAX_SIZE] = ""; // 尺寸為 MAX_SIZE 的數組,初始為空
file = fopen("test.txt", "r");
if (file != NULL)
{
while (fgets(string, MAX_SIZE, file) != NULL) // 我們一行一行地讀取文件內容,只要不遇到文件結尾
printf("%s\n", string); // 顯示字符串
fclose(file);
}
return 0;
}
fscanf
此函數的原理和 scanf 是一樣的。負責從文件中讀取規定樣式的內容。
函數原型:
int fscanf(FILE *stream, const char *format, ...)
示例:
例如我們創建一個 test.txt 文件,在里面輸入三個數:23, 45, 67。
輸入的形式可以是類似下面這樣:
- 每個數之間有空格
- 每個數之間換一行
#include <stdio.h>
int main(int argc, char *argv[])
{
FILE* file = NULL;
int score[3] = {0}; // 包含 3 個最佳得分的數組
file = fopen("test.txt", "r");
if (file != NULL)
{
fscanf(file, "%d %d %d", &score[0], &score[1], &score[2]);
printf("最佳得分是 : %d, %d 和 %d\n", score[0], score[1], score[2]);
fclose(file);
}
return 0;
}
運行輸出:
最佳得分是:23, 45, 67
4. 在文件中移動
前面我們提到了虛擬的“游標”,現在我們仔細地來學習一下。
每當我們打開一個文件的時候,實際上都存在一個“游標”,標識你當前在文件中所處的位置。
你可以類比我們的文本編輯器,每次你在文本編輯器(例如記事本)里面輸入文字的時候,不是有一個游標(光標)可以到處移動么?它指示了你在文件中的位置,也就是你下一次輸入會從哪里開始。
總結來說,游標系統使得我們可以在文件中指定位置進行讀寫操作。
我們介紹三個與文件中游標移動有關的函數:
ftell:告知目前在文件中哪個位置。tell 表示“告訴”。
fseek:移動文件中的游標到指定位置。seek 表示“探尋”。
rewind:將游標重置到文件的開始位置(這和用 fseek 函數來使游標回到文件開始位置是一個效果)。rewind 表示“轉回”。
ftell:指示目前在文件中的游標位置
這個函數使用起來非常簡單,它返回一個 long 型的整數值,標明目前游標所在位置。函數原型是:
long ftell(FILE* pointerOnFile);
其中,pointerOnFile 這個指針就是文件指針,指向當前文件。
相信不必用例子就知道如何使用了吧。
fseek:使游標移動到指定位置
函數原型為:
int fseek(FILE* pointerOnFile, long move, int origin);
此函數能使游標在文件(pointerOnFile 指針所指)中從位置(origin 所指。origin 表示“初始”)開始移動一定距離(move 所指。move 表示“移動”)。
move 參數:可以是一個正整數,表明向前移動;0,表明不移動;或者負整數,表明回退。
-
origin 參數:它的取值可以是以下三個值(
#define
所定義的常量)中的任意:-
SEEK_SET
:文件開始處。SET 表示“設置”。 -
SEEK_CUR
:游標當前所在位置。CUR 是 current(表示“當前”)的縮寫。 -
SEEK_END
:文件末尾。END 表示“結尾”。
-
來看幾個具體使用實例吧:
// 這行代碼將游標放置到距離文件開始處 5 個位置的地方
fseek(file, 5, SEEK_SET);
// 這行代碼將游標放置到距離當前位置往后 3 個位置的地方
fseek(file, -3, SEEK_CUR);
// 這行代碼將游標放置到文件末尾
fseek(file, 0, SEEK_END);
rewind:使游標回到文件開始位置
這個函數的作用就相當于使用 fseek 來使游標回到 0 的位置
void rewind(FILE* pointerOnFile);
相信使用難不倒大家吧,看函數原型就一目了然了。和 fseek(file, 0, SEEK_SET);
是一個效果。
5. 文件的重命名和刪除
我們來學習兩個簡單的函數,以結束這次的課程:
rename 函數:重命名一個文件(rename 表示“重命名”)。
remove 函數:刪除一個文件(remove 表示“移除”)。
這兩個函數的特殊之處就在于,不同于之前的一些文件操作函數,它們不需要文件指針作為參數,只需要把文件的名字傳給這兩個函數就夠了。
rename:重命名文件
函數原型:
int rename(const char* oldName, const char* newName);
oldName 就是文件的“舊名字”,而 newName 是文件的“新名字”。
如果函數執行成功,則返回 0;否則,返回非零的 int 型值。
以下是一個使用的例子:
int main(int argc, char *argv[])
{
rename("test.txt", "renamed_test.txt");
return 0;
}
很簡單吧。
remove:刪除一個文件
函數原型:
int remove(const char* fileToRemove);
fileToRemove 就是要刪除的文件名。
注意:remove 函數要慎用,因為它不會提示你是否確認刪除文件。
文件是直接從硬盤被永久刪除了,也不會先移動至垃圾箱。
想要再找回被刪除的文件就只能借助一些特殊的軟件了,但是恢復過程可能沒那么容易,也不一定能夠成功。
實例:
int main(int argc, char *argv[])
{
remove("test.txt");
return 0;
}
6. 第二部分第八課預告
今天的課就到這里,一起加油吧!
我是 謝恩銘,公眾號「程序員聯盟」(微信號:coderhub)運營者,慕課網精英講師 Oscar 老師,終生學習者。
熱愛生活,喜歡游泳,略懂烹飪。
人生格言:「向著標桿直跑」