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>
class SinhVien {
private:
int ma;
string ten;
public:
SinhVien(int m = 0, const string& t = "") : ma(m), ten(t) {}
int getMa() const { return ma; }
string getTen() const { return ten; }
void luu(ofstream& f) const {
if (f.is_open()) {
f << ma << endl;
f << ten << endl;
} else {
cerr << "Loi: File chua mo hoac mo that bai!" << endl;
}
}
void doc(ifstream& f) {
if (f.is_open()) {
f >> ma;
f.ignore();
getline(f, ten);
} else {
cerr << "Loi: File chua mo hoac mo that bai!" << endl;
}
}
void hienThi() const {
cout << "Ma so: " << ma << ", 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() {
SinhVien sv1(101, "Nguyen Van A");
ofstream f_out("sinhvien.txt");
if (f_out.is_open()) {
sv1.luu(f_out);
f_out.close();
cout << "Da luu thong tin sinh vien vao sinhvien.txt" << endl;
} else {
cerr << "Khong mo duoc file sinhvien.txt de ghi." << endl;
}
SinhVien sv_doc;
ifstream f_in("sinhvien.txt");
if (f_in.is_open()) {
sv_doc.doc(f_in);
f_in.close();
cout << "Da doc thong tin sinh vien tu sinhvien.txt:" << endl;
sv_doc.hienThi();
} else {
cerr << "Khong mo duoc file sinhvien.txt de doc." << endl;
}
return 0;
}
Output:
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
- 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>
#include <fstream>
// Bao gồm định nghĩa lớp SinhVien từ đoạn code trên
int main() {
vector<SinhVien> ds;
ds.push_back(SinhVien(101, "Nguyen Van A"));
ds.push_back(SinhVien(102, "Tran Thi B"));
ds.push_back(SinhVien(103, "Le Van C"));
ofstream f_out("danhsach_sinhvien.txt");
if (f_out.is_open()) {
f_out << ds.size() << endl;
for (const auto& sv : ds) {
sv.luu(f_out);
}
f_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;
}
vector<SinhVien> ds_doc;
ifstream f_in("danhsach_sinhvien.txt");
if (f_in.is_open()) {
size_t n = 0;
f_in >> n;
f_in.ignore();
for (size_t i = 0; i < n; ++i) {
SinhVien sv;
sv.doc(f_in);
ds_doc.push_back(sv);
}
f_in.close();
cout << "Da doc danh sach sinh vien tu danhsach_sinhvien.txt:" << endl;
for (const auto& sv : ds_doc) {
sv.hienThi();
}
} else {
cerr << "Khong mo duoc file danhsach_sinhvien.txt de doc." << endl;
}
return 0;
}
Output:
Da luu danh sach sinh vien vao danhsach_sinhvien.txt
Da doc danh sach sinh vien tu danhsach_sinhvien.txt:
Ma so: 101, Ten: Nguyen Van A
Ma so: 102, Ten: Tran Thi B
Ma so: 103, Ten: Le Van C
- 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>
class SinhVienBinary {
private:
int ma;
string ten;
public:
SinhVienBinary(int m = 0, const string& t = "") : ma(m), ten(t) {}
int getMa() const { return ma; }
string getTen() const { return ten; }
void luu(ofstream& f) const {
if (f.is_open()) {
f.write(reinterpret_cast<const char*>(&ma), sizeof(ma));
size_t d = ten.size();
f.write(reinterpret_cast<const char*>(&d), sizeof(d));
f.write(ten.c_str(), d);
} else {
cerr << "Loi: File chua mo hoac mo that bai!" << endl;
}
}
void doc(ifstream& f) {
if (f.is_open()) {
f.read(reinterpret_cast<char*>(&ma), sizeof(ma));
size_t d = 0;
f.read(reinterpret_cast<char*>(&d), sizeof(d));
if (d > 0) {
vector<char> buf(d);
f.read(buf.data(), d);
ten.assign(buf.data(), d);
} else {
ten = "";
}
} else {
cerr << "Loi: File chua mo hoac mo that bai!" << endl;
}
}
void hienThi() const {
cout << "Ma so: " << ma << ", 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> ds_bin;
ds_bin.push_back(SinhVienBinary(201, "Phan Van D"));
ds_bin.push_back(SinhVienBinary(202, "Hoang Thi E F"));
ds_bin.push_back(SinhVienBinary(203, ""));
ofstream f_out("danhsach_sinhvien.bin", ios::binary);
if (f_out.is_open()) {
size_t n = ds_bin.size();
f_out.write(reinterpret_cast<const char*>(&n), sizeof(n));
for (const auto& sv : ds_bin) {
sv.luu(f_out);
}
f_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;
}
vector<SinhVienBinary> ds_doc_bin;
ifstream f_in("danhsach_sinhvien.bin", ios::binary);
if (f_in.is_open()) {
size_t n_doc = 0;
f_in.read(reinterpret_cast<char*>(&n_doc), sizeof(n_doc));
for (size_t i = 0; i < n_doc; ++i) {
SinhVienBinary sv;
sv.doc(f_in);
ds_doc_bin.push_back(sv);
}
f_in.close();
cout << "Da doc danh sach sinh vien tu danhsach_sinhvien.bin (nhi phan):" << endl;
for (const auto& sv : ds_doc_bin) {
sv.hienThi();
}
} else {
cerr << "Khong mo duoc file danhsach_sinhvien.bin de doc." << endl;
}
return 0;
}
Output:
Da luu danh sach sinh vien vao danhsach_sinhvien.bin (nhi phan)
Da doc danh sach sinh vien tu danhsach_sinhvien.bin (nhi phan):
Ma so: 201, Ten: Phan Van D
Ma so: 202, Ten: Hoang Thi E F
Ma so: 203, Ten:
- Đ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