Đ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ệu và chung 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ó.
1class ThietBi {2protected:3 int id;4public:5 ThietBi(int i) : id(i) {}6 void batTat() const { cout << "Thiet bi " << id; }7};89// truong hop A: ke thua THUONG -> ThietBi bi nhan doi10class 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};1516// truong hop B: ke thua AO -> ThietBi chia se mot ban17class 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};2223int main() {24 MayDaNangThuong* a = new MayDaNangThuong(7);25 MayDaNangAo* b = new MayDaNangAo(9);26 b->batTat();27 delete a;28 delete b;29}
Đ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 A và B cùng có một phương thức tên chay(), thì
c.chay() sẽ nhập nhằng ngay cả khi A và B 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:
ThietBilà lớp ông bà, có trườngidvà phương thứcbatTat().MayInkế thừaThietBi.MayQuetkế thừaThietBi.MayDaNang(máy đa năng) kế thừa cảMayInlẫnMayQuet.
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 MayIn mà khô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ụ.
Mỗi đường kế thừa (MayDaNang → MayIn → ThietBi và MayDaNang → 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 virtual ở các lớp giữa khi chúng kế thừa lớp ông bà. Khi đó
MayIn và MayQuet 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
}
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 ThietBi là dù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:
virtualcó chi phí (đối tượng cần con trỏ phụ để tìm phần cơ sở chung), nên đừng rảivirtualkhắp nơi cho chắc.
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
ThietBi mà MayDaNang 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.
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). GiangVien và NghienCuu 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, GiangVien và NghienCuu ở 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;
và 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
KetXuatDuoc và LuuDuoc 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, MayDaNang có hai 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() và 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ó có chức năng in, nó chứa một bộ phận in, nhưng nói
nó là 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 → ThietBi và
MayDaNang → 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
- 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?
- 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?
- 3Vì sao Java và C# cho đa kế thừa interface nhưng cấm đa kế thừa lớp?
- 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.
Đ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.