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

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
luuVaoFile
vàdocTuFile
vào lớpSinhVien
. luuVaoFile
nhận một đối tượngofstream
(output file stream) làm tham số. Nó ghimaSo
vàten
vào luồng file. Sử dụngendl
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ượngifstream
(input file stream). Nó đọcmaSo
và sau đó dùnggetline
để đọcten
. Việc sử dụngfile.ignore()
là cần thiết sau khi đọc số nguyên bằngfile >> 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 đếngetline
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ượngsv1
. - 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ủasv1
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ụngifstream
để 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ủasv_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ớisv1
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ọiluuVaoFile
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ạysoLuongSV
lần. Trong mỗi lần lặp, chúng ta tạo một đối tượngSinhVien
tạm thời, gọidocTuFile
để điền dữ liệu từ file vào đối tượng đó, rồi thêm đối tượng đã đọc vàodanhSachSV_doc
bằngpush_back()
. - Việc
file_in.ignore()
sau khi đọc số lượng cũng rất quan trọng để chuẩn bị chogetline
(được gọi bên trongsv.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()
và 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ụngfile.write()
. Đối vớiint maSo
, chúng ta ép kiểu địa chỉ củamaSo
sangconst char*
và ghisizeof(maSo)
byte. Đối vớistring ten
, chúng ta không thể chỉ ghi trực tiếpten
vìstring
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ằngten.c_str()
).docTuFileBinary
sử dụngfile.read()
. Chúng ta đọcmaSo
tương tự như khi ghi. Đối vớistring
, 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ùngvector<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 chostring ten
. Việc kiểm tradoDaiTen > 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