Trong bài này chúng ta sẽ học tính đa hình trong C++ (polymorphism): hàm ảo virtual, từ khóa override và final, cách hàm ảo hoạt động bên dưới, hàm hủy ảo, và lỗi “cắt lát” đối tượng. Đa hình là thứ khiến OOP thật sự mạnh: một đoạn code viết cho lớp cha chạy đúng với mọi lớp con, kể cả những lớp con viết sau này.
Bài 20 trong Serie Lập trình C++ từ A tới Z
Tính đa hình trong C++ là gì?
Đa hình (polymorphism) nghĩa là “nhiều hình thái”: cùng một lời gọi hàm, nhưng mỗi loại đối tượng phản ứng theo cách riêng của nó.
Ví dụ đời thường: chiếc điều khiển TV có nút “Phát”. Bấm nút đó với đầu DVD thì phát đĩa, với loa bluetooth thì phát nhạc, với TV thì phát kênh đang chọn. Người bấm chỉ cần biết “bấm Phát”, không cần biết đang điều khiển thiết bị gì.
C++ có 2 loại đa hình:
- Đa hình lúc biên dịch (compile-time): nạp chồng hàm, nạp chồng toán tử, template. Trình biên dịch quyết định gọi hàm nào ngay lúc biên dịch, dựa vào kiểu đối số. Chúng ta đã gặp nạp chồng hàm ở bài về hàm.
- Đa hình lúc chạy (runtime): hàm ảo
virtual. Quyết định gọi hàm nào chỉ có lúc chương trình chạy, dựa vào kiểu thật của đối tượng. Đây là nội dung chính của bài này.
Vấn đề khi chưa có hàm ảo
Ở bài tính kế thừa trong C++, lớp con Sach ghi đè được hàm inThongTin() của lớp cha. Giờ thư viện muốn để sách và tạp chí chung một danh sách để duyệt và in:
#include <iostream>
#include <string>
class TaiLieu
{
public:
TaiLieu(const std::string& ten) : ten_(ten) {}
void inThongTin() const { std::cout << "Tai lieu: " << ten_ << '\n'; }
protected:
std::string ten_;
};
class Sach : public TaiLieu
{
public:
Sach(const std::string& ten, const std::string& tacGia) : TaiLieu(ten), tacGia_(tacGia) {}
void inThongTin() const { std::cout << "Sach: " << ten_ << " - " << tacGia_ << '\n'; }
private:
std::string tacGia_;
};
class TapChi : public TaiLieu
{
public:
TapChi(const std::string& ten, int soKy) : TaiLieu(ten), soKy_(soKy) {}
void inThongTin() const { std::cout << "Tap chi: " << ten_ << " so " << soKy_ << '\n'; }
private:
int soKy_;
};
int main()
{
Sach s("Lap trinh C++", "Bjarne Stroustrup");
TapChi t("Tap chi Dien tu", 125);
TaiLieu* danhSach[] = {&s, &t};
for (TaiLieu* p : danhSach)
p->inThongTin();
return 0;
}
Con trỏ TaiLieu* trỏ được tới Sach và TapChi vì sách là một tài liệu. Nhưng cả 2 dòng đều gọi bản inThongTin() của TaiLieu, mất hết tác giả và số kỳ. Lý do: trình biên dịch chỉ thấy p có kiểu TaiLieu*, nên gọi hàm của TaiLieu, không quan tâm p thực sự đang trỏ vào đối tượng gì.
Hàm ảo virtual và từ khóa override
Thêm virtual trước hàm ở lớp cha, và override sau hàm ở lớp con:
#include <iostream>
#include <string>
class TaiLieu
{
public:
TaiLieu(const std::string& ten) : ten_(ten) {}
virtual ~TaiLieu() = default;
virtual void inThongTin() const { std::cout << "Tai lieu: " << ten_ << '\n'; }
protected:
std::string ten_;
};
class Sach : public TaiLieu
{
public:
Sach(const std::string& ten, const std::string& tacGia) : TaiLieu(ten), tacGia_(tacGia) {}
void inThongTin() const override { std::cout << "Sach: " << ten_ << " - " << tacGia_ << '\n'; }
private:
std::string tacGia_;
};
class TapChi : public TaiLieu
{
public:
TapChi(const std::string& ten, int soKy) : TaiLieu(ten), soKy_(soKy) {}
void inThongTin() const override { std::cout << "Tap chi: " << ten_ << " so " << soKy_ << '\n'; }
private:
int soKy_;
};
int main()
{
Sach s("Lap trinh C++", "Bjarne Stroustrup");
TapChi t("Tap chi Dien tu", 125);
TaiLieu* danhSach[] = {&s, &t};
for (TaiLieu* p : danhSach)
p->inThongTin();
return 0;
}
Vòng lặp main không đổi một chữ, nhưng giờ mỗi đối tượng in đúng thông tin của mình. Đó chính là đa hình: code duyệt danh sách chỉ biết TaiLieu*, còn việc in thế nào do từng class tự quyết.
virtualở lớp cha: báo rằng hàm này được phép ghi đè, và phải chọn theo kiểu thật lúc chạy.overrideở lớp con: báo rằng hàm này ghi đè một hàm ảo của lớp cha. Không bắt buộc, nhưng nên luôn viết, lý do ở ngay dưới.virtual ~TaiLieu() = default;: hàm hủy ảo, sẽ giải thích ở mục riêng.
Vì sao luôn viết override?
Chữ ký hàm ở lớp con phải khớp hoàn toàn với lớp cha, kể cả chữ const. Lệch một chút là thành hàm mới, không ghi đè gì cả, và đa hình âm thầm không chạy. override bắt lỗi này lúc biên dịch:
#include <iostream>
#include <string>
class TaiLieu
{
public:
virtual ~TaiLieu() = default;
virtual void inThongTin() const { std::cout << "Tai lieu\n"; }
};
class Sach : public TaiLieu
{
public:
void inThongTin() override { std::cout << "Sach\n"; } // quen const
};
int main()
{
Sach s;
TaiLieu* p = &s;
p->inThongTin();
return 0;
}
inThongTin() của Sach thiếu const nên không khớp với hàm của lớp cha. Có override, g++ báo ngay marked 'override', but does not override. Không có override, chương trình vẫn biên dịch, và p->inThongTin() lặng lẽ in “Tai lieu” thay vì “Sach”.
Từ khóa final
final dùng để chặn ghi đè tiếp: void inThongTin() const final thì lớp con của Sach không ghi đè hàm này được nữa. Viết class Sach final : public TaiLieu thì không ai kế thừa Sach được. Dùng khi các bạn muốn chốt một thiết kế, tránh bị lớp con làm sai.
Hàm ảo hoạt động thế nào?
Mỗi class có hàm ảo được trình biên dịch tạo một bảng hàm ảo (vtable) chứa địa chỉ các hàm ảo của class đó. Mỗi đối tượng có thêm một con trỏ ẩn vptr trỏ tới vtable của class thật của nó:
Khi gọi p->inThongTin(), chương trình đi theo vptr của đối tượng tới vtable, lấy ra địa chỉ hàm đúng rồi mới gọi. Cái giá phải trả là mỗi đối tượng to thêm một con trỏ (8 byte trên máy 64-bit) và mỗi lần gọi hàm ảo chậm hơn một chút vì phải tra bảng. Với chương trình bình thường, chi phí này không đáng kể.
Hàm hủy ảo (virtual destructor)
Khi xóa đối tượng lớp con thông qua con trỏ lớp cha, hàm hủy của lớp cha bắt buộc phải là hàm ảo. Thử quên:
#include <iostream>
class TaiLieu
{
public:
~TaiLieu() { std::cout << " Huy TaiLieu\n"; } // khong virtual
virtual void inThongTin() const { std::cout << "Tai lieu\n"; }
};
class SachDienTu : public TaiLieu
{
public:
SachDienTu() : boDem_(new char[1024]) {}
~SachDienTu() { delete[] boDem_; std::cout << " Huy SachDienTu, giai phong bo dem\n"; }
private:
char* boDem_;
};
int main()
{
TaiLieu* p = new SachDienTu();
std::cout << "delete p:\n";
delete p;
return 0;
}
Chỉ có hàm hủy của TaiLieu chạy, hàm hủy của SachDienTu bị bỏ qua, nên 1024 byte bộ đệm không bao giờ được giải phóng. Đây là rò rỉ bộ nhớ (memory leak), và theo chuẩn C++ thì đây là undefined behavior. g++ có cảnh báo -Wdelete-non-virtual-dtor khi bật -Wall.
Lưu ý: Class nào có ít nhất một hàm ảo, tức là được thiết kế để làm lớp cha cho đa hình, thì luôn khai báo virtual ~TenClass() = default;. Đây là quy tắc mà gần như mọi bộ chuẩn code C++ đều yêu cầu.
Phần new, delete và rò rỉ bộ nhớ được giải thích kỹ ở bài cấp phát động. Trong code C++ hiện đại, các bạn sẽ dùng smart pointer thay cho new/delete trực tiếp như ví dụ cuối bài.
Lỗi cắt lát đối tượng (object slicing)
Đa hình chỉ hoạt động qua con trỏ hoặc tham chiếu. Truyền đối tượng lớp con theo kiểu giá trị vào hàm nhận lớp cha thì phần riêng của lớp con bị “cắt” mất:
#include <iostream>
#include <string>
class TaiLieu
{
public:
TaiLieu(const std::string& ten) : ten_(ten) {}
virtual ~TaiLieu() = default;
virtual void inThongTin() const { std::cout << "Tai lieu: " << ten_ << '\n'; }
protected:
std::string ten_;
};
class Sach : public TaiLieu
{
public:
Sach(const std::string& ten, const std::string& tacGia) : TaiLieu(ten), tacGia_(tacGia) {}
void inThongTin() const override { std::cout << "Sach: " << ten_ << " - " << tacGia_ << '\n'; }
private:
std::string tacGia_;
};
void inQuaGiaTri(TaiLieu t) { t.inThongTin(); } // nhan ban sao kieu TaiLieu
void inQuaThamChieu(const TaiLieu& t) { t.inThongTin(); }
int main()
{
Sach s("Lap trinh C++", "Bjarne Stroustrup");
inQuaGiaTri(s);
inQuaThamChieu(s);
return 0;
}
inQuaGiaTri nhận một bản sao kiểu TaiLieu. Khi sao chép, chỉ phần TaiLieu của sách được chép sang, phần tác giả bị bỏ, và bản sao đó thực sự là một TaiLieu, nên dù có virtual vẫn gọi bản của lớp cha. Truyền bằng tham chiếu const TaiLieu& thì không sao chép gì, đa hình chạy đúng.
Quy tắc truyền tham số ở bài về hàm áp dụng ở đây: đối tượng thì truyền const&.
Áp dụng vào chương trình thư viện
Mỗi loại tài liệu có quy định mượn khác nhau: sách 14 ngày, tạp chí 3 ngày, sách tham khảo chỉ đọc tại chỗ. Đây là việc điển hình của đa hình:
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class TaiLieu
{
public:
TaiLieu(const std::string& ten) : ten_(ten) {}
virtual ~TaiLieu() = default;
virtual int soNgayMuonToiDa() const { return 14; }
virtual void inThongTin() const { std::cout << ten_; }
protected:
std::string ten_;
};
class Sach : public TaiLieu
{
public:
using TaiLieu::TaiLieu;
void inThongTin() const override { std::cout << "[Sach] " << ten_; }
};
class TapChi : public TaiLieu
{
public:
using TaiLieu::TaiLieu;
int soNgayMuonToiDa() const override { return 3; }
void inThongTin() const override { std::cout << "[Tap chi] " << ten_; }
};
class SachThamKhao : public TaiLieu
{
public:
using TaiLieu::TaiLieu;
int soNgayMuonToiDa() const override { return 0; } // chi doc tai cho
void inThongTin() const override { std::cout << "[Tham khao] " << ten_; }
};
int main()
{
std::vector<std::unique_ptr<TaiLieu>> kho;
kho.push_back(std::make_unique<Sach>("Lap trinh C++"));
kho.push_back(std::make_unique<TapChi>("Tap chi Dien tu so 125"));
kho.push_back(std::make_unique<SachThamKhao>("Tu dien Anh Viet"));
for (const auto& tl : kho)
{
tl->inThongTin();
int ngay = tl->soNgayMuonToiDa();
if (ngay > 0)
std::cout << ": muon toi da " << ngay << " ngay\n";
else
std::cout << ": chi doc tai cho\n";
}
return 0;
}
Mấy điểm mới trong ví dụ này:
using TaiLieu::TaiLieu;: lớp con dùng lại luôn hàm khởi tạo của lớp cha, khỏi phải viết lại khi lớp con không có thuộc tính riêng.std::vector<std::unique_ptr<TaiLieu>>: một danh sách co giãn được (std::vector) chứa các con trỏ thông minh (std::unique_ptr) tới tài liệu. Con trỏ thông minh tựdeleteđối tượng khi danh sách bị hủy, nên không cần tự giải phóng. Hai kiểu này có bài riêng trong serie, ở đây các bạn chỉ cần dùng theo mẫu.
Muốn thêm loại tài liệu mới, ví dụ BanDo (bản đồ, mượn 7 ngày), chỉ cần viết thêm một class kế thừa TaiLieu và ghi đè 2 hàm. Vòng lặp trong main không phải sửa gì. Đây là sức mạnh lớn nhất của đa hình: thêm tính năng mới mà không phải sửa code cũ.
Bài tập
- Viết lớp cha
HinhHoccó hàm ảodienTich(), và 3 lớp conHinhTron,HinhVuong,HinhTamGiac. Lưu chung trong một mảng con trỏHinhHoc*rồi in tổng diện tích. - Bỏ chữ
virtualở bài 1 và chạy lại. Kết quả thay đổi thế nào, vì sao? - Thêm class
BanDovào chương trình thư viện ở trên với thời hạn mượn 7 ngày. Bạn phải sửa những dòng nào trongmain? - In
sizeof(TaiLieu)khi có và khi không có hàm ảo. Giải thích chênh lệch.
Lời giải sẽ có trong bài tổng hợp bài tập OOP C++ nhé.
Kết
Tính đa hình trong C++ cho phép viết code làm việc với lớp cha nhưng chạy đúng hành vi của từng lớp con. Ba quy tắc cần nhớ: hàm muốn đa hình thì virtual ở lớp cha và override ở lớp con, class có hàm ảo thì phải có hàm hủy ảo, và chỉ dùng đa hình qua con trỏ hoặc tham chiếu để tránh cắt lát đối tượng.
Muốn tra cứu chi tiết về hàm ảo virtual, các bạn xem thêm tài liệu chuẩn trên cppreference.
Bài trước là đa kế thừa trong C++, bài tiếp theo chúng ta sẽ đẩy đa hình lên một bước nữa với hàm thuần ảo, lớp trừu tượng và interface: tính trừu tượng trong C++. Thấy bài viết có ích, các bạn chia sẻ cho bạn bè cùng học nhé.








