Trong bài này chúng ta sẽ học đa kế thừa trong C++ (multiple inheritance): một class kế thừa từ nhiều lớp cha cùng lúc. Bài sẽ đi qua cú pháp, thứ tự gọi hàm khởi tạo, cách xử lý khi 2 lớp cha có hàm trùng tên, và “vấn đề kim cương” nổi tiếng cùng cách giải quyết bằng kế thừa ảo virtual.
Bài 19 trong Serie Lập trình C++ từ A tới Z
Đa kế thừa là gì?
Ở bài tính kế thừa trong C++, mỗi lớp con chỉ có một lớp cha. Đa kế thừa là khi một lớp con kế thừa từ hai lớp cha trở lên, và có đủ thuộc tính, phương thức của tất cả các lớp cha đó.
Thư viện của chúng ta bắt đầu có sách điện tử. Một cuốn sách điện tử vừa là một cuốn sách (có tên, tác giả), vừa là một file số (có định dạng PDF hay EPUB, dung lượng). Đa kế thừa cho phép SachDienTu lấy cả hai phần đó.
Không phải ngôn ngữ nào cũng cho phép đa kế thừa class. Java và C# chỉ cho kế thừa một lớp cha, còn C++ thì cho phép, kèm theo một số rắc rối mà bài này sẽ chỉ ra.
Cú pháp đa kế thừa trong C++
Các lớp cha viết sau dấu :, ngăn cách bằng dấu phẩy, mỗi lớp có kiểu kế thừa riêng:
#include <iostream>
#include <string>
class Sach
{
public:
Sach(const std::string& ten, const std::string& tacGia) : ten_(ten), tacGia_(tacGia)
{
std::cout << " Khoi tao Sach\n";
}
void inSach() const { std::cout << ten_ << " - " << tacGia_ << '\n'; }
private:
std::string ten_;
std::string tacGia_;
};
class FileSo
{
public:
FileSo(const std::string& dinhDang, double dungLuongMB) : dinhDang_(dinhDang), dungLuongMB_(dungLuongMB)
{
std::cout << " Khoi tao FileSo\n";
}
void inFile() const { std::cout << "File " << dinhDang_ << ", " << dungLuongMB_ << " MB\n"; }
private:
std::string dinhDang_;
double dungLuongMB_;
};
class SachDienTu : public Sach, public FileSo
{
public:
SachDienTu(const std::string& ten, const std::string& tacGia, const std::string& dinhDang, double mb)
: Sach(ten, tacGia), FileSo(dinhDang, mb)
{
std::cout << " Khoi tao SachDienTu\n";
}
};
int main()
{
std::cout << "Tao sach dien tu:\n";
SachDienTu ebook("Lap trinh C++", "Bjarne Stroustrup", "PDF", 12.5);
ebook.inSach(); // tu Sach
ebook.inFile(); // tu FileSo
return 0;
}
class SachDienTu : public Sach, public FileSo: kế thừa đồng thời 2 lớp cha.- Hàm khởi tạo gọi hàm khởi tạo của từng lớp cha trong danh sách khởi tạo:
Sach(ten, tacGia), FileSo(dinhDang, mb). - Đối tượng
ebookgọi đượcinSach()củaSachlẫninFile()củaFileSo.
Thứ tự gọi hàm khởi tạo trong đa kế thừa
Các hàm khởi tạo lớp cha được gọi theo thứ tự khai báo kế thừa (Sach rồi tới FileSo), sau cùng mới tới thân hàm khởi tạo của lớp con. Hàm hủy thì chạy theo thứ tự ngược lại.
Lưu ý: Thứ tự gọi do dòng class SachDienTu : public Sach, public FileSo quyết định, chứ không phải thứ tự các bạn viết trong danh sách khởi tạo. Viết : FileSo(...), Sach(...) thì Sach vẫn được khởi tạo trước, và g++ cảnh báo -Wreorder. Nếu lớp cha này phụ thuộc vào lớp cha kia, phải sắp đúng thứ tự ở dòng khai báo class.
Hai lớp cha có hàm trùng tên
Rắc rối đầu tiên của đa kế thừa: cả Sach và FileSo đều có hàm inThongTin(). Gọi trên SachDienTu thì gọi của ai?
#include <iostream>
class Sach
{
public:
void inThongTin() const { std::cout << "Thong tin sach\n"; }
};
class FileSo
{
public:
void inThongTin() const { std::cout << "Thong tin file\n"; }
};
class SachDienTu : public Sach, public FileSo {};
int main()
{
SachDienTu ebook;
ebook.inThongTin(); // loi: khong biet goi cua lop nao
return 0;
}
g++ không đoán mà báo request for member 'inThongTin' is ambiguous, kèm danh sách 2 ứng viên. Có 2 cách giải quyết:
#include <iostream>
class Sach
{
public:
void inThongTin() const { std::cout << "Thong tin sach\n"; }
};
class FileSo
{
public:
void inThongTin() const { std::cout << "Thong tin file\n"; }
};
class SachDienTu : public Sach, public FileSo
{
public:
void inThongTin() const // tu dinh nghia lai, goi ro tung lop cha
{
Sach::inThongTin();
FileSo::inThongTin();
}
};
int main()
{
SachDienTu ebook;
ebook.inThongTin();
ebook.Sach::inThongTin(); // hoac goi ro lop cha tu ben ngoai
return 0;
}
Cách tốt nhất là lớp con tự định nghĩa lại inThongTin() và quyết định gọi phần nào của lớp cha nào. Cách viết ebook.Sach::inThongTin() từ bên ngoài cũng chạy, nhưng bắt người dùng class phải biết cấu trúc kế thừa bên trong, nên chỉ dùng khi thật cần.
Vấn đề kim cương (diamond problem)
Rắc rối lớn hơn xảy ra khi 2 lớp cha lại có chung một lớp ông. Giả sử cả Sach và FileSo đều kế thừa TaiLieu (có mã tài liệu):
#include <iostream>
class TaiLieu
{
public:
TaiLieu() { std::cout << " Khoi tao TaiLieu\n"; }
int ma = 0;
};
class Sach : public TaiLieu {};
class FileSo : public TaiLieu {};
class SachDienTu : public Sach, public FileSo {};
int main()
{
std::cout << "Tao SachDienTu:\n";
SachDienTu ebook;
ebook.Sach::ma = 1001;
ebook.FileSo::ma = 2002;
std::cout << "Sach::ma = " << ebook.Sach::ma << ", FileSo::ma = " << ebook.FileSo::ma << '\n';
return 0;
}
Lệnh biên dịch đầu tiên trong ảnh là file ebook.cpp, giống hệt code trên nhưng main chỉ viết ebook.ma = 1001;: g++ báo request for member 'ma' is ambiguous. Bản thứ hai chỉ rõ từng đường kế thừa thì biên dịch được, và kết quả cho thấy vấn đề: TaiLieu được khởi tạo 2 lần, một cuốn sách điện tử có tới 2 mã khác nhau. Đó là vì mỗi lớp cha mang theo một bản TaiLieu riêng:
Sơ đồ kế thừa đúng ý chúng ta muốn có hình thoi, giống viên kim cương, nên gọi là vấn đề kim cương (diamond problem). Nhưng kế thừa thường lại tạo ra 2 nhánh tách rời như hình bên trái.
Kế thừa ảo: giải quyết vấn đề kim cương
Thêm từ khóa virtual khi Sach và FileSo kế thừa TaiLieu, C++ sẽ chỉ tạo một bản TaiLieu dùng chung:
#include <iostream>
class TaiLieu
{
public:
TaiLieu(int ma) : ma_(ma) { std::cout << " Khoi tao TaiLieu, ma = " << ma << '\n'; }
int ma() const { return ma_; }
private:
int ma_;
};
class Sach : virtual public TaiLieu
{
public:
Sach(int ma) : TaiLieu(ma) {}
};
class FileSo : virtual public TaiLieu
{
public:
FileSo(int ma) : TaiLieu(ma) {}
};
class SachDienTu : public Sach, public FileSo
{
public:
SachDienTu(int ma) : TaiLieu(ma), Sach(ma), FileSo(ma) {}
};
int main()
{
std::cout << "Tao SachDienTu:\n";
SachDienTu ebook(1001);
std::cout << "ma = " << ebook.ma() << '\n';
return 0;
}
TaiLieu giờ chỉ khởi tạo 1 lần, và ebook.ma() không còn mơ hồ. Điểm đặc biệt: với lớp cơ sở ảo, lớp con cuối cùng (SachDienTu) phải tự gọi hàm khởi tạo TaiLieu(ma). Lời gọi TaiLieu(ma) trong Sach và FileSo bị bỏ qua khi tạo SachDienTu, vì nếu không thì không biết lấy mã của nhánh nào.
Lưu ý: Chữ virtual phải đặt ở các lớp cha trung gian (Sach, FileSo), không phải ở lớp con cuối cùng. Và phải thêm ngay từ khi thiết kế Sach, FileSo: đợi tới lúc viết SachDienTu mới nhận ra thì phải quay lại sửa các class đã có.
Khi nào nên dùng đa kế thừa?
Đa kế thừa mạnh nhưng làm thiết kế phức tạp hơn nhiều. Kinh nghiệm chung trong C++:
- Nên dùng khi các lớp cha là interface: class chỉ gồm các hàm thuần ảo, không có dữ liệu. Ví dụ
SachDienTukế thừaCoTheTimKiemvàCoTheTaiXuong. Không có dữ liệu thì không có vấn đề kim cương. Phần interface có trong bài tính trừu tượng trong C++. - Cân nhắc thay bằng thành phần (composition) khi lớp cha có nhiều dữ liệu:
SachDienTukế thừaSachvà chứa một đối tượngFileSolàm thuộc tính. Sách điện tử có một file, cách nói này cũng hợp lý. - Tránh cây kế thừa hình kim cương nếu có thể. Thư viện chuẩn C++ chỉ dùng nó ở vài chỗ như
std::iostreamkế thừa cảstd::istreamlẫnstd::ostream.
Bài tập
- Viết lớp
ThietBiDien(công suất) vàThietBiMang(địa chỉ IP), rồi lớpCameraIPkế thừa cả 2. In thứ tự gọi hàm khởi tạo và hàm hủy. - Thêm hàm
inThongTin()vào cả 2 lớp cha ở bài 1, rồi viếtinThongTin()choCameraIPgọi cả 2. - Cho
ThietBiDienvàThietBiMangcùng kế thừaThietBi(có số serial). Insizeof(CameraIP)khi kế thừa thường và khi kế thừa ảo, giải thích sự khác nhau. - Viết lại
SachDienTutheo kiểu thành phần: kế thừaSachvà có thuộc tính kiểuFileSo. So sánh với cách đa kế thừa.
Lời giải sẽ có trong bài tổng hợp bài tập OOP C++ nhé.
Kết
Đa kế thừa trong C++ cho phép một class có đặc điểm của nhiều lớp cha, nhưng đi kèm 2 rắc rối: hàm trùng tên giữa các lớp cha và vấn đề kim cương khi các lớp cha có chung lớp ông. Trùng tên thì tự định nghĩa lại hàm ở lớp con, còn kim cương thì dùng kế thừa ảo virtual ở các lớp trung gian. Quy tắc an toàn nhất: đa kế thừa từ các interface, còn dữ liệu thì ưu tiên thành phần.
Bài tiếp theo chúng ta sẽ học tính chất quan trọng nhất của OOP: tính đa hình trong C++ với hàm ảo. Nếu thấy bài viết có ích, hãy chia sẻ và tham gia hội Nghiện lập trình để cùng thảo luận nhé.







