1.9. Thao tác với các tệp

Các chương trình chúng ta viết từ trước đến giờ vẫn còn khá cơ bản. Đấy là bình thường, các bạn vừa mới bắt đầu học C++ thôi mà. Chỉ cần thêm 1 chút cố gắng với rèn luyện thì sớm sẽ viết được các ứng dụng thật sự thôi.

Những kiến thức cơ bản cũng gần đầy đủ rồi. Trong bài học này, chúng ta sẽ tìm hiểu thêm 1 tác vụ cũng khá quan trọng trong C++ : thao tác với các tệp.

Những chương trình mà chúng ta thảo luận trong các bài học mới trước chỉ có thể hiển thị thông điệp ra màn hình cũng như là nhận dữ liệu từ người dùng. Thế nhưng như vậy vẫn chưa đủ. Chúng ta đều biết những phần mềm như Notepad, MS Word hay MS Excel còn có khả năng đọc cũng như là thay đổi nội dung của các tệp. Kể cả trong thế giới trò chơi cũng vậy : chắc hẳn tồn tại các tệp có nhiệm vụ lưu trữ thông tin về mức độ hoàn thành trò chơi của người chơi (mà bạn sử dụng khi có các điểm nhớ) cũng như là các tệp hình ảnh, âm thanh được sử dụng. Tóm lại, nếu một chương trình mà không thể giao tiếp với các tệp thì sẽ rất dễ dẫn đến nhiều tính năng bị hạn chế.

Trong bài học này, chúng ta sẽ xem làm sao để làm được điều đó. Nếu bạn đã làm chủ được các lệnh cincout thì các bạn đã biết gần hết rồi đấy !

Viết dữ liệu vào trong tệp

Điều đầu tiên cần biết khi muốn thao tác với các tệp là để mở tệp đấy ra. Trong C++ cũng vậy !

Một khi tệp đã được mở ra, mọi chuyện sẽ diễn ra giống như  khi chúng ta dùng cincout. Ví dụ dễ thấy nhất là chúng ta cũng sẽ sử dụng <<>>. Tin tôi đi, rất nhanh thôi các bạn sẽ quen với việc này.

Chúng ta sử dụng khái niệm luồng (flux) dữ liệu khi nói về việc giao tiếp giữa chương trình máy tính với bên ngoài. Bài học này tập trung vào luồng dữ liệu tới các tệp nhưng mà nói « thao tác với các tệp » thì hình như là dễ hiểu hơn nhiều đúng không.

Tiêu đề fstream

Như thường lệ, khi chúng ta muốn sử dụng 1 tính năng trong C++, cần phải bắt đầu bằng việc bao gồm đúng tệp tiêu đề. Trong trường hợp này, bạn sẽ phải thêm #include <fstream> vào đầu mã nguồn chương trình của bạn.

! Các bạn đều đã biết về gói iostream cung cấp những công cụ để nhập/xuất dữ liệu với console. iostream thật ra là input/output stream nghĩa là luồng nhập/xuất. Tương ứng của chúng ta là fstream cho file stream nghĩa là luồng dữ liệu tới tệp.

Khác biệt là bạn cần luồng riêng cho mỗi tệp. Sau đây hãy cùng xem cách để mở ra 1 luồng xuất cho phép chúng ta ghi dữ liệu vào tệp.

Mở tệp để ghi

Các luồng thực chất là các đối tượng (object) bởi vì như chúng ta đã từng nhắc đến, C++ là ngôn ngữ lập trình hướng đối tượng (object oriented).

Đừng lo lắng, chúng ta sẽ dành cả 1 chương sau để nói về khái niệm này. Trước mắt, hãy coi nó như 1 biến cải tiển phức hợp. Các đối tượng mà chúng ta nhắc đến lưu khá nhiều thông tin về tệp đang được mở cũng như cung cấp nhiều tình năng cho phép chúng ta đóng tệp hay đưa con trỏ về đầu tệp, vv…

Quan trọng là chúng ta có thể khai báo 1 luồng giống như cách chúng ta đã làm để khai báo 1 biến. Biến đặc biệt này sẽ có « kiểu dữ liệu » là ofstream và giá trị là đường dẫn tới vị trí tệp cần thao tác.

Giống như khi đặt tên biến, có 1 vài quy tắc cần nhớ khi đặt tên luồng :

  • Tên chỉ gồm chữ, số và dấu _
  • Ký tự đầu tiên phải là chữ (viết hoa hoặc thường)
  • Các chữ không được có chứa các dấu
  • Không có dấu cách

Hẳn là các bạn đã nhận ra là những quy tắc này không khác gì những quy tắ để đặt tên biến. Ngoài ra là những quy ước về đặt tên mà rất nhiều lập trình viên sử dụng và chúng ta đã nhất trí dùng trong giáo trình này. Trong phần tiếp theo, tôi sẽ chon dùng luong làm tên luồng mà chúng ta sẽ thao tác trong các ví dụ.

#include <iostream>
#include <fstream>
using namespace std;

int main(){
   ofstream luong("C:/Applis/lttb/files/scores.txt");
   //Khai bao 1 luong cho phep ghi vao tep C:/Applis/lttb/files/scores.txt
   return 0;
}

Trong dấu ngoặc kép  là đường dẫn tới vị trí tệp. Bạn có thể dùng 1 trong 2 dạng sau đây :

  • Đường dẫn tuyệt đối : chỉ ra vị trí của tệp so với ổ đĩa của máy tính. Ví dụ : C:/Applis/lttb/files/scores.txt
  • Đường dẫn tương đối : vị trí của tệp so với thư mịc chứa tệp chạy của chương trình. Ví dụ : files/scores.txt nếu tệp chạy nằm trong C:/Applis/lttb

Sau đó, chúng ta có thể bắt đầu sử dụng luồng để ghi dữ liệu vào tệp.

! Nếu tệp tin đích không tồn tại, chương trình sẽ tạo ra nó ! Thế nhưng những thư mục trong đường dẫn phải tồn tại. Như trong ví dụ bên trên C:/Applis/lttb/files phải tồn tại, nếu không tệp tin scores.txt sẽ không được tạo ra.

Thường thường thì đường dẫn sẽ được chứa trong 1 chuỗi ký tự string. Trong trường hợp đó thì cần dung thêm hàm c_str() để mở tệp.

string const tenTep("C:/Applis/lttb/files/scores.txt");

ofstream luong(tenTep.c_str());
//Khai bao luong de ghi du lieu vao tep

1 số vấn đề có thể xảy ra khi ta cố mở 1 tệp như là tệp không thuộc sở hữu của bạn hay là ổ cứng đã đầy. Vì thế phải luôn luôn kiểm tra để chắc chắn là mọi thứ diễn ra suôn sẻ. Chúng ta dùng cú pháp if(luong) để thử. Nếu phép thử thất bại nghĩa là đã có vấn đề trong quá trình mở tệp nên ta không thể thao tác với tệp được

ofstream luong("C:/Applis/lttb/files/scores.txt");

if(monFlux){   //Kiem tra xem moi thu dien ra suon se
    //Tat ca OK, thao tac voi tep
} else {
    cout << "LOI: Khong mo duoc tep." << endl;
}

Tất cả cuối cùng đã sẵn sàng. Chuẩn bị ghi dữ liệu vào tệp !

Ghi dữ liệu vào luồng

Tôi đã nói với các bạn là việc này sẽ giống như với cout. Chúng ta sẽ sử dụng dấu << để gửi dữ liễu từ chương trình vào luồng.

#include <iostream>
#include <fstream>
#include <string>
using namespace std;

int main(){
    string const tenTep("C:/Applis/lttb/files/scores.txt");
    ofstream luong(tenTep.c_str());

    if(luong){
        luong << "Xin chao Tan Binh, day la cau se duoc ghi vao trong tep." << endl;
        luong << 42 << endl;

        int tuoi(23);
        luong << "Toi " << tuoi << " tuoi." << endl;
    }else{
        cout << "LOI: Khong mo duoc tep." << endl;
    }
    return 0;
}

Nếu tôi chạy chương trình này, tệp scores.txt sẽ được tạo ra với nội dung sau đây.

Để thực hành, bạn hãy thử viết 1 chương trình yêu cầu người dung nhập vào tên và tuổi của họ và ghi thông tin này vào 1 tệp thử xem.

Lựa chọn chế độ khi mở tệp

? Thế chuyện gì sẽ xảy ra nếu tệp đã tồn tại sẵn trong ổ cứng?

Nội dung của tệp đó sẽ bị xóa và thay thế bằng những dữ liệu mà bạn vừa ghi vào. Điều này sẽ thành vấn đề nếu chúng ta chỉ muốn thêm nội dung vào cuối 1 tệp đã có sẵn. Ví dụ có thể là 1 tệp ghi lại tất cả các hoạt động của người dung mà chúng ta không muốn xóa đi nội dung của nó mỗi lần chạy lại chương trình.

Để thêm nội dung vào cuối 1 tệp, chúng ta cần bổ sung thêm 1 thông số khi khởi tạo luồng tới tệp đó : ofstream luong("C:/Applis/lttb/files/scores.txt ", ios::app);.

! app là viết tắt của append nghĩa là « nối thêm »

Nhờ việc này, bạn không cần lo về vấn đề sẽ xóa mất nội dung tệp nữa. Mọi dữ liệu sẽ được ghi thêm vào cuối tệp.

Đọc nội dung 1 tệp tin

Đã biết cách để ghi dữ liệu vào trong 1 tệp, bây giờ chúng ta sẽ chuyển qua xem cách để đọc được nội dung từ đó. Các bạn sẽ thấy là những điều tôi trình bày dưới đây sẽ không quá lạ lẫm.

Mở tệp để đọc

Nguyên lý vẫn không thay đổi, chỉ khác là chúng ta sẽ dùng ifstream thay vì ofstream. Việc kiểm tra vẫn luôn luôn cần thiết để chắc là quá trình mở tệp không có vấn đề gì.

ifstream luong("C:/Applis/lttb/files/scores.txt");  //Mo tep de doc noi dung
if(luong){
    //Tep da mo, san sang de doc
}else{
    cout << "LOI: Khong mo duoc tep de doc." << endl;
}
… và đọc tệp

Có 3 cách khác nhau để đọc 1 tệp :

  1. Đọc theo dòng, sử dụng hàm getline();
  2. Đọc từng chữ, sử dụng dấu >>.
  3. Đọc từng ký tự, sử dụng hàm get().

Sau đây là chi tiết.

Đọc theo dòng

Phương pháp đầu tiên cho phép đọc toàn bộ 1 dòng và lưu nó vào trong 1 chuỗi ký tự.

string dong;
getline(luong, dong); //Doc 1 dong hoan chinh

Cách sử dụng giống hệt như chúng ta đã làm với cin, không có gì đặc biệt.

Đọc từng chữ

Phương pháp thứ 2 cũng quen thuộc không kém. Tôi sẽ trình bày 1 ví dụ để gợi nhớ cho các bạn.

double so;
monFlux >> so; //Doc 1 so thap phan tu tep

string chu;
monFlux >> chu;    //Doc 1 chu tu tep

Phương pháp này cho phép đọc thông tin giữa vị trí hiện tại của con trỏ trong tệp và dấu cách gần nhất sau đấy. Dữ liệu đọc được sẽ được chuyển đổi thành double, int hay string hoặc bất kỳ kiểu dữ liệu nào tùy theo kiểu mà chúng ta khai báo.

Đọc từng ký tự

Phương pháp cuối cùng là phương pháp lạ lẫm duy nhất ở đây, nhưng nó cũng không có gì phức tạp cả. Tôi xin bảo đảm.

char a;
luong.get(a);

Đoạn mã đọc 1 ký tự duy nhất và lưu nó vào trong biến a.

! Phương pháp này đọc tất cả các ký tự, có nghĩa là tính cả các dấu cách, dấu tab hay cả ký tự xuống dòng (vâng, xuống dòng cũng là 1 ký tự, có điều nó hơi đặc biệt 1 chút).Dù đặc biệt đến mấy thì các ký tự này cũng vẫn được lưu trong biến.

Bạn có còn nhớ trong bài 5, khi chúng ta nói về cin, tôi đã chỉ cho các bạn là cần thêm cin.ignore() khi chúng ta chuyển từ đọc từng chữ qua đọc theo dòng. Chúng ta cũng cần làm điều tương tự trong trường hợp này.

ifstream luong("C:/Applis/lttb/files/scores.txt");
string chu;
luong >> chu;          //Doc tung chu

luong.ignore();        //Thay doi cach doc
string dong;
getline(luong, dong); //Doc theo dong

Nhưng tôi cũng thú thực với các bạn là hiếm khi người ta thay đổi liên tục giữa các cách đọc tệp.

Đọc toàn bộ tệp

Việc phải đọc toàn bộ tệp là rất thường xuyên. Tôi đã nói ở trên cách để đọc nhưng vẫn chưa nhắc đến với các bạn cách để dừng khi chúng ta đọc đến cuối tệp.

Để biết là chúng ta có thể tiếp tục đọc không, cần phải sử dụng kết quả trả về của hàm getline(). Trong thực tế, ngoài việc đọc 1 dòng dữ liệu, hàm này còn trả về cho chúng ta 1 biến bool thông báo xem chúng ta có thể tiếp tục đọc không. Nếu kết quả trả về là true nghĩa là mọi việc vẫn ổn, chúng ta có thể tiếp tục đọc. Nếu hàm này trả về false, vậy có nghĩa là chúng ta đã đọc tới cuối tệp hoặc có vấn đề xảy ra khi đọc. Trong cả 2 trường hợp đó thì chúng ta đều phải dừng việc đọc.

Các bạn có thấy quen không? Nghe như kiểu một điều kiện của vòng lặp không xác định vậy nhỉ. Đúng thế, trong tình huống này, vòng lặp while là sự lựa chọn hợp lý nhất cho chúng ta.

#include <iostream>
#include <fstream>
#include <string>
using namespace std;

int main(){
   ifstream tep("C:/Applis/lttb/files/scores.txt");
   if(tep){
      //Mo tep khong co van de gi, chung ta co the doc
      string dong; //Bien de luu tru dong vua doc
      while(getline(tep, dong)){  //Khi con chua het tep va khong co van de gi, doc 1 dong trong tep
         cout << dong << endl;
         //In dong do ra man hinh
         //Hoac lam gi do voi dong du lieu nay, tuy ban
      }
   }else{
      cout << "LOI: Khong the mo tep de doc." << endl;
   }
   return 0;
}

Khi mà chúng ta đã đọc được dữ liệu thì việc thao tác trở nên quá dễ dàng. Ở đây tôi chỉ đơn giản là in ra màn hình nhưng trong 1 chương trình thực thì chúng ta có thể làm nhiều việc khác. Đây là cách thức thường được sử dụng nhất khi phải đọc dữ liệu trong 1 tệp. Thật ra điều này rất dễ hiểu vì khi dữ liệu được lưu trong 1 biến string, chúng ta có thể sử dụng hàng đống hàm để thao tác với chuỗi ký tự trên dữ liệu vừa đọc được !

1 vài mẹo vặt

Sau đây là 1 vài mẹo vặt hữu ích khi bạn thao tác với các tệp.

Đóng 1 tệp

Ở bên trên, tôi đã nói với các bạn về cách để mở 1 tệp nhưng vẫn chưa hề đề cập đến cách để đóng nó lại. Thật ra không phải do tôi quên mà là việc đó là không cần thiết. Những tệp đang mở sẽ tự động được đóng lại khi chương trình thoát ra khỏi đoạn mã mà trong đấy các luồng được khởi tạo.

void f(){
   ofstream luong("C:/Applis/lttb/files/scores.txt");  //Mo tep ra de su dung
   //Su dung tep
}  //Sau khi thoat ra khoi doan ma nay, tep tu dong dong lai

Tuy nhiên cũng có những trường hợp xảy đến là chúng ta cần phải đóng tệp trước khi nó được đóng tự động. Trong những trường hợp đó, chúng ta sử dụng đến hàm close().

void f(){
   ofstream luong("C:/Applis/lttb/files/scores.txt");  //Mo tep ra de su dung
   //Utilisation du fichier
   luong.close();  //Dong tep lai
   //Tu dong nay, khong the ghi them du lieu vao tep
}

Cũng tương tự như thế, chúng ta không nhất thiết phải mở tệp lúc khởi tạo mà có thể chờ thực hiện 1 vài xử lý rồi mới sử dụng hàm open() để mở tệp.

void f(){
   ofstream luong;  //Khoi tao 1 luong doc lap, khong lien quan den bat cu tep nao
   luong.open("C:/Applis/lttb/files/scores.txt");  //Mo tep C:/Applis/lttb/files/scores.txt
   //Su dung tep
   luong.close();  //Dong tep lai
  //Tu dong nay, khong the ghi them du lieu vao tep
}

Như các bạn có thể thấy, trong đa số trường hợp thì việc này là vô dụng. Cứ để tệp được mở và đóng tự động là được.

! 1 vài người thích sử dụng những hàm open()close() mặc dù là không cần thiết. Lợi ích của việc này là kiểm soát được lúc nào thì tệp được mở ra và lúc nào thì nó được đóng lại. Nói chúng đấy là sở thích của từng người, tôi để các bạn tự lựa chọn.

Con trỏ trong tệp

Hãy cùng đi sâu 1 chút vào các khái niệm kỹ thuật và xem xem chuyện gì thật sự diễn ra khi mở tệp ra đọc. Khi bạn sử dụng Notepad để mở tệp chẳng hạn, bạn sẽ nhìn thấy 1 con trỏ hiển thị ở vị trí mà  bạn sẽ viết. Ví dụ trong hình sau, con trỏ nằm ngay sau chữ “Tan Binh”.

Nếu chúng ta gõ thêm nội dung vào tệp, dữ liệu sẽ hiện ra ở ngay vị trí đó. Điều này chắc hẳn là ai cũng biết rồi. Thế nhưng có thể các bạn chưa biết, đó là trong C++ cũng có 1 con trỏ như vậy.

Khi chương trình chạy dòng lệnh sau

ifstream fichier("C:/Applis/lttb/files/scores.txt");

thì tệp C:/Applis/lttb/files/scores.txt sẽ được mở ra và con trỏ nằm ở đầu tệp. Nếu chúng ta đọc chữ đầu tiên thì trong chuỗi ký tự kết quả sẽ có là “Xin” và con trỏ sẽ nằm ở vị trí đầu chữ tiếp theo. Chữ tiếp theo máy sẽ đọc được là “chao”, “Tan”, “Binh,”, vv… cho tới cuối tệp. Tóm lại là chúng ta bắt buộc phải đọc tệp 1 cách tuần tự, không được thực dụng cho lắm.

Thật may là chúng ta cũng có thể yêu cầu con trỏ di chuyển với 1 số yêu cầu đơn giản như hãy đi đến vị trí ký tự thứ 20 tính từ đầu tệp hoặc hãy tiến thêm 10 ký tự. Nhờ đó chúng ta có thể chỉ cần đọc những dữ liệu chúng ta cần.

Để làm thế, bước đầu cần biết vị trí hiện tại của con trỏ trong tệp sau đó mới đến dịch chuyển nó.

Vị trí hiện tại của con trỏ  

Chúng ta có hàm cho phép biết vị trí của con trỏ trong tệp, cụ thể là cho biết xem nó đang ở ký tự thứ bao nhiêu. Đáng buồn là hàm này lại có 2 tên khác nhau dành cho 2 trường hợp là luồng nhập và luồng xuất. Thêm vào đấy thì 2 tên này còn là 2 cái tên khó nhớ.

  • Với ifstream : tellg()
  • Với ofstream : tellp()

Cách dùng của 2 hàm này thế nhưng lại hoàn toàn giống nhau.

ofstream teo("C:/Applis/lttb/files/scores.txt");
int viTri = tep.tellp(); //Tim ra vi tri con tro
cout << "Chung ta dang o ky tu thu " << viTri << " trong tep." << endl;
Dịch chuyển con trỏ

Để làm việc này chúng ta cũng có đến 2 hàm.

  • Với ifstream : seekg()
  • Với ofstream : seekp()

Cách sử dụng của chúng cũng giống nhau nên sau đây tôi sẽ chỉ trình bày 1 trong 2 hàm.

Các hàm này nhận vào 2 thông số : vị trí hiện tại của con trỏ và khoảng cách (tính theo số ký tự) bạn muốn thêm vào so với vị trí hiện tại.

luong.seekp(khoangCach, viTri);

Có 3 vị trí mốc ở trong tệp :

  • Đầu tệp : ios::beg
  • Cuối tệp : ios::end
  • Vị trí hiện tại : ios::cur

Ví dụ nếu muốn di chuyển 20 ký tự tính từ vị trí hiện tại, bạn có thể viết luong.seekp(20, ios::cur) hay 10 ký tự tính từ đầu tệp luong.seekp(10, ios::beg).

Xác định kích thước tệp

Chúng ta có thể thực hiện điều này dựa vào những điều vừa nói ở trên. Để xác định kích thước tệp, chúng ta sẽ đưa con trỏ tới cuối tệp và lấy ra kết quả vị trí của con trỏ. Do mỗi ký tự có giá trị là 1 byte, chúng ta sẽ xác định được dung lượng dữ liệu của tệp theo đơn vị byte. 1 ví dụ cụ thể để giúp bạn dễ hiểu.

#include <iostream>
#include <fstream>
using namespace std;

int main(){
    ifstream tep("C:/Applis/lttb/files/scores.txt");  //Mo tep
    tep.seekg(0, ios::end);  //Di toi cuoi tep
    int kichThuoc;
    kichThuoc = tep.tellg();
    //Lay ra gia tri kich thuoc tep
    cout << "Kich thuoc cua tep la : " << kichThuoc << " byte." << endl;
    return 0;
}

Thế là chúng ta đã điểm hết những khái niệm cơ bản liên quan đến tệp rồi. Phần phía sau còn cần các bạn tự học hỏi thêm nhiều.

Tóm tắt bài hoc :
  • Trong C++, để đọc và viết vào trong tệp, cần sử dụng gói <fstream>.
  • Cần khởi tạo 1 đối tượng ofstream để ghi vào tệp và 1 đối tượng ifstream nếu muốn đọc dữ liệu từ tệp.
  • Thao tác ghi vào tệp giống với cout : luong << "Du lieu"; trong khi thao tác đọc từ tệp thì giống với cin : luong >> tenBien;
  • Có thể đọc theo dòng bằng cách sử dụng lệnh getline().
  • Con trỏ cho biết vị trí hiện tại của chúng ta ở trong tệp. Nếu cần thiết, chúng ta có thể dịch chuyển con trỏ đến vị trí chúng ta muốn.