Tệp
Tới đây bạn đã biết lưu dữ liệu trong biến, mảng và struct. Nhưng mọi thứ ấy nằm trong bộ nhớ trong (RAM): chương trình vừa kết thúc là dữ liệu biến mất sạch. Muốn giữ lại danh sách sinh viên, điểm thi hay nội dung một văn bản để lần sau mở lên vẫn còn, ta phải ghi chúng xuống tệp trên đĩa. Bài này dạy cách làm việc với tệp văn bản trong C.
Một tệp là một dãy byte có tên, được hệ điều hành lưu trên thiết bị lưu trữ lâu dài như ổ cứng hay thẻ nhớ. Khác với biến trong RAM, dữ liệu trong tệp tồn tại lâu dài (persistent): tắt máy rồi bật lại nó vẫn còn nguyên. Nhờ tệp, một chương trình có thể lưu kết quả để dùng cho lần chạy sau, hoặc nhiều chương trình khác nhau cùng đọc chung một nguồn dữ liệu.
Hãy nghĩ tới chương trình quản lý điểm mà bạn đã viết ở các bài trước. Mỗi lần chạy, bạn phải ngồi gõ lại từ đầu tên và điểm của bốn mươi sinh viên, chỉ để xem một bảng xếp loại rồi tắt đi và mất sạch. Không ai làm việc như vậy. Trong đời thật, dữ liệu được nhập một lần rồi nằm yên trong một tệp, chương trình mở tệp ra, đọc vào bộ nhớ, xử lý, ghi kết quả xuống, và lần sau mở lại vẫn còn nguyên. Toàn bộ phần mềm bạn dùng hằng ngày đều xoay quanh mô hình đó: soạn thảo văn bản là đọc và ghi tệp, cấu hình chương trình là một tệp, ảnh và nhạc cũng là tệp. Học tệp là học cách để chương trình của mình có trí nhớ vượt qua một lần chạy.
Có một chi tiết đáng chú ý ngay từ đầu. Đĩa cứng chậm hơn RAM rất nhiều lần, nên hệ điều hành không đọc ghi từng byte một mà làm việc theo khối. C giấu chuyện đó đi sau một lớp thư viện gọi là vào ra có đệm (buffered I/O), và mọi hàm bạn sắp học đều là cửa ngõ vào lớp đệm ấy. Hiểu điều này giúp bạn không ngạc nhiên khi ghi xong mà mở tệp lên vẫn chưa thấy gì, một hiện tượng ta sẽ giải thích kỹ ở cuối bài.
Con trỏ tệp và quy trình ba bước
Mọi thao tác với tệp trong C đều đi qua một con trỏ tệp kiểu FILE *. Nó như một cái tay cầm trỏ tới tệp đang mở, ghi nhớ tệp nào, vị trí đọc hay ghi hiện tại ở đâu. Ta lấy con trỏ này khi gọi fopen và trả lại khi gọi fclose.
Làm việc với tệp luôn theo đúng ba bước:
mởtệp bằngfopen, nhận về mộtFILE *.đọchoặcghiqua con trỏ ấy.đóngtệp bằngfcloseđể hệ điều hành ghi nốt phần còn trong bộ đệm xuống đĩa và giải phóng tài nguyên.
Quên đóng tệp có thể làm mất dữ liệu vừa ghi.
Mở và đóng tệp văn bản
Hàm fopen có nguyên mẫu FILE *fopen(const char *ten, const char *che_do). Tham số thứ nhất là tên tệp (có thể kèm đường dẫn), tham số thứ hai là một chuỗi cho biết ta định làm gì với tệp. Ba chế độ cơ bản nhất:
| Chế độ | Tên gọi | Ý nghĩa |
|---|---|---|
"r" | đọc (read) | mở tệp đã có để đọc. Nếu tệp không tồn tại, fopen trả về NULL. |
"w" | ghi (write) | tạo tệp mới để ghi. Nếu tệp đã có thì xoá sạch nội dung cũ. |
"a" | ghi nối (append) | mở tệp để ghi thêm vào cuối; nội dung cũ được giữ nguyên. Nếu chưa có thì tạo mới. |
Ba chế độ trên là bộ khung, nhưng chuỗi chế độ còn hai biến thể quan trọng. Thêm dấu + thì tệp mở ra vừa đọc vừa ghi được: "r+" mở tệp đã có để đọc và ghi mà không xoá nội dung, "w+" xoá sạch rồi cho phép cả đọc lẫn ghi, "a+" cho đọc ở mọi vị trí nhưng mọi lần ghi đều bị đẩy về cuối tệp. Thêm chữ b, ví dụ "rb" hay "wb", thì tệp được mở ở chế độ nhị phân. Trên Linux và macOS, chữ b không thay đổi gì cả; nhưng trên Windows, chế độ văn bản tự động dịch ký tự xuống dòng \n trong bộ nhớ thành cặp \r\n khi ghi ra đĩa và dịch ngược lại khi đọc vào. Với văn bản thì phép dịch đó có ích, còn với dữ liệu nhị phân nó là tai hoạ: một byte có giá trị 10 nằm giữa ảnh sẽ bị biến thành hai byte và tệp hỏng. Quy tắc gọn: tệp chữ thì không cần b, tệp dữ liệu thô thì luôn thêm b.
Vị trí đọc ghi hiện tại cũng là thứ nên nắm. Mỗi FILE * giữ một con trỏ vị trí, ta hay gọi là đầu đọc. Mở bằng "r" hay "w" thì đầu đọc nằm ở byte đầu tiên và tự tiến dần sau mỗi thao tác; mở bằng "a" thì mọi lần ghi đều nhảy về cuối bất kể đầu đọc đang ở đâu, nên chế độ nối rất an toàn cho việc ghi nhật ký nhiều lần.
Chế độ "w" xoá sạch nội dung cũ ngay khi mở thành công. Nếu bạn chỉ muốn thêm dữ liệu mà không mất phần đã có, hãy dùng "a". Nhầm hai chế độ này là cách phổ biến nhất để vô tình xoá mất một tệp dữ liệu.
Nhiều bạn nghĩ tệp chỉ mất nội dung khi mình thật sự gọi fprintf. Không phải: chỉ riêng việc fopen("dulieu.txt", "w") thành công đã cắt tệp về độ dài không. Chương trình có thể chết ngay dòng sau đó, hoặc bạn bấm dừng, thì tệp vẫn rỗng. Khi làm việc với dữ liệu thật, hãy mở bằng "r" để đọc trước, chỉ mở "w" khi bạn thật sự có nội dung mới sẵn sàng ghi đè.
Luôn kiểm tra lỗi mở tệp
fopen có thể thất bại: tên sai, không có quyền, đĩa đầy. Khi đó nó trả về NULL. Quy tắc cứng: sau mỗi fopen phải kiểm tra kết quả trước khi dùng. Nếu không, một FILE * bằng NULL đem đi đọc hay ghi sẽ làm chương trình tắt ngúm.
#include <stdio.h>
int main(void) {
FILE *f = fopen("diem.txt", "r"); // open for reading
if (f == NULL) { // ALWAYS check the result
printf("Khong mo duoc tep!\n");
return 1; // stop with error code
}
// ... work with the file here ...
fclose(f); // release the file
return 0;
}
Chỉ khi f khác NULL ta mới đi tiếp. Câu lệnh return 1 báo cho hệ điều hành rằng chương trình kết thúc do lỗi.
Vì sao phép kiểm này lại được nhấn mạnh tới vậy? Vì FILE * là một con trỏ, và NULL là giá trị con trỏ không trỏ tới đâu cả. Khi bạn đem một con trỏ NULL truyền cho fgets hay fprintf, thư viện sẽ đi lần theo địa chỉ đó để tìm cấu trúc mô tả tệp, và nó đọc vào vùng nhớ cấm. Kết quả là chương trình bị hệ điều hành chấm dứt ngay lập tức, thường kèm thông báo cụt lủn kiểu segmentation fault, không nói được lý do thật là "tệp không mở được". Một dòng kiểm NULL biến một cú chết bí ẩn thành một câu báo lỗi rõ ràng mà người dùng đọc là hiểu.
Lý do khiến fopen thất bại thì rất đời thường: gõ sai tên tệp, để tệp ở thư mục khác với thư mục chạy chương trình, tệp đang bị chương trình khác giữ, đường dẫn cần quyền quản trị, hoặc đĩa đầy khi mở để ghi. Chú ý rằng đường dẫn tương đối được tính từ thư mục làm việc lúc chạy chứ không phải từ chỗ đặt tệp nguồn, nên chạy cùng một chương trình từ hai chỗ khác nhau có thể ra hai kết quả khác nhau. Khi cần biết chính xác vì sao hỏng, có thể gọi perror("fopen") để thư viện in kèm nguyên nhân do hệ thống báo về.
Ghi dữ liệu ra tệp văn bản
Để ghi, hai công cụ chính là fprintf và fputs. Chúng giống hệt printf và puts đã quen, chỉ thêm con trỏ tệp làm tham số đầu tiên: thay vì in ra màn hình, kết quả đi vào tệp.
#include <stdio.h>
int main(void) {
FILE *f = fopen("diem.txt", "w"); // create / overwrite
if (f == NULL) {
printf("Khong mo duoc tep!\n");
return 1;
}
fprintf(f, "An 8.5\n"); // write a formatted line
fprintf(f, "Binh 7.0\n");
fprintf(f, "Chi 9.0\n");
fputs("Het danh sach\n", f); // write a plain string
fclose(f);
printf("Da ghi xong.\n");
return 0;
}
Sau khi chạy, mở diem.txt bằng trình soạn thảo bất kỳ, bạn sẽ thấy đúng bốn dòng vừa ghi. Ký tự xuống dòng \n quan trọng để các bản ghi tách bạch, dễ đọc lại sau.
printf("...") in ra màn hình; fprintf(f, "...") ghi y hệt nhưng vào tệp f. Thực ra màn hình cũng là một tệp đặc biệt tên stdout, nên printf(x) chính là fprintf(stdout, x).
Bộ đệm, fclose và fflush
Khi bạn gọi fprintf(f, "An 8.5\n"), dữ liệu thường chưa xuống đĩa ngay. Nó được xếp vào một vùng nhớ trung gian gọi là bộ đệm (buffer) do thư viện quản lý, và chỉ thật sự được ghi xuống khi bộ đệm đầy, khi bạn gọi fclose, hoặc khi bạn chủ động gọi fflush. Cách làm này không phải để gây khó, mà vì mỗi lần chạm vào đĩa là một thao tác rất đắt so với một phép ghi vào RAM. Gom một nghìn dòng ngắn lại rồi ghi một lần nhanh hơn ghi một nghìn lần riêng lẻ tới hàng chục lần.
Cái giá phải trả là dữ liệu có thể còn nằm treo trong bộ đệm khi chương trình kết thúc bất thường. Nếu chương trình chết giữa chừng, hoặc bạn quên fclose, phần đang nằm trong bộ đệm bốc hơi và tệp trên đĩa thiếu mất những dòng cuối. Đây chính là lời giải cho hiện tượng làm nhiều người mới bối rối: chương trình in ra "Da ghi xong" nhưng mở tệp lên lại thấy trống.
Vậy fclose làm những gì? Nó đẩy nốt bộ đệm xuống đĩa, trả lại vùng nhớ đệm, và trả lại cho hệ điều hành cái ô mô tả tệp đang mở. Số tệp mở đồng thời của một tiến trình là hữu hạn, nên một vòng lặp mở tệp mà không đóng sẽ cạn kiệt tài nguyên rồi khiến fopen bắt đầu trả về NULL dù tệp hoàn toàn bình thường. Ngoài ra fclose còn trả về 0 khi thành công và EOF khi có lỗi, và với dữ liệu quan trọng thì đáng kiểm tra giá trị này: lỗi đĩa đầy nhiều khi chỉ lộ ra đúng ở lần đẩy đệm cuối cùng.
Hàm fflush(f) đẩy bộ đệm xuống ngay lập tức mà không đóng tệp. Nó có ích khi chương trình chạy lâu và bạn muốn tệp nhật ký luôn cập nhật để theo dõi từ ngoài, hoặc trước một đoạn xử lý rủi ro có thể làm chết chương trình. Đừng lạm dụng: gọi fflush sau từng dòng sẽ xoá sạch lợi ích của bộ đệm.
Rất nhiều bài nộp bị trừ điểm chỉ vì thiếu fclose(f) ở cuối. Người viết nghĩ chương trình sắp kết thúc rồi, hệ điều hành sẽ tự dọn. Trên thực tế nhiều môi trường có dọn giúp khi tiến trình thoát bình thường, nhưng bạn không được dựa vào đó: chỉ cần chương trình thoát bằng đường khác, hoặc còn chạy tiếp và tệp bị đọc lại ngay sau đó, là dữ liệu chưa kịp xuống đĩa. Mở tệp ở đâu thì đóng tệp ở đó, coi như một cặp không tách rời.
Đọc dữ liệu từ tệp văn bản
Để đọc, ta thường dùng fscanf khi dữ liệu có khuôn dạng rõ ràng (số, từ cách nhau bởi khoảng trắng), hoặc fgets khi muốn đọc nguyên một dòng kể cả khoảng trắng. Điểm mấu chốt là phải biết khi nào hết tệp để dừng.
Đọc tới hết tệp với EOF
EOF (end of file) là một hằng số đặc biệt báo hiệu đã hết tệp. fscanf trả về số mục đọc thành công; khi không còn gì để đọc, nó trả về EOF. Ta lặp cho tới khi gặp EOF.
#include <stdio.h>
int main(void) {
FILE *f = fopen("diem.txt", "r");
if (f == NULL) { printf("Khong mo duoc tep!\n"); return 1; }
char ten[50];
double diem, tong = 0.0;
int dem = 0;
// read "name score" pairs until EOF
while (fscanf(f, "%49s %lf", ten, &diem) == 2) {
printf("%s -> %.1f\n", ten, diem);
tong += diem;
dem++;
}
fclose(f);
if (dem > 0)
printf("Trung binh = %.2f\n", tong / dem);
return 0;
}
Điều kiện == 2 nghĩa là fscanf đọc đủ cả tên lẫn điểm; khi hết tệp nó trả về EOF (khác 2) nên vòng lặp dừng. Ở đây danh sách giả định mỗi dòng có dạng ten diem, ví dụ An 8.5.
Hãy giảng kỹ hơn về EOF, vì đây là chỗ sai kinh điển. EOF không phải một ký tự nằm sẵn ở cuối tệp mà bạn có thể đọc thấy. Nó là một hằng số (thường bằng -1) mà thư viện trả về để nói rằng lần đọc vừa rồi không lấy được gì nữa. Nói cách khác, hết tệp là một điều được phát hiện sau khi đã cố đọc và thất bại, chứ không phải một cái mốc nhìn thấy trước. Chính vì thế ta luôn đặt phép kiểm ngay trên giá trị trả về của hàm đọc, như while (fscanf(...) == 2) hay while (fgets(...) != NULL).
Cũng nên hiểu con số mà fscanf trả về: đó là số mục đã gán thành công, chứ không phải số byte. Nếu tệp có dòng hỏng kiểu An tam-phay-nam, fscanf với "%49s %lf" sẽ gán được tên rồi tắc ở điểm, trả về 1. Điều kiện == 2 bắt đúng ca đó và dừng vòng lặp, thay vì âm thầm tính sai. Nhưng cẩn thận: fscanf khi gặp dữ liệu không khớp thì để nguyên ký tự sai trong luồng, nên nếu bạn viết vòng lặp while (!feof(f)) rồi đọc bên trong, chương trình sẽ quay tròn mãi ở đúng chỗ hỏng đó.
Chuỗi định dạng "%49s" cũng không phải ngẫu nhiên. Mảng ten dài 50 nên ta cho phép đọc tối đa 49 ký tự, chừa một ô cho ký tự kết thúc chuỗi '\0'. Nếu viết "%s" trơn, một dòng dữ liệu dài bất thường trong tệp sẽ tràn ra ngoài mảng và ghi đè lên vùng nhớ bên cạnh, một lỗi nguy hiểm và rất khó lần ra.
while (!feof(f)) là cái bẫy phổ biến nhất trong bài học về tệp. Hàm feof chỉ trả về đúng sau khi một lần đọc đã chạm tới cuối tệp và thất bại. Nghĩa là ở vòng lặp cuối, feof còn báo sai, thân vòng vẫn chạy thêm một lần với dữ liệu không đọc được, và bản ghi cuối bị xử lý lặp hai lần hoặc thành rác. Hãy luôn để chính hàm đọc làm điều kiện: while (fscanf(f, "%d", &x) == 1) hoặc while (fgets(dong, sizeof(dong), f) != NULL).
Trong fscanf, các đối số nhận giá trị phải truyền địa chỉ bằng &, đúng như scanf. Ví dụ &diem. Riêng ten là mảng char nên tên mảng đã là địa chỉ, không thêm &. Quên & là lỗi kinh điển khiến chương trình tắt ngúm.
Đọc từng dòng với fgets
Khi mỗi dòng là một câu có khoảng trắng (ví dụ một địa chỉ, một câu văn), fscanf với %s sẽ dừng ở khoảng trắng đầu tiên. Lúc này fgets tiện hơn: nó đọc nguyên một dòng (tối đa n-1 ký tự) vào một mảng char, và trả về NULL khi hết tệp.
#include <stdio.h>
int main(void) {
FILE *f = fopen("vanban.txt", "r");
if (f == NULL) { printf("Khong mo duoc tep!\n"); return 1; }
char dong[256];
int stt = 1;
// fgets returns NULL at end of file
while (fgets(dong, sizeof(dong), f) != NULL) {
printf("%d: %s", stt, dong); // dong already ends with newline
stt++;
}
fclose(f);
return 0;
}
fgets giữ lại ký tự xuống dòng \n ở cuối mỗi dòng (nếu có), nên ở printf ta không cần thêm \n. Tham số sizeof(dong) bảo đảm không đọc tràn quá kích thước mảng, tránh lỗi vượt biên.
Việc fgets giữ lại \n là chi tiết hay bị bỏ sót và gây ra những so sánh chuỗi sai đến khó hiểu. Nếu tệp có dòng An thì mảng dong chứa A, n, \n, \0, nên strcmp(dong, "An") cho kết quả khác 0 dù nhìn bằng mắt thì hai chuỗi giống hệt nhau. Muốn cắt ký tự xuống dòng, ta tìm nó rồi thay bằng ký tự kết thúc chuỗi:
#include <stdio.h>
#include <string.h>
int main(void) {
char dong[256] = "An\n";
size_t n = strlen(dong); // strlen returns size_t, not int
if (n > 0 && dong[n - 1] == '\n') {
dong[n - 1] = '\0'; // drop the trailing newline
}
printf("[%s]\n", dong); // prints: [An]
return 0;
}
Chú ý strlen trả về kiểu size_t (một kiểu nguyên không dấu), không phải int. Phép kiểm n > 0 phải đứng trước phép truy cập dong[n - 1], vì với chuỗi rỗng thì n bằng 0 và n - 1 trên kiểu không dấu sẽ quay vòng thành một số cực lớn, dẫn tới đọc ra ngoài mảng.
Chọn fscanf hay fgets là một quyết định có đánh đổi thật. fscanf gọn khi dữ liệu có khuôn dạng chặt chẽ (các số cách nhau bởi khoảng trắng), nhưng nó dễ mắc kẹt khi tệp bẩn và không đọc nổi chuỗi có dấu cách. fgets chắc tay hơn: nó luôn nuốt trọn một dòng, không bao giờ tràn mảng nếu bạn truyền đúng kích thước, và bạn có thể phân tích dòng đó sau bằng sscanf hay tự tách bằng tay. Trong mã sản xuất, lối đọc từng dòng rồi phân tích được ưa dùng hơn hẳn.
Giới thiệu tệp nhị phân
Với dữ liệu lớn hoặc nhiều bản ghi cùng kiểu, ghi dạng văn bản vừa tốn chỗ vừa chậm vì phải chuyển số thành chữ rồi đọc ngược lại. Tệp nhị phân lưu byte thô đúng như trong bộ nhớ, gọn và nhanh hơn. Hai hàm chính là fwrite (ghi cả khối) và fread (đọc cả khối). Mở tệp nhị phân ta thêm chữ b vào chế độ: "wb", "rb".
#include <stdio.h>
typedef struct { char ten[20]; double diem; } SinhVien;
int main(void) {
SinhVien ds[3] = {{"An", 8.5}, {"Binh", 7.0}, {"Chi", 9.0}};
FILE *f = fopen("sv.dat", "wb"); // binary write
if (f == NULL) { return 1; }
fwrite(ds, sizeof(SinhVien), 3, f); // write 3 records
fclose(f);
SinhVien doc[3];
f = fopen("sv.dat", "rb"); // binary read
if (f == NULL) { return 1; }
int n = fread(doc, sizeof(SinhVien), 3, f); // read back
fclose(f);
for (int i = 0; i < n; i++)
printf("%s %.1f\n", doc[i].ten, doc[i].diem);
return 0;
}
fwrite(ds, sizeof(SinhVien), 3, f) đọc là: ghi 3 phần tử, mỗi phần tử dài sizeof(SinhVien) byte, từ địa chỉ ds, vào tệp f. fread có cùng dạng tham số và trả về số phần tử đọc được. Mở sv.dat bằng trình soạn thảo thường sẽ thấy ký tự lộn xộn, vì đây là byte thô chứ không phải chữ.
Văn bản hay nhị phân, chọn cái nào
Hai lối lưu trữ này khác nhau ở chỗ có phiên dịch hay không. Tệp văn bản lưu con số dưới dạng chữ: giá trị 8.5 thành bốn ký tự 8, ., 5 và một dấu tách. Khi ghi, fprintf phải chuyển số thành chữ; khi đọc, fscanf phải phân tích chữ ngược về số. Tệp nhị phân bỏ qua toàn bộ vòng phiên dịch đó, đổ thẳng các byte đang nằm trong bộ nhớ xuống đĩa.
Nhị phân được lợi ba điểm: nhanh hơn vì không phải chuyển đổi, gọn hơn vì một double luôn chiếm đúng 8 byte thay vì có thể dài hơn khi viết ra chữ, và giữ đúng giá trị số thực tới từng bit thay vì bị làm tròn theo số chữ số bạn in ra. Đổi lại, tệp nhị phân không đọc được bằng mắt, không sửa được bằng trình soạn thảo, khó lần lỗi, và quan trọng nhất là nó gắn chặt với máy đã sinh ra nó. Cùng một struct có thể có kích thước khác nhau trên hai trình biên dịch vì luật chèn byte đệm (padding), và thứ tự byte của số nguyên cũng có thể ngược nhau giữa hai kiến trúc. Một tệp .dat ghi trên máy này đem sang máy khác đọc rất có thể ra rác, trong khi một tệp .txt thì đi đâu cũng đọc được.
Lời khuyên thực dụng cho người mới: dùng văn bản cho dữ liệu bạn còn muốn nhìn, sửa, hay đem trao đổi (cấu hình, nhật ký, danh sách điểm để nộp thầy); dùng nhị phân cho dữ liệu khối lớn cùng kiểu mà chỉ chương trình của bạn đọc, và chấp nhận rằng khuôn dạng đó là riêng của bạn. Dù chọn lối nào cũng luôn kiểm giá trị trả về của fread: nếu nó nhỏ hơn số phần tử bạn yêu cầu thì tệp ngắn hơn dự kiến hoặc đã hỏng, và bạn phải dừng thay vì đem mảng chưa điền đủ ra dùng.
Bài tập thực hành
Bài 1. Viết chương trình mở tệp thongbao.txt ở chế độ ghi và lưu vào đó ba dòng tự chọn, sau đó báo ra màn hình đã ghi xong. Kiểm tra lỗi mở tệp đầy đủ.
Gợi ý
Mở bằng fopen("thongbao.txt", "w"), kiểm tra NULL rồi return 1 nếu lỗi. Dùng ba lệnh fprintf(f, "...\n") cho ba dòng, gọi fclose(f), cuối cùng printf("Da ghi xong.\n"). Mở lại tệp bằng trình soạn thảo để kiểm chứng.
Bài 2. Cho một tệp songuyen.txt chứa một dãy số nguyên cách nhau bởi khoảng trắng. Viết chương trình đọc tới EOF, tính tổng và đếm số lượng, rồi in trung bình cộng. Xử lý trường hợp tệp rỗng.
Gợi ý
Lặp while (fscanf(f, "%d", &x) == 1) để đọc từng số tới khi không còn (trả về EOF). Cộng dồn vào tong, tăng dem. Sau vòng lặp, nếu dem lớn hơn 0 thì in tong / dem (ép kiểu sang double để có phần thập phân), ngược lại báo tệp rỗng.
Bài 3. Viết chương trình đếm số dòng của một tệp văn bản bất kỳ bằng fgets, in kết quả ra màn hình.
Gợi ý
Khai báo char dong[256] và biến đếm bằng 0. Lặp while (fgets(dong, sizeof(dong), f) != NULL) và tăng biến đếm mỗi vòng. Khi fgets trả về NULL là hết tệp, dừng lại và in biến đếm.
Bài 4. Định nghĩa struct SinhVien gồm tên và điểm. Nhập danh sách sinh viên từ bàn phím, ghi ra tệp nhị phân bằng fwrite, sau đó đọc lại bằng fread và in ra màn hình để kiểm chứng.
Gợi ý
Mở tệp chế độ "wb", gọi fwrite(ds, sizeof(SinhVien), n, f) ghi cả mảng một lần. Đóng tệp, mở lại chế độ "rb", gọi fread(doc, sizeof(SinhVien), n, f) để đọc về. Số phần tử fread trả về cho biết đã đọc đủ chưa, dùng nó làm cận khi in.
Bài 5. Viết chương trình sao chép một tệp văn bản sang tệp khác theo từng dòng, đồng thời đếm số dòng đã chép. Xử lý đầy đủ lỗi mở tệp cho cả hai tệp.
Lời giải chi tiết
Bài này có một cái bẫy về tài nguyên: nếu mở được tệp nguồn nhưng mở tệp đích thất bại, ta phải đóng tệp nguồn lại trước khi thoát, nếu không sẽ rò rỉ một tệp đang mở. Vòng lặp dùng fgets làm điều kiện, vì fgets trả về NULL đúng khi hết tệp; mỗi dòng đọc được ghi thẳng sang tệp đích bằng fputs, và không cần thêm \n vì fgets đã giữ lại ký tự xuống dòng. Cuối cùng đóng cả hai tệp để bộ đệm được đẩy xuống đĩa.
#include <stdio.h>
int main(void) {
FILE *nguon = fopen("vaovan.txt", "r");
if (nguon == NULL) {
printf("Khong mo duoc tep nguon!\n");
return 1;
}
FILE *dich = fopen("banchep.txt", "w");
if (dich == NULL) {
printf("Khong mo duoc tep dich!\n");
fclose(nguon); // release the file we already opened
return 1;
}
char dong[512];
int soDong = 0;
while (fgets(dong, sizeof(dong), nguon) != NULL) {
fputs(dong, dich); // fgets kept the newline, so no extra one
soDong++;
}
fclose(dich); // flush the buffer to disk
fclose(nguon);
printf("Da chep %d dong\n", soDong);
return 0;
}
Lưu ý: nếu một dòng trong tệp dài hơn 511 ký tự, fgets sẽ cắt nó thành nhiều lần đọc. Bản sao vẫn đúng từng byte, chỉ có biến soDong là đếm cao hơn thực tế. Muốn đếm chuẩn, chỉ tăng biến đếm khi dòng đọc được có kết thúc bằng \n.
Bài 6. Một tệp nhietdo.txt chứa mỗi dòng một số thực là nhiệt độ đo được. Viết chương trình đọc tệp, tìm giá trị lớn nhất, và in ra. Chương trình phải chạy đúng cả khi tệp rỗng.
Lời giải chi tiết
Cái khó nằm ở tệp rỗng. Nếu ta khởi tạo max bằng 0 rồi so sánh dần, chương trình sẽ báo nhiệt độ cao nhất là 0 cho một tệp rỗng, và còn báo sai cả với tệp toàn số âm. Cách chắc chắn là dùng một biến đếm: chỉ khi đọc được số đầu tiên ta mới đặt max bằng chính số đó, và chỉ in kết quả khi biến đếm lớn hơn 0. Nhờ vậy ta không bao giờ dùng tới một giá trị chưa được điền.
#include <stdio.h>
int main(void) {
FILE *f = fopen("nhietdo.txt", "r");
if (f == NULL) {
printf("Khong mo duoc tep!\n");
return 1;
}
double x, max = 0.0;
int dem = 0;
while (fscanf(f, "%lf", &x) == 1) { // the read itself is the condition
if (dem == 0 || x > max) {
max = x; // first value seeds max
}
dem++;
}
fclose(f);
if (dem == 0) {
printf("Tep rong, khong co du lieu\n");
} else {
printf("Nhiet do cao nhat: %.2f (tren %d gia tri)\n", max, dem);
}
return 0;
}
Ý chính: điều kiện dem == 0 || x > max vừa xử lý lần đầu vừa xử lý các lần sau bằng một dòng, và phép kiểm dem == 0 sau vòng lặp bảo đảm ta không in ra một con số vô nghĩa khi chẳng đọc được gì.
Tự kiểm tra
Vì sao sau mỗi lần gọi fopen phải kiểm tra kết quả?
Vì fopen có thể thất bại (tên sai, không có quyền, đĩa đầy) và khi đó nó trả về NULL. Nếu ta không kiểm tra mà đem một con trỏ NULL đi đọc hay ghi, chương trình sẽ truy cập vào địa chỉ không hợp lệ và tắt ngúm. Kiểm tra NULL ngay sau fopen giúp dừng sớm và báo lỗi rõ ràng.
Khác nhau giữa chế độ "w" và "a" là gì?
Chế độ "w" tạo tệp mới và xoá sạch nội dung cũ nếu tệp đã tồn tại, nên mọi dữ liệu trước đó mất hết. Chế độ "a" mở tệp để ghi nối vào cuối, giữ nguyên nội dung cũ và chỉ thêm phần mới phía sau. Khi muốn tích luỹ dữ liệu qua nhiều lần chạy thì dùng "a".
Câu hỏi tự kiểm
- 1Gọi fopen("dulieu.txt", "w") thành công trên một tệp đã có nội dung thì điều gì xảy ra?
- 2Vì sao không nên dùng while (!feof(f)) làm điều kiện lặp đọc tệp?
- 3Giá trị mà fscanf trả về là gì?
- 4Để ghi dữ liệu nhị phân thô mà không bị Windows dịch ký tự xuống dòng thành cặp, nên mở tệp ở chế độ nào?
Tóm tắt
Tệp là cách để chương trình có trí nhớ vượt qua một lần chạy. Mọi thao tác đều đi qua một FILE * lấy từ fopen và trả lại bằng fclose. Chuỗi chế độ quyết định số phận dữ liệu: "r" đọc, "w" xoá sạch rồi ghi, "a" ghi nối vào cuối, thêm + để vừa đọc vừa ghi, thêm b cho dữ liệu nhị phân. Ngay sau fopen phải kiểm NULL, vì dùng một con trỏ tệp NULL sẽ làm chương trình chết mà không nói rõ lý do.
Ghi văn bản bằng fprintf hoặc fputs, đọc bằng fscanf hoặc fgets, còn dữ liệu khối cùng kiểu thì dùng fwrite và fread với chế độ nhị phân. Điều kiện dừng vòng lặp luôn là giá trị trả về của chính hàm đọc, không bao giờ là feof. Cuối cùng, dữ liệu bạn ghi nằm trong bộ đệm chứ chưa xuống đĩa ngay, nên fclose (hoặc fflush khi cần) không phải phép lịch sự mà là điều kiện để dữ liệu thật sự được lưu.
Làm việc với tệp luôn theo ba bước: mở bằng fopen, kiểm tra NULL, làm việc rồi đóng bằng fclose. Ba chế độ "r", "w", "a" quyết định tệp được đọc, ghi đè hay ghi nối. Ghi bằng fprintf hoặc fputs, đọc bằng fscanf hoặc fgets và lặp tới EOF. Với dữ liệu khối cùng kiểu, tệp nhị phân dùng fread và fwrite cho gọn và nhanh.