Chuyển tới nội dung chính

Đồ án nhỏ: hệ quản lý sinh viên

Đây là bài điểm nhấn của học phần. Tới đây bạn đã đi qua đủ bốn trụ cột của lập trình hướng đối tượng cùng với template và thư viện chuẩn. Mỗi trụ cột đã luyện riêng trong các bài trước. Phần này gom tất cả lại trong một bài toán duy nhất, đủ nhỏ để đọc hết trong một buổi nhưng đủ lớn để thấy các kỹ thuật ráp vào nhau ra sao.

Bài toán là một hệ quản lý sinh viên cỡ nhỏ: một chương trình giữ danh sách sinh viên trong bộ nhớ và cho phép thêm, xoá, tìm, tính điểm trung bình, sắp xếp theo điểm và in danh sách. Đây không phải một phần mềm hoàn chỉnh có giao diện và cơ sở dữ liệu, mà là phần lõi nghiệp vụ viết theo tư duy hướng đối tượng.

Cái hay của đồ án nằm ở chỗ nó buộc ta đặt mọi mảnh ghép đúng chỗ: đóng gói để dữ liệu sinh viên không bị sửa bậy, kế thừa để không chép lại phần chung của Nguoi, đa hình để in nhiều loại đối tượng bằng một lời gọi duy nhất, và một bộ chứa của thư viện chuẩn để giữ danh sách mà không phải tự cài mảng động.

Mục tiêu sau bài này

Bạn sẽ phân tích một yêu cầu thực tế thành các lớp với quan hệ kế thừa và kết hợp rõ ràng, cài đặt một hệ thống nhỏ thể hiện đồng thời đóng gói, kế thừa, đa hình và dùng std::vector, đồng thời quản lý vòng đời đối tượng cấp phát động đúng cách.

Đề bài và yêu cầu chức năng

Ta phát biểu yêu cầu rõ ràng trước khi viết bất kỳ dòng mã nào, vì thiết kế tốt luôn bắt đầu từ việc hiểu đúng việc cần làm. Hệ quản lý sinh viên cần đáp ứng các chức năng sau.

  • Thêm sinh viên. Nhận thông tin một sinh viên mới (họ tên, năm sinh, mã số sinh viên, điểm trung bình) và đưa vào danh sách quản lý.
  • Xoá sinh viên. Cho một mã số sinh viên, tìm và loại sinh viên đó khỏi danh sách, đồng thời giải phóng đúng vùng nhớ của đối tượng bị xoá.
  • Tìm sinh viên. Cho một mã số, trả về sinh viên tương ứng nếu có, để xem hoặc cập nhật.
  • Tính điểm trung bình. Tính điểm trung bình cộng của cả lớp, dùng để đánh giá chung.
  • Sắp xếp theo điểm. Sắp danh sách giảm dần theo điểm trung bình, phục vụ xếp hạng.
  • In danh sách. Duyệt và in mọi sinh viên ra màn hình theo một định dạng thống nhất.

Mỗi chức năng tưởng đơn giản nhưng đều dẫn ta tới một quyết định thiết kế. "In danh sách theo một định dạng thống nhất" gợi ý một phương thức hiển thị dùng chung, mà nếu sau này có thêm loại người khác (giảng viên chẳng hạn) thì cách in phải tự đổi theo, đó là chỗ cho đa hình. "Thêm và xoá" gợi ý một bộ chứa co giãn được, đó là chỗ cho std::vector. "Không cho sửa điểm bậy" gợi ý dữ liệu phải đóng gói. Từng yêu cầu nghiệp vụ ánh xạ thẳng vào một kỹ thuật đã học.

Quy ước include

Trong cả bài, ta giả định mỗi tệp đã có #include <iostream>, #include <string>, #include <vector>, #include <algorithm>using namespace std;. Ta dùng std::vector làm bộ chứa thay vì tự cài mảng động, vì vector chính là một lớp template của thư viện chuẩn.

Thiết kế tổng thể

Bước phân tích cho ta ba lớp.

  1. Mọi người trong hệ thống đều có họ tên và năm sinh, nên ta tách phần chung đó vào một lớp cơ sở tên Nguoi. Lớp này đóng gói hai thuộc tính chung và cung cấp một phương thức ảo hienThi để hiển thị, đặt ảo ngay từ đầu để mở đường cho đa hình.
  2. SinhVien kế thừa Nguoi theo quan hệ "là một" (mỗi sinh viên là một người) và bổ sung hai thuộc tính riêng là mã số sinh viên và điểm, đồng thời ghi đè hienThi để in thêm phần riêng.
  3. QuanLySinhVien không phải là một người, mà chứa một danh sách người, nên nó liên hệ với SinhVien bằng quan hệ kết hợp "có nhiều". Lớp quản lý giữ một vector các con trỏ và cung cấp toàn bộ các thao tác nghiệp vụ.

Việc QuanLySinhVien giữ con trỏ Nguoi* chứ không phải đối tượng SinhVien theo trị là một lựa chọn có chủ ý. Chỉ khi thao tác qua con trỏ lớp cơ sở thì điều phối động mới hoạt động, và nhờ vậy cùng một lời gọi hienThi mới chạy đúng phiên bản của lớp con. Nếu lưu theo trị vector<SinhVien> thì ta mất khả năng chứa lẫn nhiều loại người trong cùng danh sách, và mất luôn tính đa hình.

Nguoi (lớp cơ sở)
# hoTen : string
# namSinh : int
+ getHoTen() : string
+ virtual hienThi() : void
+ virtual ~Nguoi()
▲ là một QuanLySinhVien (lớp quản lý)
| - ds : vector<Nguoi*>
SinhVien (lớp dẫn xuất) + them(Nguoi*) : void
- maSV : string có nhiều 0..* + xoa(string) : bool
- diem : double ◇───────────────────+ tim(string) : Nguoi*
+ getMaSV() : string + diemTrungBinh() : double
+ getDiem() : double + sapXepTheoDiem() : void
+ hienThi() : void (override) + inDanhSach() : void

Khi đọc sơ đồ, hãy chú ý ba điều. Thứ nhất, các thuộc tính của Nguoi để mức protected (dấu #) để lớp con truy cập được mà bên ngoài vẫn không sờ tới. Thứ hai, hienThi ở lớp cơ sở là ảo và được SinhVien ghi đè, đó là điểm tựa của đa hình. Thứ ba, Nguoi có một hàm huỷ ảo, vì lớp quản lý sẽ xoá đối tượng qua con trỏ lớp cơ sở, mà xoá đa hình thiếu hàm huỷ ảo sẽ rò rỉ bộ nhớ.

Ghi nhớ: kế thừa hay kết hợp?

Một quy tắc ánh xạ hữu ích: nếu câu "B là một A" nghe tự nhiên thì dùng kế thừa; nếu câu "X có một (hoặc nhiều) B" mới đúng thì dùng kết hợp, tức để B làm thành viên của X. Ở đây "sinh viên là một người" nên SinhVien kế thừa Nguoi; còn "hệ quản lý có nhiều người" nên QuanLySinhVien chứa danh sách, không kế thừa.

Cài đặt lớp cơ sở Nguoi

Ta bắt đầu từ lớp cơ sở. Nguoi đóng gói hai thuộc tính chung và phơi bày một giao diện gọn. Phương thức hienThi được khai báo virtual để các lớp con ghi đè, và hàm huỷ cũng virtual để việc xoá qua con trỏ lớp cơ sở dọn đúng phần của lớp con. Ta đặt thuộc tính ở mức protected thay vì private để SinhVien dùng lại trực tiếp khi cần.

class Nguoi {
protected: // visible to derived classes, hidden outside
string hoTen;
int namSinh;
public:
Nguoi(const string& ht, int ns) : hoTen(ht), namSinh(ns) {}

string getHoTen() const { return hoTen; } // const getter, read-only

// Virtual so each subclass can supply its own display.
virtual void hienThi() const {
cout << "Nguoi: " << hoTen << ", sinh nam " << namSinh;
}

// Virtual destructor: required for safe deletion via a base pointer.
virtual ~Nguoi() = default;
};

Hãy chú ý hienThi của lớp cơ sở vẫn có thân hàm chứ không thuần ảo. Lý do là một "người" chung chung vẫn in được phần họ tên và năm sinh; ta chưa cần ép Nguoi thành lớp trừu tượng. Ở đây ta giữ phiên bản có thân để minh hoạ cả việc gọi lại phần cài đặt của lớp cha từ lớp con.

Cài đặt lớp dẫn xuất SinhVien

SinhVien kế thừa Nguoi theo kiểu public để bảo toàn quan hệ "là một". Nó thêm hai thuộc tính riêng đặt ở mức private, và ghi đè hienThi để in thêm mã số cùng điểm. Trong phần ghi đè, ta gọi lại Nguoi::hienThi để tái dùng phần in chung của lớp cha rồi nối thêm phần riêng, tránh chép lại đoạn in họ tên.

class SinhVien : public Nguoi { // SinhVien is-a Nguoi
private:
string maSV;
double diem;
public:
// Forward the shared part to the base constructor, then init own members.
SinhVien(const string& ht, int ns, const string& ms, double d)
: Nguoi(ht, ns), maSV(ms), diem(d) {}

string getMaSV() const { return maSV; } // used by search and delete
double getDiem() const { return diem; } // used by average and sort

// Override the base display; reuse the base part, then add own fields.
void hienThi() const override {
Nguoi::hienThi(); // call the base version first
cout << " | MSSV: " << maSV
<< " | Diem: " << diem;
}
};

Có vài điểm đáng dừng lại. Từ khoá override không bắt buộc nhưng nên có: nó yêu cầu trình biên dịch kiểm tra rằng ta thật sự ghi đè một hàm ảo của lớp cha; nếu lỡ viết sai chữ ký, ví dụ quên const, trình biên dịch báo lỗi ngay thay vì âm thầm tạo một hàm mới che khuất hàm cha. Hàm dựng của SinhVien chuyển phần chung cho Nguoi(ht, ns) qua danh sách khởi tạo rồi mới khởi tạo phần riêng, đúng thứ tự "dựng phần cơ sở trước, phần riêng sau". Hai getter đánh dấu const vì chúng chỉ đọc, nhờ vậy gọi được cả trên đối tượng hằng.

Cài đặt lớp quản lý QuanLySinhVien

Lớp quản lý là nơi mọi thứ ráp lại. Nó giữ một vector<Nguoi*>, tức một danh sách con trỏ tới lớp cơ sở, và cài toàn bộ các thao tác nghiệp vụ. Vì danh sách lưu Nguoi*, ta vừa chứa được nhiều loại người khác nhau trong tương lai, vừa có điều phối động khi gọi hienThi. Đổi lại, lớp quản lý nhận trách nhiệm quản lý vòng đời các đối tượng mà nó nắm: khi xoá một sinh viên hoặc khi chính nó bị huỷ, nó phải delete từng con trỏ để không rò rỉ bộ nhớ.

class QuanLySinhVien {
private:
vector<Nguoi*> ds; // owns the pointed-to objects

public:
// Add a person (any Nguoi or subclass) to the list.
void them(Nguoi* p) {
ds.push_back(p);
}

// Find by student id; return nullptr if not found.
Nguoi* tim(const string& ms) const {
for (Nguoi* p : ds) {
// Try to view this person as a SinhVien to read its id.
SinhVien* sv = dynamic_cast<SinhVien*>(p);
if (sv && sv->getMaSV() == ms) return p;
}
return nullptr;
}

// Remove by student id; free the object's memory. Return success.
bool xoa(const string& ms) {
for (size_t i = 0; i < ds.size(); ++i) {
SinhVien* sv = dynamic_cast<SinhVien*>(ds[i]);
if (sv && sv->getMaSV() == ms) {
delete ds[i]; // release the heap object
ds.erase(ds.begin() + i); // then drop the pointer slot
return true;
}
}
return false;
}

// Class-wide grade average over all students.
double diemTrungBinh() const {
double tong = 0.0;
int dem = 0;
for (Nguoi* p : ds) {
SinhVien* sv = dynamic_cast<SinhVien*>(p);
if (sv) { tong += sv->getDiem(); ++dem; }
}
return dem == 0 ? 0.0 : tong / dem;
}

// Sort the list by grade, descending. Uses std::sort with a lambda.
void sapXepTheoDiem() {
sort(ds.begin(), ds.end(), [](Nguoi* a, Nguoi* b) {
SinhVien* sa = dynamic_cast<SinhVien*>(a);
SinhVien* sb = dynamic_cast<SinhVien*>(b);
double da = sa ? sa->getDiem() : -1.0;
double db = sb ? sb->getDiem() : -1.0;
return da > db; // higher grade comes first
});
}

// Print every person polymorphically: hienThi() dispatches dynamically.
void inDanhSach() const {
for (Nguoi* p : ds) {
p->hienThi(); // runs SinhVien::hienThi for students
cout << "\n";
}
}

// Destructor frees every owned object: no leak when the manager dies.
~QuanLySinhVien() {
for (Nguoi* p : ds) delete p;
}
};

Lớp này cô đọng cả bốn trụ cột. Đóng gói: danh sách ds để private, bên ngoài chỉ tác động qua các phương thức công khai có kiểm soát. Kế thừa: danh sách chứa Nguoi* nên đặt được mọi lớp con của Nguoi vào. Đa hình: trong inDanhSach, lời gọi p->hienThi() qua con trỏ lớp cơ sở tự chạy phiên bản đúng của lớp thực, không cần một câu if nào kiểm tra loại. Bộ chứa template: vector lo việc co giãn, push_back thêm phần tử, erase bỏ phần tử, và sort với một biểu thức lambda lo việc sắp xếp.

Sở hữu thì phải giải phóng

Lớp quản lý "sở hữu" các đối tượng mà danh sách trỏ tới, nên nó phải giải phóng chúng. Hàm huỷ duyệt và delete từng con trỏ; thao tác xoa cũng delete trước khi gỡ ô khỏi vector. Nếu quên bước này, chương trình rò rỉ bộ nhớ vì các đối tượng cấp phát bằng new không bao giờ được thu hồi. Vì lớp này nắm tài nguyên qua con trỏ thô, theo quy tắc bộ ba ta nên cấm hoặc tự viết hàm dựng sao chép và toán tử gán, tránh hai bản quản lý cùng delete một vùng nhớ.

Một chi tiết kỹ thuật trong các phương thức tìm, xoá, tính trung bình và sắp xếp là dynamic_cast<SinhVien*>. Vì danh sách lưu kiểu cơ sở Nguoi* nhưng các thao tác này cần đọc thuộc tính riêng của sinh viên (mã số, điểm), ta phải ép kiểu xuống lớp con một cách an toàn. dynamic_cast trả về con trỏ hợp lệ nếu đối tượng thực đúng là SinhVien, ngược lại trả về nullptr; nhờ vậy nếu sau này danh sách có lẫn loại người khác, các phép tính điểm vẫn bỏ qua chúng một cách an toàn thay vì hỏng.

Chương trình main minh hoạ

Bây giờ ta ráp các lớp lại thành một chương trình chạy được. Hàm main tạo vài sinh viên trên vùng nhớ động, thêm vào hệ, in danh sách đa hình, tính điểm trung bình, sắp xếp theo điểm rồi in lại để thấy thứ tự đã đổi. Vì QuanLySinhVien tự giải phóng trong hàm huỷ, hàm main không cần delete thủ công.

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;

// (the three classes above are assumed to be included here)

int main() {
QuanLySinhVien lop;

// Create students on the heap; the manager will own and free them.
lop.them(new SinhVien("Nguyen Van An", 2004, "SV001", 7.5));
lop.them(new SinhVien("Tran Thi Binh", 2005, "SV002", 9.0));
lop.them(new SinhVien("Le Hoang Cuong", 2004, "SV003", 6.8));

cout << "== Danh sach ban dau ==\n";
lop.inDanhSach(); // polymorphic display

cout << "\nDiem trung binh lop: "
<< lop.diemTrungBinh() << "\n"; // class average

lop.sapXepTheoDiem(); // sort by grade, descending
cout << "\n== Sau khi sap xep theo diem ==\n";
lop.inDanhSach();

// Search then delete one student by id.
if (lop.tim("SV001"))
cout << "\nTim thay SV001.\n";
lop.xoa("SV001"); // remove and free that object

cout << "\n== Sau khi xoa SV001 ==\n";
lop.inDanhSach();

return 0; // QuanLySinhVien destructor frees the rest, no leak
}

Khi biên dịch và chạy, chương trình in danh sách ban đầu theo thứ tự thêm vào, rồi báo điểm trung bình của ba sinh viên, sau đó in lại danh sách đã sắp giảm dần theo điểm (Bình 9.0 lên đầu, Cường 6.8 xuống cuối), và cuối cùng in danh sách sau khi xoá SV001. Toàn bộ phần in đi qua một lời gọi hienThi duy nhất trong inDanhSach, nhưng nhờ đa hình, mỗi đối tượng tự biết in phần riêng của mình.

Xem chạy từng bước: tạo sinh viên và thêm vào vector

Trace đầu tiên theo dõi giai đoạn dựng danh sách. Mỗi new SinhVien(...) cấp một đối tượng trên bộ nhớ động (heap), còn vector bên trong lop giữ các con trỏ tới chúng. Chú ý cột "tĩnh: Nguoi" trên mỗi thẻ đối tượng: kiểu tĩnh của con trỏ trong danh sách là Nguoi*, nhưng kiểu động của đối tượng thật là SinhVien.

Tạo các SinhVien trên heap và thêm vào vector C++
1int main() {
2 QuanLySinhVien lop;
3 lop.them(new SinhVien("Nguyen Van An", 2004, "SV001", 7.5));
4 lop.them(new SinhVien("Tran Thi Binh", 2005, "SV002", 9.0));
5 lop.them(new SinhVien("Le Hoang Cuong", 2004, "SV003", 6.8));
6 // ds (ben trong lop) gio giu 3 con tro Nguoi*
7}
Ngăn xếp stack
main()
#0QuanLySinhVien lop
dsvector<Nguoi*> { }
Bộ nhớ động heap
(trống)
Tạo đối tượng quản lý lop trên ngăn xếp. Bên trong nó có một vector ds rỗng, chưa giữ con trỏ nào. Heap còn trống.
1/4

Xem chạy từng bước: in danh sách đa hình

Trace thứ hai là trái tim của đồ án: vòng lặp trong inDanhSach duyệt từng Nguoi* và gọi p->hienThi(). Kiểu tĩnh của pNguoi, nhưng vì hienThivirtual, C++ nhìn vào kiểu động SinhVien qua vtable rồi gọi SinhVien::hienThi. Banner ở trên cùng cho thấy lời gọi Nguoi* được phân giải về SinhVien::hienThi thế nào, một dòng lệnh chạy đúng hành vi của ba đối tượng khác nhau.

Duyệt vector gọi hienThi() đa hình C++
1void QuanLySinhVien::inDanhSach() const {
2 for (Nguoi* p : ds) { // p co kieu TINH la Nguoi*
3 p->hienThi(); // virtual -> dieu phoi theo kieu DONG
4 cout << "\n";
5 }
6}
Ngăn xếp stack
inDanhSach()
p→ #1
Bộ nhớ động heap
#1SinhVientĩnh: Nguoi
hoTenNguyen Van An
maSVSV001
diem7.5
#2SinhVientĩnh: Nguoi
hoTenTran Thi Binh
maSVSV002
diem9.0
#3SinhVientĩnh: Nguoi
hoTenLe Hoang Cuong
maSVSV003
diem6.8
Vào vòng lặp. p là con trỏ kiểu TĨNH Nguoi*, lần lượt trỏ tới từng đối tượng trong ds. Lượt đầu p trỏ tới #1 (kiểu động SinhVien).
1/4
Vì sao không cần sửa inDanhSach khi thêm loại người mới

Giả sử về sau ta thêm lớp GiangVien : public Nguoi với cách hienThi riêng. Chỉ cần them một đối tượng GiangVien* vào danh sách, hàm inDanhSach in nó đúng cách mà không sửa một dòng nào, vì p->hienThi() luôn điều phối động tới phiên bản của lớp thực. Mã cũ dùng được cho kiểu mới, đó là lợi ích cốt lõi của đa hình.

Xem chạy từng bước: sắp xếp theo điểm

Trace cuối minh hoạ sapXepTheoDiem. std::sort so sánh từng cặp con trỏ bằng lambda; lambda dùng dynamic_cast để đọc diem rồi xếp điểm cao lên trước. Chỉ thứ tự con trỏ trong ds đổi chỗ, các đối tượng trên heap không hề di chuyển.

Sắp xếp ds theo điểm, giảm dần C++
1void QuanLySinhVien::sapXepTheoDiem() {
2 sort(ds.begin(), ds.end(), [](Nguoi* a, Nguoi* b) {
3 double da = ((SinhVien*)a)->getDiem();
4 double db = ((SinhVien*)b)->getDiem();
5 return da > db; // diem cao len truoc
6 });
7}
Ngăn xếp stack
sapXepTheoDiem()
#0ds (truoc khi sap)
ds[0]→ #1 (7.5)
ds[1]→ #2 (9.0)
ds[2]→ #3 (6.8)
Bộ nhớ động heap
#1SinhVientĩnh: Nguoi
hoTenAn
diem7.5
#2SinhVientĩnh: Nguoi
hoTenBinh
diem9.0
#3SinhVientĩnh: Nguoi
hoTenCuong
diem6.8
Trước khi sắp: ds theo thứ tự thêm vào, ds[0]=An (7.5), ds[1]=Binh (9.0), ds[2]=Cuong (6.8). Các đối tượng thật nằm yên trên heap, ta chỉ sắp lại các con trỏ.
1/3

Nhận xét và hướng mở rộng

Đồ án trên cố tình giữ nhỏ để làm bật cấu trúc. Trong thực tế, ta sẽ muốn mở rộng theo vài hướng, mỗi hướng nối lại với một kỹ thuật đã học hoặc một học phần kế tiếp.

  • Nhập từ bàn phím và đọc ghi tệp. Thay vì gắn cứng dữ liệu trong main, ta thêm phương thức đọc thông tin từ cin và ghi danh sách ra tệp văn bản qua ofstream, đọc lại qua ifstream. Khi đó hệ thống lưu được trạng thái giữa các lần chạy, gần với một ứng dụng thật.
  • Dùng con trỏ thông minh. Quản lý bộ nhớ bằng con trỏ thô buộc ta tự delete, dễ quên và dễ sai. Thay vector<Nguoi*> bằng vector<unique_ptr<Nguoi>> thì mỗi đối tượng tự giải phóng khi bị gỡ khỏi danh sách hoặc khi lớp quản lý bị huỷ, không cần hàm huỷ tự viết. Đây là cách viết C++ hiện đại theo nguyên tắc RAII, an toàn hơn hẳn.
  • Tách giao diện. Có thể trừu tượng hoá phần hiển thị thành một giao diện thuần ảo KetXuatDuoc với hàm thanhChuoi() const = 0, để lớp quản lý in được mọi đối tượng "ket xuat duoc" chứ không chỉ con cháu của Nguoi. Lập trình hướng giao diện giúp hệ thống dễ mở rộng và dễ kiểm thử.
  • Tổng quát hoá bằng template. Lớp quản lý có thể viết thành QuanLy<T> chứa vector<T*>, để tái dùng cho cả sinh viên, giảng viên hay bất kỳ thực thể nào, đúng tinh thần lớp template.
Khuôn mẫu thu nhỏ của phần mềm thực tế

Đồ án "hệ quản lý sinh viên" là một khuôn mẫu thu nhỏ của rất nhiều phần mềm thực tế: một lớp cơ sở trừu tượng hoá phần chung, các lớp con đặc tả phần riêng, và một lớp quản lý giữ tập đối tượng rồi cung cấp các thao tác. Nắm chắc khuôn mẫu này, bạn có thể mở rộng sang quản lý sản phẩm, nhân sự, thiết bị, hay bất kỳ tập thực thể nào, chỉ bằng cách thay phần riêng mà giữ nguyên bộ khung.

Khi nối sang các học phần sau, khuôn mẫu này còn dùng lại được trong đồ án Trí tuệ nhân tạo. Ở đó, một lớp cơ sở MoHinh có thể trừu tượng hoá một mô hình học máy bất kỳ với phương thức ảo duDoan, các lớp con là từng thuật toán cụ thể, và một lớp quản lý giữ tập mô hình để so sánh, chọn ra mô hình tốt nhất theo một độ đo. Bộ khung đóng gói, kế thừa, đa hình và bộ chứa mà ta vừa luyện ở đây chính là nền để xây những hệ thống lớn hơn nhiều.

Bài tập thực hành

Bài tập 1: thêm lớp GiangVien không sửa lớp quản lý

Thêm một lớp GiangVien : public Nguoi với thuộc tính riêng boMon (string) và ghi đè hienThi để in thêm bộ môn. Trong main, them một GiangVien* vào cùng danh sách rồi gọi inDanhSach. Mục tiêu: chứng minh không phải sửa một dòng nào trong QuanLySinhVien.

Lời giải chi tiết

inDanhSach chỉ gọi p->hienThi() qua Nguoi*, mọi lớp con của Nguoi đều in đúng nhờ điều phối động. Chỉ cần khai báo lớp mới ghi đè hienThi và thêm đối tượng vào danh sách.

class GiangVien : public Nguoi {
private:
string boMon;
public:
GiangVien(const string& ht, int ns, const string& bm)
: Nguoi(ht, ns), boMon(bm) {}

void hienThi() const override {
Nguoi::hienThi(); // reuse the shared part
cout << " | Bo mon: " << boMon; // then add own field
}
};

int main() {
QuanLySinhVien truong;
truong.them(new SinhVien("Nguyen Van An", 2004, "SV001", 7.5));
truong.them(new GiangVien("Tran Thi Hoa", 1980, "Tin hoc"));
truong.inDanhSach(); // both print correctly, no change to the manager
}

Ý chính: thêm hành vi mới chỉ cần một lớp con mới ghi đè virtual, phần quản lý không đổi.

Bài tập 2: tìm sinh viên điểm cao nhất

Thêm phương thức SinhVien* sinhVienCaoNhat() const cho QuanLySinhVien, trả về con trỏ tới sinh viên có điểm cao nhất, hoặc nullptr nếu danh sách rỗng.

Lời giải chi tiết

Duyệt ds, dùng dynamic_cast để chỉ xét đối tượng đúng là SinhVien, theo dõi con trỏ có điểm lớn nhất. Trả về con trỏ kiểu SinhVien* để gọi viên đọc được thuộc tính riêng.

SinhVien* sinhVienCaoNhat() const {
SinhVien* best = nullptr;
for (Nguoi* p : ds) {
SinhVien* sv = dynamic_cast<SinhVien*>(p); // skip non-students
if (sv && (best == nullptr || sv->getDiem() > best->getDiem()))
best = sv;
}
return best; // nullptr if no student found
}

Ý chính: dynamic_cast trả nullptr cho đối tượng không phải SinhVien, nên vòng lặp bỏ qua chúng an toàn, và ta luôn so sánh trên kiểu con để đọc getDiem.

Bài tập 3: chuyển sang con trỏ thông minh

Viết lại QuanLySinhVien dùng vector<unique_ptr<Nguoi>> thay cho vector<Nguoi*>. Bỏ hàm huỷ tự viết và sửa them để nhận unique_ptr<Nguoi>.

Lời giải chi tiết

unique_ptr tự delete khi bị gỡ khỏi vector hoặc khi vector bị huỷ, nên không cần hàm huỷ thủ công. Vì unique_ptr không sao chép được, ta move nó vào danh sách. Khi cần con trỏ thô để đọc, dùng .get().

#include <memory>

class QuanLySinhVien {
private:
vector<unique_ptr<Nguoi>> ds; // owns objects automatically (RAII)
public:
void them(unique_ptr<Nguoi> p) {
ds.push_back(move(p)); // transfer ownership into the list
}

void inDanhSach() const {
for (const auto& p : ds) {
p->hienThi(); // p.get() dispatches virtually
cout << "\n";
}
}
// no destructor needed: unique_ptr frees each object on its own
};

int main() {
QuanLySinhVien lop;
lop.them(make_unique<SinhVien>("Nguyen Van An", 2004, "SV001", 7.5));
lop.inDanhSach();
} // all unique_ptr destroyed here, every object freed, no manual delete

Ý chính: con trỏ thông minh chuyển trách nhiệm giải phóng cho hệ thống kiểu, loại bỏ nguy cơ quên delete và lỗi sao chép hai bản cùng huỷ một vùng nhớ.

Tự kiểm tra

1) Vì sao QuanLySinhVien lưu vector con trỏ Nguoi* chứ không phải vector đối tượng SinhVien theo trị?

Vì điều phối động chỉ hoạt động khi gọi qua con trỏ hoặc tham chiếu lớp cơ sở. Lưu Nguoi* cho phép cùng một danh sách chứa nhiều loại người và gọi hienThi đa hình. Lưu vector<SinhVien> theo trị làm mất cả hai khả năng đó, và còn gây cắt xén đối tượng (object slicing) nếu cố gán một lớp con vào.

2) Nếu quên đánh dấu ~Nguoi()virtual, chuyện gì xảy ra khi lớp quản lý delete một Nguoi* trỏ tới SinhVien?

Chỉ phần Nguoi của đối tượng được huỷ, phần riêng của SinhVien (maSV, diem) bị bỏ sót, gây rò rỉ tài nguyên và hành vi không xác định. Hàm huỷ ảo bảo đảm hàm huỷ của lớp con chạy trước khi tới lớp cha.

Ghi nhớ

Đồ án này gói gọn bốn trụ cột trong một chỗ: đóng gói (dữ liệu private/protected), kế thừa (SinhVien là một Nguoi), đa hình (p->hienThi() điều phối động qua vtable), và bộ chứa template (std::vector). Nắm chắc bộ khung này là nắm chắc cách dựng phần lõi của hầu hết phần mềm quản lý thực tế.