Bài 38.5: Bài tập thực hành kết hợp OOP và file trong C++

Chào mừng các bạn đến với bài thực hành quan trọng trong chuỗi bài học C++! Chúng ta đã đi qua các khái niệm cốt lõi của Lập trình hướng đối tượng (OOP) như lớp (class), đối tượng (object), đóng gói (encapsulation), kế thừa (inheritance), đa hình (polymorphism). Song song đó, chúng ta cũng tìm hiểu về cách tương tác với file để lưu trữ và đọc dữ liệu từ đĩa.

Vậy điều gì xảy ra khi chúng ta kết hợp hai kỹ năng này? Đây chính là lúc sức mạnh thực sự của C++ được thể hiện!

Hãy tưởng tượng bạn xây dựng một ứng dụng quản lý sinh viên. Mỗi sinh viên là một đối tượng với các thuộc tính như mã số, tên, điểm số... Khi chương trình kết thúc, tất cả dữ liệu về các đối tượng sinh viên này sẽ mất sạch nếu không được lưu trữ lại. Đây là lúc xử lý file vào cuộc. Chúng ta cần lưu trạng thái của các đối tượng này vào một file và sau đó có thể đọc lại từ file để khôi phục trạng thái đó khi chương trình khởi động lại.

Bài thực hành này sẽ hướng dẫn bạn cách làm điều đó: lưu trữ (serialize) các đối tượng vào file và đọc lại (deserialize) chúng từ file.

Tại sao cần kết hợp OOP và File Handling?

  • Lưu trữ dữ liệu bền vững: Dữ liệu của đối tượng tồn tại ngay cả sau khi chương trình kết thúc.
  • Quản lý dữ liệu phức tạp: Thay vì lưu trữ dữ liệu thô (số, chuỗi) một cách lộn xộn, chúng ta lưu trữ các đối tượng đã được đóng gói gọn gàng. Điều này giúp cấu trúc dữ liệu rõ ràng và dễ quản lý hơn.
  • Chia sẻ dữ liệu: Dữ liệu của đối tượng có thể được chia sẻ giữa các lần chạy chương trình hoặc thậm chí giữa các ứng dụng khác nhau thông qua file.

Lưu trữ một Đối Tượng đơn lẻ vào File Text

Cách đơn giản nhất để bắt đầu là lưu dữ liệu của một đối tượng vào một file văn bản (text file). Chúng ta sẽ định nghĩa một lớp đơn giản và thêm phương thức để lưu/đọc dữ liệu của chính nó.

Hãy xét lớp SinhVien sau:

#include <iostream>
#include <string>
#include <fstream> // Thư viện cho xử lý file

class SinhVien {
private:
    int maSo;
    string ten;

public:
    // Constructor
    SinhVien(int ms = 0, const string& t = "") : maSo(ms), ten(t) {}

    // Getter
    int getMaSo() const { return maSo; }
    string getTen() const { return ten; }

    // Phương thức lưu dữ liệu đối tượng vào file text
    void luuVaoFile(ofstream& file) const {
        if (file.is_open()) {
            // Lưu maSo và ten, mỗi thuộc tính trên một dòng hoặc cách nhau bởi dấu phân cách
            file << maSo << endl;
            file << ten << endl;
        } else {
            cerr << "Loi: File chua mo hoac mo that bai!" << endl;
        }
    }

    // Phương thức đọc dữ liệu đối tượng từ file text
    void docTuFile(ifstream& file) {
        if (file.is_open()) {
            // Đọc maSo và ten theo đúng thứ tự đã lưu
            file >> maSo; // Đọc số nguyên
            // Đọc bỏ ký tự xuống dòng còn lại sau khi đọc số
            file.ignore();
            getline(file, ten); // Đọc cả dòng cho tên (để xử lý tên có khoảng trắng)
        } else {
            cerr << "Loi: File chua mo hoac mo that bai!" << endl;
        }
    }

    // Phương thức hiển thị thông tin
    void hienThi() const {
        cout << "Ma so: " << maSo << ", Ten: " << ten << endl;
    }
};

Trong đoạn code này:

  • Chúng ta thêm các hàm luuVaoFiledocTuFile vào lớp SinhVien.
  • luuVaoFile nhận một đối tượng ofstream (output file stream) làm tham số. Nó ghi maSoten vào luồng file. Sử dụng endl sau mỗi thuộc tính giúp chúng ta dễ dàng đọc lại từng dòng một sau này.
  • docTuFile nhận một đối tượng ifstream (input file stream). Nó đọc maSo và sau đó dùng getline để đọc ten. Việc sử dụng file.ignore() là cần thiết sau khi đọc số nguyên bằng file >> maSo để bỏ qua ký tự xuống dòng còn lại trong buffer của luồng file, tránh ảnh hưởng đến getline tiếp theo.

Bây giờ, chúng ta sẽ sử dụng các phương thức này trong hàm main để lưu và đọc một đối tượng:

#include <iostream>
#include <string>
#include <fstream>
// Bao gồm định nghĩa lớp SinhVien từ đoạn code trên

int main() {
    // Tạo một đối tượng SinhVien
    SinhVien sv1(101, "Nguyen Van A");

    // **Bước 1: Lưu đối tượng vào file**
    ofstream file_out("sinhvien.txt"); // Mở file để ghi (output)

    if (file_out.is_open()) {
        sv1.luuVaoFile(file_out); // Gọi phương thức lưu
        file_out.close();         // Đóng file
        cout << "Da luu thong tin sinh vien vao sinhvien.txt" << endl;
    } else {
        cerr << "Khong mo duoc file sinhvien.txt de ghi." << endl;
    }

    // **Bước 2: Đọc đối tượng từ file**
    // Tạo một đối tượng SinhVien khác hoặc trống để đọc dữ liệu vào
    SinhVien sv_doc;

    ifstream file_in("sinhvien.txt"); // Mở file để đọc (input)

    if (file_in.is_open()) {
        sv_doc.docTuFile(file_in); // Gọi phương thức đọc
        file_in.close();          // Đóng file
        cout << "Da doc thong tin sinh vien tu sinhvien.txt:" << endl;
        sv_doc.hienThi();         // Hiển thị dữ liệu đọc được
    } else {
        cerr << "Khong mo duoc file sinhvien.txt de doc." << endl;
    }

    return 0;
}
  • Trong main, chúng ta tạo một đối tượng sv1.
  • Sử dụng ofstream để mở file "sinhvien.txt" ở chế độ ghi (ofstream). Nếu file chưa tồn tại, nó sẽ được tạo. Nếu tồn tại, nội dung cũ sẽ bị ghi đè.
  • Kiểm tra xem file có mở thành công không (file_out.is_open()).
  • Gọi sv1.luuVaoFile(file_out) để ghi dữ liệu của sv1 vào file.
  • Sau khi ghi xong, nhất định phải đóng file bằng file_out.close().
  • Để đọc, chúng ta tạo một đối tượng sv_doc và sử dụng ifstream để mở cùng file "sinhvien.txt" ở chế độ đọc (ifstream).
  • Kiểm tra file có mở thành công không (file_in.is_open()).
  • Gọi sv_doc.docTuFile(file_in) để đọc dữ liệu từ file và gán vào các thuộc tính của sv_doc.
  • Đóng file file_in.close().
  • Cuối cùng, hiển thị sv_doc để kiểm tra dữ liệu đọc được có khớp với sv1 ban đầu không.

Sau khi chạy chương trình, bạn sẽ thấy một file sinhvien.txt được tạo ra với nội dung tương tự như:

101
Nguyen Van A

Và output trên console sẽ là:

Da luu thong tin sinh vien vao sinhvien.txt
Da doc thong tin sinh vien tu sinhvien.txt:
Ma so: 101, Ten: Nguyen Van A

Lưu trữ và Đọc Nhiều Đối Tượng

Trong thực tế, chúng ta thường cần lưu trữ một danh sách (ví dụ: vector) các đối tượng. Cách phổ biến nhất để làm điều này là lưu trữ số lượng đối tượng trước, sau đó lần lượt lưu trữ từng đối tượng một. Khi đọc, chúng ta đọc số lượng đối tượng, sau đó lặp lại việc đọc từng đối tượng đúng số lần đó.

Chúng ta sẽ sử dụng lại lớp SinhVien như trên. Hàm main sẽ được điều chỉnh như sau:

#include <iostream>
#include <string>
#include <vector> // Thư viện cho vector
#include <fstream>
// Bao gồm định nghĩa lớp SinhVien từ đoạn code trên

int main() {
    // Tạo một vector chứa nhiều đối tượng SinhVien
    vector<SinhVien> danhSachSV;
    danhSachSV.push_back(SinhVien(101, "Nguyen Van A"));
    danhSachSV.push_back(SinhVien(102, "Tran Thi B"));
    danhSachSV.push_back(SinhVien(103, "Le Van C"));

    // **Bước 1: Lưu vector đối tượng vào file**
    ofstream file_out("danhsach_sinhvien.txt");

    if (file_out.is_open()) {
        // Lưu số lượng đối tượng trước
        file_out << danhSachSV.size() << endl;
        // Sau đó, lưu từng đối tượng
        for (const auto& sv : danhSachSV) {
            sv.luuVaoFile(file_out);
        }
        file_out.close();
        cout << "Da luu danh sach sinh vien vao danhsach_sinhvien.txt" << endl;
    } else {
        cerr << "Khong mo duoc file danhsach_sinhvien.txt de ghi." << endl;
    }

    // **Bước 2: Đọc vector đối tượng từ file**
    vector<SinhVien> danhSachSV_doc; // Vector trống để lưu dữ liệu đọc từ file

    ifstream file_in("danhsach_sinhvien.txt");

    if (file_in.is_open()) {
        size_t soLuongSV = 0;
        // Đọc số lượng đối tượng đầu tiên
        file_in >> soLuongSV;
        file_in.ignore(); // Bỏ qua ký tự xuống dòng sau số lượng

        // Đọc từng đối tượng theo số lượng đã đọc
        for (size_t i = 0; i < soLuongSV; ++i) {
            SinhVien sv; // Tạo đối tượng trống
            sv.docTuFile(file_in); // Đọc dữ liệu vào đối tượng
            danhSachSV_doc.push_back(sv); // Thêm đối tượng vào vector
        }

        file_in.close();
        cout << "Da doc danh sach sinh vien tu danhsach_sinhvien.txt:" << endl;
        // Hiển thị danh sách đã đọc
        for (const auto& sv : danhSachSV_doc) {
            sv.hienThi();
        }
    } else {
        cerr << "Khong mo duoc file danhsach_sinhvien.txt de doc." << endl;
    }

    return 0;
}
  • Chúng ta sử dụng vector<SinhVien> để chứa danh sách các sinh viên.
  • Khi lưu, dòng đầu tiên của file sẽ ghi số lượng sinh viên trong vector (danhSachSV.size()). Sau đó, vòng lặp sẽ gọi luuVaoFile cho từng sinh viên.
  • Khi đọc, chúng ta đầu tiên đọc số nguyên biểu thị số lượng sinh viên (soLuongSV).
  • Sử dụng vòng lặp for chạy soLuongSV lần. Trong mỗi lần lặp, chúng ta tạo một đối tượng SinhVien tạm thời, gọi docTuFile để điền dữ liệu từ file vào đối tượng đó, rồi thêm đối tượng đã đọc vào danhSachSV_doc bằng push_back().
  • Việc file_in.ignore() sau khi đọc số lượng cũng rất quan trọng để chuẩn bị cho getline (được gọi bên trong sv.docTuFile) ở lần lặp đầu tiên.

File danhsach_sinhvien.txt sẽ có dạng:

3
101
Nguyen Van A
102
Tran Thi B
103
Le Van C

Output trên console sẽ hiển thị danh sách sinh viên đã đọc được.

Lưu trữ và Đọc File Nhị Phân (Binary Files)

File text dễ đọc bằng mắt thường, nhưng có thể không hiệu quả về không gian lưu trữ và tốc độ đọc/ghi, đặc biệt với dữ liệu số lượng lớn. Ngoài ra, việc xử lý các định dạng đặc biệt (như khoảng trắng trong tên, ký tự xuống dòng) có thể phức tạp hơn. File nhị phân lưu trữ dữ liệu dưới dạng byte thô, thường hiệu quả hơn.

Khi làm việc với file nhị phân trong C++, chúng ta sử dụng các hàm read()write() của luồng file (fstream). Những hàm này làm việc với các khối byte, yêu cầu chúng ta cung cấp một con trỏ tới dữ liệu (thường là char*) và kích thước của dữ liệu đó.

Lớp SinhVien cần được điều chỉnh để có các phương thức lưu/đọc nhị phân:

#include <iostream>
#include <string>
#include <fstream>
#include <vector> // Để sử dụng vector trong main

class SinhVienBinary {
private:
    int maSo;
    string ten;

public:
    // Constructor
    SinhVienBinary(int ms = 0, const string& t = "") : maSo(ms), ten(t) {}

    // Getter
    int getMaSo() const { return maSo; }
    string getTen() const { return ten; }

    // Phương thức lưu dữ liệu đối tượng vào file nhị phân
    void luuVaoFileBinary(ofstream& file) const {
        if (file.is_open()) {
            // Lưu maSo (int) trực tiếp dưới dạng byte
            file.write(reinterpret_cast<const char*>(&maSo), sizeof(maSo));

            // Đối với string, ta cần lưu độ dài trước, rồi mới lưu nội dung
            size_t doDaiTen = ten.size();
            file.write(reinterpret_cast<const char*>(&doDaiTen), sizeof(doDaiTen));
            file.write(ten.c_str(), doDaiTen);
        } else {
            cerr << "Loi: File chua mo hoac mo that bai!" << endl;
        }
    }

    // Phương thức đọc dữ liệu đối tượng từ file nhị phân
    void docTuFileBinary(ifstream& file) {
        if (file.is_open()) {
            // Đọc maSo
            file.read(reinterpret_cast<char*>(&maSo), sizeof(maSo));

            // Đọc độ dài tên, sau đó đọc nội dung tên
            size_t doDaiTen = 0;
            file.read(reinterpret_cast<char*>(&doDaiTen), sizeof(doDaiTen));

            // Đảm bảo độ dài hợp lý để tránh lỗi
            if (doDaiTen > 0) {
                // Cấp phát bộ nhớ tạm để đọc chuỗi
                vector<char> buffer(doDaiTen);
                file.read(buffer.data(), doDaiTen);
                ten.assign(buffer.data(), doDaiTen); // Gán dữ liệu từ buffer vào string
            } else {
                 ten = ""; // Chuỗi rỗng nếu độ dài là 0
            }

        } else {
            cerr << "Loi: File chua mo hoac mo that bai!" << endl;
        }
    }

     // Phương thức hiển thị thông tin
    void hienThi() const {
        cout << "Ma so: " << maSo << ", Ten: " << ten << endl;
    }
};

Trong phiên bản này:

  • luuVaoFileBinary sử dụng file.write(). Đối với int maSo, chúng ta ép kiểu địa chỉ của maSo sang const char* và ghi sizeof(maSo) byte. Đối với string ten, chúng ta không thể chỉ ghi trực tiếp tenstring quản lý bộ nhớ động bên trong. Cách an toàn và chuẩn là ghi độ dài của chuỗi trước, sau đó ghi nội dung chuỗi (lấy bằng ten.c_str()).
  • docTuFileBinary sử dụng file.read(). Chúng ta đọc maSo tương tự như khi ghi. Đối với string, chúng ta đầu tiên đọc độ dài chuỗi. Sau đó, dựa vào độ dài này, chúng ta cấp phát một vùng nhớ đệm (ở đây dùng vector<char>), đọc dữ liệu chuỗi vào vùng đệm đó, và cuối cùng dùng dữ liệu trong vùng đệm để gán cho string ten. Việc kiểm tra doDaiTen > 0 giúp xử lý trường hợp chuỗi rỗng.

Hàm main để lưu/đọc một danh sách đối tượng nhị phân sẽ tương tự như ví dụ trước, chỉ cần thay đổi loại file stream và tên phương thức:

#include <iostream>
#include <string>
#include <vector>
#include <fstream>
// Bao gồm định nghĩa lớp SinhVienBinary từ đoạn code trên

int main() {
    vector<SinhVienBinary> danhSachSV_bin;
    danhSachSV_bin.push_back(SinhVienBinary(201, "Phan Van D"));
    danhSachSV_bin.push_back(SinhVienBinary(202, "Hoang Thi E F")); // Tên có khoảng trắng
    danhSachSV_bin.push_back(SinhVienBinary(203, "")); // Tên rỗng

    // **Bước 1: Lưu vector đối tượng vào file nhị phân**
    // Mở file với cờ ios::binary
    ofstream file_out("danhsach_sinhvien.bin", ios::binary);

    if (file_out.is_open()) {
        // Lưu số lượng đối tượng dưới dạng nhị phân
        size_t soLuong = danhSachSV_bin.size();
        file_out.write(reinterpret_cast<const char*>(&soLuong), sizeof(soLuong));

        // Lưu từng đối tượng dưới dạng nhị phân
        for (const auto& sv : danhSachSV_bin) {
            sv.luuVaoFileBinary(file_out);
        }
        file_out.close();
        cout << "Da luu danh sach sinh vien vao danhsach_sinhvien.bin (nhi phan)" << endl;
    } else {
        cerr << "Khong mo duoc file danhsach_sinhvien.bin de ghi." << endl;
    }

    // **Bước 2: Đọc vector đối tượng từ file nhị phân**
    vector<SinhVienBinary> danhSachSV_doc_bin;

    // Mở file với cờ ios::binary
    ifstream file_in("danhsach_sinhvien.bin", ios::binary);

    if (file_in.is_open()) {
        size_t soLuongSV_bin = 0;
        // Đọc số lượng đối tượng nhị phân
        file_in.read(reinterpret_cast<char*>(&soLuongSV_bin), sizeof(soLuongSV_bin));

        // Đọc từng đối tượng
        for (size_t i = 0; i < soLuongSV_bin; ++i) {
            SinhVienBinary sv;
            sv.docTuFileBinary(file_in);
            danhSachSV_doc_bin.push_back(sv);
        }

        file_in.close();
        cout << "Da doc danh sach sinh vien tu danhsach_sinhvien.bin (nhi phan):" << endl;
        // Hiển thị danh sách đã đọc
        for (const auto& sv : danhSachSV_doc_bin) {
            sv.hienThi();
        }
    } else {
        cerr << "Khong mo duoc file danhsach_sinhvien.bin de doc." << endl;
    }

    return 0;
}
  • Điểm khác biệt chính là khi mở file, chúng ta cần thêm cờ ios::binary (ví dụ: ofstream file_out("danhsach_sinhvien.bin", ios::binary);).
  • Việc đọc/ghi số lượng đối tượng cũng được thực hiện bằng read()/write() thay vì toán tử <</>>.
  • Các vòng lặp và logic cơ bản vẫn giống như khi làm việc với file text.

Kết quả chạy chương trình sẽ vẫn hiển thị danh sách sinh viên được đọc từ file, nhưng file danhsach_sinhvien.bin sẽ chứa dữ liệu không thể đọc hiểu trực tiếp bằng trình soạn thảo văn bản thông thường.

Comments

There are no comments at the moment.