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

Đa thừa kế và vấn đề kim cương

Ở các bài trước, mỗi lớp con chỉ có một lớp cha. C++ còn cho phép một lớp kế thừa từ nhiều lớp cơ sở cùng lúc, gọi là đa thừa kế (multiple inheritance). Công cụ này mạnh nhưng dễ sinh rắc rối khi các lớp cơ sở lại có chung một tổ tiên. Đó là vấn đề kim cương (diamond problem), và lời giải của C++ là kế thừa ảo (virtual inheritance).

Vì sao có nhu cầu đa thừa kế

Trước khi bàn tới rắc rối, hãy hiểu vì sao người ta muốn có đa thừa kế ngay từ đầu.

Thế giới thực không xếp gọn thành một cây phân loại duy nhất. Một chiếc máy đa năng vừa là máy in, vừa là máy quét. Một giảng viên kiêm nghiên cứu vừa thuộc nhóm giảng dạy, vừa thuộc nhóm nghiên cứu. Nếu ngôn ngữ chỉ cho mỗi lớp một cha, ta buộc phải chọn một trục phân loại rồi bỏ trục còn lại, hoặc chép mã sang lớp khác.

Đa thừa kế cho phép một lớp nằm ở giao của nhiều phân loại cùng lúc. Vấn đề chỉ nảy sinh khi các lớp cơ sở có dữ liệuchung tổ tiên. Lúc đó câu hỏi "đối tượng con có bao nhiêu bản dữ liệu của tổ tiên" trở nên nhức nhối, và đó là toàn bộ nội dung của bài này.

Thử ngay: xem vấn đề kim cương chạy từng bước

Trace dưới đây dựng hai đối tượng trên bộ nhớ động để bạn so sánh trực tiếp: đối tượng thứ nhất dùng kế thừa thường (không virtual), đối tượng thứ hai dùng kế thừa ảo. Chưa cần hiểu cú pháp, hãy bấm ▶ Chạy rồi nhìn kỹ phần fields của mỗi đối tượng trên heap và đếm xem mỗi bên có mấy trường id. Con số chênh nhau ấy chính là vấn đề kim cương, và các mục bên dưới sẽ giải thích vì sao nó xảy ra cùng cách khử nó.

Kim cương: nhân đôi phần ông bà vs chia sẻ một bản C++
1class ThietBi {
2protected:
3 int id;
4public:
5 ThietBi(int i) : id(i) {}
6 void batTat() const { cout << "Thiet bi " << id; }
7};
8
9// truong hop A: ke thua THUONG -> ThietBi bi nhan doi
10class MayIn : public ThietBi { public: MayIn(int i):ThietBi(i){} };
11class MayQuet : public ThietBi { public: MayQuet(int i):ThietBi(i){} };
12class MayDaNangThuong : public MayIn, public MayQuet {
13public: MayDaNangThuong(int i):MayIn(i),MayQuet(i){}
14};
15
16// truong hop B: ke thua AO -> ThietBi chia se mot ban
17class MayInV : virtual public ThietBi { public: MayInV(int i):ThietBi(i){} };
18class MayQuetV: virtual public ThietBi { public: MayQuetV(int i):ThietBi(i){} };
19class MayDaNangAo : public MayInV, public MayQuetV {
20public: MayDaNangAo(int i):ThietBi(i),MayInV(i),MayQuetV(i){}
21};
22
23int main() {
24 MayDaNangThuong* a = new MayDaNangThuong(7);
25 MayDaNangAo* b = new MayDaNangAo(9);
26 b->batTat();
27 delete a;
28 delete b;
29}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu main(). Chưa có gì trên bộ nhớ động (heap).
1/6

Đa thừa kế là gì

Cú pháp chỉ là liệt kê các lớp cơ sở, ngăn bởi dấu phẩy:

class C : public A, public B { /* ... */ };

Lớp C lúc này gộp cả phần của A lẫn phần của B. Về mặt bộ nhớ, đối tượng C được xếp thành các mảnh liền nhau: một mảnh cho phần A, một mảnh cho phần B, rồi tới các trường riêng của C. Hình dung đó giải thích luôn vì sao đa thừa kế làm đối tượng phình to. Thứ tự khởi tạo theo thứ tự liệt kê: A dựng trước, rồi B, rồi mới tới thân hàm dựng của C; khi hủy thì ngược lại.

Còn một hệ quả ít ai để ý: nếu AB cùng có một phương thức tên chay(), thì c.chay() sẽ nhập nhằng ngay cả khi AB không hề chung tổ tiên. Trình biên dịch không có cách nào biết bạn muốn bản của A hay bản của B. Bạn phải viết rõ c.A::chay() hoặc c.B::chay(). Vậy nhập nhằng tên là rủi ro cố hữu của đa thừa kế, còn kim cương chỉ là dạng đặc biệt và khó chịu nhất của nó.

Đa thừa kế hữu ích nhất khi các lớp cơ sở là những giao diện thuần ảo độc lập (như Hinh, KetXuatDuoc ở bài lớp trừu tượng), vì khi đó không có dữ liệu nào bị trùng lặp.

Vấn đề kim cương

Rắc rối nảy sinh khi hai lớp cơ sở lại cùng kế thừa từ một lớp ông bà chung. Hãy hình dung một thiết bị văn phòng:

  • ThietBi là lớp ông bà, có trường id và phương thức batTat().
  • MayIn kế thừa ThietBi.
  • MayQuet kế thừa ThietBi.
  • MayDaNang (máy đa năng) kế thừa cả MayIn lẫn MayQuet.

Sơ đồ kế thừa có hình thoi (kim cương), nên mới có tên gọi đó:

ThietBi (ông bà: id, batTat())
/ \
MayIn MayQuet (hai lớp giữa, mỗi lớp kéo theo một phần ThietBi)
\ /
MayDaNang (lớp đáy: kế thừa qua HAI đường)

Nếu khai báo kế thừa thông thường, MayDaNang đi xuống ThietBi theo hai đường (qua MayIn và qua MayQuet), nên nó nhận hai bản sao của phần ông bà: hai trường id riêng biệt và hai bản batTat(). Lời gọi m.batTat() hay m.id trở nên nhập nhằng vì trình biên dịch không biết bạn muốn bản nào.

Hãy nghĩ kỹ vì sao trình biên dịch xử sự như vậy. Nó xây MayInkhông cần biết sau này có ai kế thừa MayIn hay không; nó chỉ biết MayIn là một ThietBi, nên đối tượng MayIn phải chứa trọn vẹn một phần ThietBi. Nó xây MayQuet theo đúng lý lẽ đó, độc lập hoàn toàn. Đến lượt MayDaNang, nó ghép một mảnh MayIn và một mảnh MayQuet lại, mà mỗi mảnh đã tự mang sẵn một ThietBi. Kết quả có hai ThietBi: hệ quả tất yếu, không phải lỗi của trình biên dịch.

Điều tai hại là hai bản id này hoàn toàn tách rời. Sửa một bản, bản kia không đổi. Một chiếc máy đa năng vật lý chỉ có một mã thiết bị, nhưng mô hình trong bộ nhớ lại cho nó hai mã có thể lệch nhau. Mô hình không còn phản ánh đúng thực tế, và lỗi phát sinh từ đây thuộc loại khó truy nhất: dữ liệu tự mâu thuẫn với chính nó.

Nhập nhằng còn kéo theo hệ quả mà sinh viên hay vấp: ép kiểu lên lớp cơ sở cũng nhập nhằng. Viết ThietBi* p = &m; sẽ báo lỗi, vì trình biên dịch không biết nên trỏ vào bản nào. Mất khả năng ép kiểu lên lớp cơ sở thì mất luôn đa hình, tức mất đi chính cái lợi ích mà kế thừa sinh ra để phục vụ.

Vì sao nhân đôi?

Mỗi đường kế thừa (MayDaNang → MayIn → ThietBiMayDaNang → MayQuet → ThietBi) mang theo một bản đầy đủ của lớp ông bà. Hai đường, hai bản. Đối tượng phình to và dữ liệu id bị tách làm hai, dễ lệch nhau.

Kế thừa ảo: chia sẻ một bản duy nhất

Giải pháp là đánh dấu virtualcác lớp giữa khi chúng kế thừa lớp ông bà. Khi đó MayInMayQuet cùng chia sẻ một bản ThietBi duy nhất, thay vì mỗi lớp ôm một bản riêng:

#include <iostream>
using namespace std;

class ThietBi {
protected:
int id;
public:
ThietBi(int i) : id(i) {}
void batTat() const { cout << "Thiet bi " << id << "\n"; }
};

// "virtual" makes ThietBi shared, not duplicated
class MayIn : virtual public ThietBi {
public: MayIn(int i) : ThietBi(i) {}
};
class MayQuet : virtual public ThietBi {
public: MayQuet(int i) : ThietBi(i) {}
};

class MayDaNang : public MayIn, public MayQuet {
public:
// The most-derived class initializes the shared virtual base directly
MayDaNang(int i) : ThietBi(i), MayIn(i), MayQuet(i) {}
};

int main() {
MayDaNang m(7);
m.batTat(); // khong con nhap nhang: chi mot ban ThietBi
}
Điểm hay quên

Với kế thừa ảo, lớp dẫn xuất cuối cùng (ở đây là MayDaNang) phải tự khởi tạo lớp cơ sở ảo ThietBi, vì các lớp trung gian không còn chịu trách nhiệm đó nữa. Để ý trong danh sách khởi tạo của MayDaNang có cả ThietBi(i). Quên dòng này là lỗi quen thuộc khi mới làm.

Vì sao lớp đáy phải khởi tạo lớp cơ sở ảo

Quy tắc này thoạt nghe tùy tiện, nhưng nó là hệ quả logic duy nhất có thể có.

Bản ThietBidùng chung và chỉ được dựng một lần. Vậy ai dựng nó? Nếu để MayIn dựng thì MayQuet sẽ dựng lần thứ hai và ta lại có hai bản, quay về đúng vấn đề cũ. Nếu để MayIn dựng còn MayQuet bỏ qua thì ta thiên vị một lớp giữa mà không có lý do chính đáng, và tệ hơn: hai lớp giữa có thể truyền hai đối số khác nhau cho ThietBi, ai thắng?

Chỉ còn một lời giải nhất quán: giao việc dựng cho lớp biết toàn cảnh, tức lớp dẫn xuất cuối cùng. Chỉ MayDaNang mới nhìn thấy cả hình thoi và biết rằng chỉ có một ThietBi cần dựng. Vì thế C++ quy định: lời gọi hàm dựng cho lớp cơ sở ảo trong các lớp trung gian bị bỏ qua, chỉ lời gọi từ lớp dẫn xuất cuối cùng mới có hiệu lực.

Hệ quả dễ gây bất ngờ. Trong ví dụ trên, MayIn(int i) : ThietBi(i) {} vẫn viết ThietBi(i), nhưng khi ta tạo một MayDaNang thì lời gọi đó không chạy. Nó chỉ chạy khi ta tạo một MayIn đứng riêng, lúc đó MayIn mới là lớp dẫn xuất cuối cùng. Cùng một dòng mã có chạy hay không phụ thuộc vào việc lớp nào là lớp đáy của đối tượng đang được tạo. Đây là chỗ khó chịu nhất của kế thừa ảo, và cũng là lý do người ta khuyên hạn chế dùng nó.

Nếu ThietBi có hàm dựng mặc định thì bạn có thể quên ThietBi(i) mà mã vẫn biên dịch: trình biên dịch lặng lẽ gọi hàm dựng mặc định, id mang giá trị mặc định thay vì giá trị bạn tưởng đã truyền vào. Lỗi này không hề báo, chỉ cho ra số sai lúc chạy.

Chi phí của kế thừa ảo

Kế thừa ảo giải quyết được nhân bản, nhưng nó không miễn phí. Cần biết cái giá để không rải virtual khắp nơi "cho chắc".

Chi phí thứ nhất là bộ nhớ. Vì bản ThietBi là dùng chung, nó không còn nằm cố định tại một khoảng cách biết trước bên trong MayIn: khi MayIn đứng riêng thì phần ThietBi nằm ở chỗ này, khi nó là mảnh của MayDaNang thì phần dùng chung lại nằm chỗ khác. Trình biên dịch không tính sẵn được độ lệch, nên phải nhét thêm vào đối tượng một con trỏ phụ (con trỏ tới bảng lớp cơ sở ảo) để tra vị trí lúc chạy. Đối tượng vì thế to hơn.

Chi phí thứ hai là tốc độ. Mỗi lần truy cập id qua đối tượng có lớp cơ sở ảo, chương trình phải đi một bước gián tiếp: đọc con trỏ phụ, tra độ lệch, rồi mới tới dữ liệu. Với kế thừa thường, độ lệch là hằng số biết trước, truy cập chỉ mất một phép cộng địa chỉ.

Chi phí thứ ba, và nặng nhất, là chi phí nhận thức. Quy tắc "lớp đáy khởi tạo lớp cơ sở ảo" buộc người đọc phải giữ trong đầu toàn bộ cây kế thừa mới suy ra được hàm dựng nào thực sự chạy. Thứ tự khởi tạo cũng đổi: lớp cơ sở ảo được dựng trước tất cả các lớp cơ sở không ảo, bất kể thứ tự liệt kê.

Vì vậy quy tắc thực dụng là: chỉ dùng virtual khi thật sự có hình thoi với lớp cơ sở có dữ liệu. Đừng phòng xa.

Khi nào nên và không nên dùng đa thừa kế

Đa thừa kế dữ liệu thật sự là công cụ mạnh nhưng dễ gây phức tạp, nên nhiều ngôn ngữ hiện đại (Java, C#) chỉ cho đa kế thừa giao diện. Lời khuyên thực dụng cho C++:

  • Nên: đa thừa kế các giao diện thuần ảo (các lớp chỉ có hàm ảo thuần, không có dữ liệu). Không có trường nào để nhân đôi nên không gặp kim cương. Đây là cách C++ diễn đạt "một lớp thực hiện nhiều giao diện".
  • Hạn chế: đa thừa kế các lớp có dữ liệu. Mỗi lớp cơ sở mang theo trạng thái riêng, đối tượng phình to và dễ nhập nhằng.
  • Chỉ dùng kế thừa ảo khi thật sự gặp hình thoi: virtual có chi phí (đối tượng cần con trỏ phụ để tìm phần cơ sở chung), nên đừng rải virtual khắp nơi cho chắc.
Quy tắc nhanh

Nếu định dùng đa thừa kế, hãy tự hỏi: các lớp cơ sở có dữ liệu không, và chúng có chung tổ tiên không? Nếu cả hai đều "không" (toàn giao diện độc lập), cứ yên tâm dùng. Nếu "có", cân nhắc dùng kết hợp ("có một") thay cho kế thừa, hoặc dùng virtual public để khử nhân đôi.

Vì sao Java và C# cấm đa kế thừa lớp

Java, C#, và nhiều ngôn ngữ ra đời sau C++ đều cho một lớp chỉ một lớp cha, nhưng lại cho thực hiện bao nhiêu interface tùy ý. Sự phân biệt này dựa trên một quan sát chính xác: kim cương chỉ gây hại khi có dữ liệu để nhân bản.

Interface trong Java, hay lớp thuần ảo trong C++, không có trường dữ liệu. Nó chỉ là danh sách chữ ký hàm, một lời hứa về hành vi. Nếu một lớp thực hiện hai interface mà cả hai cùng thừa kế từ interface thứ ba, lớp đó vẫn chỉ cần cung cấp một cài đặt cho mỗi hàm. Không có id nào bị nhân đôi, không có trạng thái nào lệch nhau, không có câu hỏi "ai khởi tạo bản dùng chung". Hình thoi vẫn tồn tại trên sơ đồ, nhưng nó vô hại, vì đỉnh kim cương rỗng.

Nói cách khác, các ngôn ngữ đó tách bạch hai thứ mà C++ gộp làm một trong từ khóa class. Chúng cho kế thừa giao diện thoải mái vì cái đó an toàn, và cấm kế thừa cài đặt từ nhiều nguồn vì cái đó sinh kim cương. C++ thì không cấm gì cả: nó đưa đủ công cụ rồi để lập trình viên tự chịu trách nhiệm. Bài học cho ta là hãy tự áp kỷ luật của Java lên chính mình: đa thừa kế giao diện thuần ảo thì thoải mái, còn đa thừa kế lớp có dữ liệu thì dừng lại và hỏi kỹ xem composition có gọn hơn không.

Khi nào nên tránh hẳn

Ba dấu hiệu cho thấy bạn nên bỏ ý định đa thừa kế và tìm cách khác.

Dấu hiệu thứ nhất: bạn phải viết virtual để mã biên dịch được. Nếu thiết kế đẩy bạn tới chỗ buộc phải dùng kế thừa ảo, đó là tín hiệu cây kế thừa đang gánh quá nhiều thứ. Thường thì một trong các lớp cơ sở thực ra chỉ là một bộ phận chứ không phải một loại.

Dấu hiệu thứ hai: bạn thấy mình viết m.MayIn::batTat() để gỡ nhập nhằng. Cách này hợp lệ về cú pháp nhưng chỉ vá triệu chứng: dữ liệu vẫn nhân đôi, ai quên chỉ rõ đường đi sẽ gặp lại lỗi, và người dùng lớp bị buộc phải biết cấu trúc kế thừa bên trong mới gọi được phương thức.

Dấu hiệu thứ ba: các lớp cơ sở có trạng thái chồng lấn nhau về ý nghĩa, ví dụ cả MayIn lẫn MayQuet đều có trường trangThaiNguon. Ngay cả khi không hề có tổ tiên chung, gộp hai lớp này lại đã tạo ra hai biến cùng mô tả một thứ trong thực tế, và chúng sẽ lệch nhau.

Trong cả ba trường hợp, lời giải thường giống nhau: đổi sang composition. Cho MayDaNang chứa một BoPhanIn và một BoPhanQuet, cả hai cùng tham chiếu tới một ThietBiMayDaNang sở hữu. Không nhân bản, không nhập nhằng, không cần virtual, và người đọc mã hiểu ngay cấu trúc chỉ bằng cách nhìn các trường của lớp.

Nguyên nhân gốc của các lỗi kim cương

Mọi lỗi trong bài này, nhập nhằng lời gọi, nhập nhằng ép kiểu, hai trường id lệch nhau, quên khởi tạo lớp cơ sở ảo, đều mọc từ một hiểu lầm chung: tưởng rằng kế thừa chỉ là chia sẻ mã.

Kế thừa chia sẻ cả bố cục bộ nhớ. Khi MayIn kế thừa ThietBi, mỗi đối tượng MayIn vật lý chứa một khối ThietBi bên trong, là dữ liệu thật, chiếm chỗ thật. Giữ hình dung này trong đầu thay vì hình dung mờ nhạt kiểu "kế thừa là dùng lại hàm của cha", thì kim cương trở nên hiển nhiên: ghép hai mảnh, mỗi mảnh chứa một khối, thì có hai khối. Cách chữa cũng hiển nhiên theo: muốn chỉ còn một khối thì phải nói với trình biên dịch rằng khối đó là dùng chung, và đó đúng là điều virtual làm.

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

Bài tập 1: vẽ sơ đồ và khử kim cương

Lớp LaoDong là cơ sở (có trường maNV). GiangVienNghienCuu cùng kế thừa LaoDong. Lớp GVKiemNC (giảng viên kiêm nghiên cứu) kế thừa cả hai. Hãy chỉ ra chỗ nhập nhằng nếu kế thừa thường, rồi viết khai báo dùng virtual public để khắc phục. Lớp nào chịu trách nhiệm khởi tạo phần LaoDong?

Lời giải chi tiết

Sơ đồ là một hình thoi: LaoDong ở đỉnh, GiangVienNghienCuu ở hai bên, GVKiemNC ở đáy. Nếu kế thừa thường, GVKiemNC đi xuống LaoDong qua hai đường nên có hai trường maNV. Truy cập g.maNV hay gọi một phương thức của LaoDong sẽ nhập nhằng.

Cách khắc phục: đánh dấu virtual ở hai lớp giữa để chia sẻ một bản LaoDong. Khi đó lớp đáy GVKiemNC phải tự khởi tạo phần LaoDong.

#include <iostream>
using namespace std;

class LaoDong {
protected:
int maNV;
public:
LaoDong(int m) : maNV(m) {}
void in() const { cout << "Nhan vien " << maNV << "\n"; }
};

// virtual makes LaoDong shared, not duplicated
class GiangVien : virtual public LaoDong {
public: GiangVien(int m) : LaoDong(m) {}
};
class NghienCuu : virtual public LaoDong {
public: NghienCuu(int m) : LaoDong(m) {}
};

class GVKiemNC : public GiangVien, public NghienCuu {
public:
// most-derived class initializes the shared virtual base
GVKiemNC(int m) : LaoDong(m), GiangVien(m), NghienCuu(m) {}
};

int main() {
GVKiemNC g(101);
g.in(); // khong nhap nhang: chi mot ban LaoDong
}

Ý chính: hình thoi sinh ra khi hai lớp giữa chung một tổ tiên có dữ liệu. virtual public gộp về một bản, và lớp dẫn xuất cuối cùng phải khởi tạo phần cơ sở ảo đó.

Bài tập 2: đa thừa kế giao diện (an toàn)

Định nghĩa hai giao diện thuần ảo độc lập: KetXuatDuoc có hàm void in() const = 0;LuuDuoc có hàm void luu() const = 0;. Cho lớp SinhVien thực hiện cả hai giao diện. Giải thích vì sao trường hợp này không gặp vấn đề kim cương.

Lời giải chi tiết

KetXuatDuocLuuDuoc không có dữ liệu (chỉ hàm ảo thuần) và không chung tổ tiên. Vì vậy SinhVien chỉ cần override cả hai hàm là xong, không có phần nào bị nhân đôi. Đây chính là lý do Java và C# cho phép một lớp thực hiện nhiều interface mà vẫn an toàn.

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

class KetXuatDuoc { // interface 1, no data
public:
virtual void in() const = 0;
virtual ~KetXuatDuoc() = default;
};
class LuuDuoc { // interface 2, no data, no shared ancestor
public:
virtual void luu() const = 0;
virtual ~LuuDuoc() = default;
};

class SinhVien : public KetXuatDuoc, public LuuDuoc {
string ten;
public:
SinhVien(string t) : ten(t) {}
void in() const override { cout << "In: " << ten << "\n"; }
void luu() const override { cout << "Luu: " << ten << "\n"; }
};

int main() {
SinhVien sv("An");
sv.in();
sv.luu();
}

Ý chính: đa thừa kế các giao diện thuần ảo độc lập là cách dùng an toàn nhất, vì không có dữ liệu để nhân đôi và không có tổ tiên chung để tạo hình thoi.

Bài tập 3: dự đoán hành vi khi quên virtual

Cho đoạn sau với kế thừa thường (không virtual). Trình biên dịch sẽ báo lỗi gì khi gặp m.batTat();, và cần sửa thế nào để vẫn giữ kế thừa thường mà gọi được?

class ThietBi { public: int id; void batTat() {} };
class MayIn : public ThietBi {};
class MayQuet : public ThietBi {};
class MayDaNang : public MayIn, public MayQuet {};

int main() {
MayDaNang m;
m.batTat(); // ?
}
Lời giải chi tiết

Vì kế thừa thường, MayDaNanghai bản ThietBi (một qua MayIn, một qua MayQuet). Lời gọi m.batTat() nhập nhằng (ambiguous): trình biên dịch không biết chạy bản của MayIn hay của MayQuet, nên báo lỗi.

Nếu muốn giữ kế thừa thường, ta phải chỉ rõ đường đi bằng tên lớp giữa:

m.MayIn::batTat(); // chon ban qua MayIn
m.MayQuet::batTat(); // hoac ban qua MayQuet

Tương tự, m.id phải viết m.MayIn::id hoặc m.MayQuet::id. Cách này chỉ "vá" chỗ nhập nhằng chứ vẫn còn hai bản dữ liệu. Lời giải sạch hơn là dùng kế thừa ảo để chỉ còn một bản ThietBi, khi đó m.batTat()m.id lại rõ ràng.

Bài tập 4: chọn composition thay cho kim cương

Quay lại bài toán máy đa năng. Thay vì cho MayDaNang kế thừa cả MayIn lẫn MayQuet, hãy thiết kế lại bằng composition: MayDaNang là một ThietBi (kế thừa đơn), và có một bộ phận in cùng một bộ phận quét. Giải thích vì sao thiết kế này không bao giờ gặp kim cương.

Lời giải chi tiết

Điểm mấu chốt là nhận ra hai câu hỏi khác nhau đã bị gộp làm một trong thiết kế cũ. "Máy đa năng có phải là một thiết bị không?" Đúng, nó có mã thiết bị, có nút bật tắt, nên kế thừa ThietBi là hợp lý và ta chỉ cần kế thừa đơn. "Máy đa năng có phải là một máy in không?" Câu này mới đáng ngờ. Nó chức năng in, nó chứa một bộ phận in, nhưng nói nó một máy in thì gượng ép. Đúng ra đây là quan hệ có một, không phải là một.

Khi tách đúng như vậy, kim cương biến mất theo đúng nghĩa đen: sơ đồ không còn hình thoi nào cả. MayDaNang chỉ có một đường đi lên ThietBi, nên chỉ có một trường id. Bộ phận in và bộ phận quét là các trường thành viên bình thường, không tham gia vào cây kế thừa nên không mang theo bản ThietBi nào. Không cần virtual, không có chi phí con trỏ phụ, không có quy tắc khởi tạo lạ lùng.

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

class ThietBi {
protected:
int id;
public:
ThietBi(int i) : id(i) {}
void batTat() const { cout << "Thiet bi " << id << "\n"; }
};

class BoPhanIn { // a component, not a base class
public:
void in(const string& tep) const { cout << "In tep " << tep << "\n"; }
};

class BoPhanQuet { // another component
public:
void quet() const { cout << "Dang quet\n"; }
};

// single inheritance: MayDaNang IS-A ThietBi
// composition: it HAS-A printer unit and HAS-A scanner unit
class MayDaNang : public ThietBi {
BoPhanIn boIn;
BoPhanQuet boQuet;
public:
MayDaNang(int i) : ThietBi(i) {}
void inTep(const string& tep) const { boIn.in(tep); } // delegate
void quetTep() const { boQuet.quet(); } // delegate
};

int main() {
MayDaNang m(7);
m.batTat(); // chi mot ban ThietBi, khong nhap nhang
m.inTep("bao-cao.pdf");
m.quetTep();
}

Ý chính: kim cương sinh ra từ việc dùng kế thừa cho một quan hệ vốn là "có một". Sửa đúng quan hệ thì vấn đề tự tan, không cần tới virtual. Đây gần như luôn là lời giải tốt hơn.

Tự kiểm tra

1) Vì sao kế thừa thường lại sinh ra hai bản phần ông bà?

Vì mỗi đường kế thừa từ lớp đáy lên lớp ông bà mang theo một bản đầy đủ của lớp ông bà. Hình thoi có hai đường (MayDaNang → MayIn → ThietBiMayDaNang → MayQuet → ThietBi), nên có hai bản. Kế thừa ảo gộp chúng về một.

2) Trong kế thừa ảo, lớp nào khởi tạo phần cơ sở ảo?

Lớp dẫn xuất cuối cùng (most-derived), ví dụ MayDaNang, vì các lớp trung gian (MayIn, MayQuet) không còn chịu trách nhiệm khởi tạo bản dùng chung nữa. Vì vậy danh sách khởi tạo của MayDaNang phải có cả ThietBi(i).

Câu hỏi tự kiểm

Kiểm tra nhanh: đa thừa kế và kim cương0/4 đúngchưa trả lời
  1. 1Với kế thừa thường (không virtual) trong hình thoi, MayDaNang chứa bao nhiêu bản phần ThietBi?
  2. 2Trong kế thừa ảo, lớp nào chịu trách nhiệm khởi tạo phần cơ sở ảo dùng chung?
  3. 3Vì sao Java và C# cho đa kế thừa interface nhưng cấm đa kế thừa lớp?
  4. 4Cách nào loại bỏ kim cương triệt để nhất mà không cần dùng virtual?

Tóm tắt bài học

Đa thừa kế cho phép một lớp gộp nhiều lớp cơ sở, và nhu cầu này là có thật vì thế giới không xếp gọn thành một cây phân loại duy nhất. Nhưng công cụ này mang theo hai rủi ro. Rủi ro thứ nhất là nhập nhằng tên, xảy ra bất cứ khi nào hai lớp cơ sở có phương thức trùng tên, kể cả khi chúng không liên quan gì nhau; cách gỡ là chỉ rõ đường đi bằng c.A::chay().

Rủi ro thứ hai, nặng hơn nhiều, là vấn đề kim cương: khi hai lớp cơ sở cùng kế thừa một tổ tiên có dữ liệu, lớp đáy nhận hai bản sao của phần tổ tiên. Hai bản này tách rời và có thể lệch nhau, khiến mô hình dữ liệu tự mâu thuẫn. Cả lời gọi phương thức lẫn phép ép kiểu lên lớp cơ sở đều trở nên nhập nhằng.

Lời giải của C++ là kế thừa ảo: đánh dấu virtual public ở các lớp giữa để chúng chia sẻ một bản tổ tiên duy nhất. Cái giá phải trả là đối tượng to hơn (thêm con trỏ phụ), truy cập chậm hơn một bước gián tiếp, và quy tắc khởi tạo trở nên khó nắm: lớp dẫn xuất cuối cùng phải tự khởi tạo lớp cơ sở ảo, còn lời gọi hàm dựng trong các lớp trung gian bị bỏ qua.

Java và C# né toàn bộ chuyện này bằng cách chỉ cho đa kế thừa interface, tức các lớp không có dữ liệu, nên không có gì để nhân bản. Và khi thấy mình cần virtual để cứu một thiết kế, hãy dừng lại: gần như lúc nào cũng có một quan hệ "có một" đang bị nhầm thành "là một". Sửa đúng quan hệ, dùng composition, thì kim cương không hình thành ngay từ đầu.

Ghi nhớ

Đa thừa kế = một lớp gộp nhiều lớp cơ sở. Nếu các lớp cơ sở chung một tổ tiên có dữ liệu thì gặp kim cương: phần ông bà bị nhân đôi và nhập nhằng. Khử bằng virtual public để chia sẻ một bản duy nhất. An toàn nhất là chỉ đa thừa kế các giao diện thuần ảo độc lập.