Composition: quan hệ "có một"
Ngoài kế thừa ("là một"), cách ghép lớp phổ biến hơn là composition: một đối tượng chứa đối tượng khác làm thành phần. Xe có một động cơ; tài khoản có một chủ sở hữu. Thành phần con sinh ra và mất đi cùng đối tượng cha.
Hãy hình dung bạn viết phần mềm quản lý một hãng taxi. Mỗi chiếc xe cần một động cơ, một
đồng hồ tính tiền, một bộ định vị. Nếu nhét toàn bộ mã của động cơ, đồng hồ và định vị vào
chung một lớp Xe, lớp đó phình to, mọi sửa đổi nhỏ đều rủi ro, và ta không thể tái dùng
phần động cơ cho một loại xe khác. Cách gọn hơn là tách mỗi bộ phận thành một lớp riêng rồi
cho Xe chứa chúng làm thành phần. Đó chính là composition: lắp một đối tượng lớn từ
những đối tượng nhỏ có sẵn, giống như lắp một chiếc xe từ các cụm linh kiện rời.
Sinh viên mới học hướng đối tượng thường bị kế thừa hớp hồn. Kế thừa nghe oai, có sơ đồ
cây đẹp, có đa hình, có virtual. Nhưng nếu bạn mở mã nguồn của bất kỳ hệ thống lớn nào
đang chạy trong thực tế, bạn sẽ thấy composition xuất hiện dày đặc hơn kế thừa rất nhiều
lần. Lý do không phải vì kế thừa xấu, mà vì trong đời sống, quan hệ "có một" đơn giản là
phổ biến hơn quan hệ "là một" rất nhiều. Bài này nói về công cụ mà bạn sẽ dùng hàng ngày,
chứ không phải công cụ dùng để khoe.
Ý tưởng cốt lõi
class Xe { ... DongCo dc; ... },Xecó mộtDongConằm ngay bên trong nó.- Tạo
XethìDongCobên trong cũng được tạo; hủyXethìDongCocũng hủy. - Lớp cha ủy nhiệm việc cho thành phần:
Xe::chay()gọidc.noMay().
Mã nguồn
#include <iostream>
using namespace std;
class DongCo {
public:
int maLuc;
void noMay() { cout << "Vroom " << maLuc; }
};
class Xe {
public:
DongCo dc; // Xe CÓ MỘT DongCo (composition)
void chay() { dc.noMay(); } // ủy nhiệm cho thành phần
};
int main() {
Xe x;
x.dc.maLuc = 150;
x.chay(); // Vroom 150
}
Thử ngay: xem composition chạy từng bước
1class DongCo {2public:3 int maLuc;4 void noMay() { cout << "Vroom " << maLuc; }5};6class Xe {7public:8 DongCo dc;9 void chay() { dc.noMay(); }10};1112int main() {13 Xe x;14 x.dc.maLuc = 150;15 x.chay();16}
Composition khác kế thừa ở đâu
Hai cách ghép lớp hay bị nhầm. Kế thừa trả lời câu hỏi "là một": OToDien là một
PhuongTien, nên nó thừa hưởng mọi trường và phương thức của PhuongTien. Composition
trả lời câu hỏi "có một": Xe có một DongCo, nên nó giữ DongCo như một trường nằm
bên trong.
Phép thử nhanh: đặt hai vật vào hai câu "A là một B" và "A có một B", câu nào nghe hợp lý
thì chọn quan hệ đó. "Ô tô là một động cơ" nghe sai, còn "ô tô có một động cơ" nghe đúng,
vậy dùng composition. Chọn nhầm sẽ để lại rắc rối: nếu cho Xe kế thừa DongCo, chiếc xe
sẽ tự có trực tiếp trường maLuc như thể bản thân nó là động cơ, và ngày muốn gắn hai động
cơ hay thay động cơ khác thì thiết kế bó cứng.
Phép thử chặt hơn: thay thế được không
Phép thử bằng câu tiếng Việt rất tiện, nhưng nó dựa vào cảm nhận ngôn ngữ và đôi khi đánh lừa. Phép thử chặt hơn là: ở mọi chỗ đang dùng B, tôi có thể đưa A vào thay mà mọi thứ vẫn đúng không? Nếu có, A thật sự là một B, kế thừa hợp lệ. Nếu không, dù câu "A là một B" nghe xuôi tai đến đâu, kế thừa vẫn sai.
Ví dụ kinh điển: HinhVuong có nên kế thừa HinhChuNhat không. Về hình học, hình vuông
đúng là một hình chữ nhật, câu nói nghe hoàn toàn xuôi. Nhưng hãy áp phép thử. Giả sử
HinhChuNhat có hai hàm datChieuRong(w) và datChieuCao(h) độc lập nhau. Một đoạn mã
làm việc với HinhChuNhat có quyền đặt rộng bằng 3, cao bằng 5, rồi mong đợi diện tích
bằng 15. Bây giờ đưa vào một HinhVuong: đặt rộng bằng 3 thì cao cũng thành 3, đặt cao
bằng 5 thì rộng cũng thành 5, diện tích ra 25 chứ không phải 15. Đoạn mã cũ vỡ dù nó không
làm gì sai. Vậy HinhVuong không thay thế được HinhChuNhat, và kế thừa ở đây là lựa
chọn tồi.
Kế thừa không nói về việc hai thứ giống nhau, mà nói về việc một thứ có dùng thay được cho thứ kia hay không. Đây là ràng buộc mạnh, và ít cặp lớp thỏa mãn nó hơn ta tưởng.
Kế thừa buộc bạn thừa hưởng cả những thứ không muốn
Một lý do nữa khiến kế thừa dễ gây hại: nó là tất cả hoặc không gì cả. Khi Xe kế thừa
DongCo, Xe nhận toàn bộ giao diện công khai của DongCo, kể cả những hàm vô nghĩa
với một chiếc xe. Nếu DongCo có hàm thayDauNhot() thì Xe cũng có luôn
x.thayDauNhot() mà ta không hề muốn phơi ra.
Composition thì có tính chọn lọc: Xe chứa DongCo nhưng tự quyết định phơi ra cái
gì. Để DongCo dc ở phần private và chỉ mở một hàm chay() công khai, thì thế giới bên
ngoài chỉ thấy chay(), không thấy động cơ đâu cả. Kế thừa tạo mối buộc chặt và công khai;
composition tạo mối buộc lỏng và có thể giấu đi. Trong thiết kế phần mềm, buộc lỏng gần như
lúc nào cũng tốt hơn.
Khởi tạo thành phần qua danh sách khởi tạo
Ở ví dụ đầu bài, DongCo không có hàm dựng nên C++ tự tạo nó bằng hàm dựng mặc định. Khi
thành phần cần một giá trị ngay lúc sinh ra, ví dụ DongCo bắt buộc phải biết mã lực, ta
truyền giá trị cho nó qua danh sách khởi tạo của lớp cha.
#include <iostream>
using namespace std;
class DongCo {
public:
int maLuc;
DongCo(int ml) : maLuc(ml) { // hàm dựng có tham số
cout << "Tao dong co " << maLuc << "\n";
}
void noMay() { cout << "Vroom " << maLuc << "\n"; }
};
class Xe {
public:
DongCo dc; // thành phần không có hàm dựng mặc định
Xe(int ml) : dc(ml) { // KHỞI TẠO dc trong danh sách khởi tạo
cout << "Lap rap xe xong\n";
}
void chay() { dc.noMay(); }
};
int main() {
Xe x(150); // 150 được chuyển tiếp xuống dc
x.chay();
}
Cú pháp Xe(int ml) : dc(ml) nghĩa là "trước khi thân hàm dựng của Xe chạy, hãy dựng
thành phần dc bằng đối số ml". Thành phần luôn được dựng xong trước khi thân hàm
dựng cha bắt đầu. Nếu thành phần không có hàm dựng mặc định mà ta quên khởi tạo nó trong
danh sách, chương trình sẽ không biên dịch được. Một điểm dễ quên: các thành viên được dựng
theo thứ tự khai báo trong lớp, không theo thứ tự viết trong danh sách khởi tạo.
Vì sao phải dùng danh sách khởi tạo mà không gán trong thân hàm dựng? Câu hỏi này đáng hỏi,
vì nhiều người mới học viết Xe(int ml) { dc.maLuc = ml; } và thấy nó vẫn chạy.
Lý do nằm ở một quy tắc cứng của C++: mọi thành viên đều đã được dựng xong trước khi thân
hàm dựng chạy dòng đầu tiên. Nếu bạn không nói gì trong danh sách khởi tạo, trình biên
dịch vẫn phải dựng dc bằng hàm dựng mặc định. Vậy khi bạn gán dc.maLuc = ml trong thân
hàm, đã có hai thao tác diễn ra: dựng dc một lần rồi ghi đè lên nó. Với int thì lãng
phí này không đáng kể, nhưng với một thành phần nặng như std::string hay một vector lớn,
bạn đang trả giá cho một lần dựng vô ích.
Và khi DongCo không có hàm dựng mặc định, như trong ví dụ trên, gán trong thân hàm
không còn là chuyện lãng phí mà là chuyện không biên dịch được: trình biên dịch buộc
phải dựng dc trước khi vào thân hàm, mà nó không biết dựng bằng cách nào. Cùng lý lẽ đó
áp cho thành viên hằng: một trường const int soKhung; chỉ được đặt giá trị đúng một lần
lúc dựng, nên bắt buộc phải nằm trong danh sách khởi tạo.
Còn thứ tự dựng theo khai báo, vì sao ngôn ngữ chọn vậy? Thứ tự hủy phải ngược thứ tự dựng, mà hàm hủy chỉ có một cho cả lớp, trong khi lớp có thể có nhiều hàm dựng với nhiều danh sách khởi tạo viết theo thứ tự khác nhau. Neo thứ tự vào khai báo trong lớp, thứ duy nhất cố định, là lời giải nhất quán duy nhất. Vì vậy hãy viết danh sách khởi tạo đúng theo thứ tự khai báo, để mã đọc lên khớp với thứ tự chạy thật.
Khi nào ưu tiên composition
Kinh nghiệm chung trong thiết kế hướng đối tượng là ưu tiên composition hơn kế thừa. Composition ghép các lớp một cách lỏng, dễ thay thế và ít ràng buộc. Khi bộ phận thay đổi, lớp cha chỉ cần gọi lại phương thức của bộ phận, không bị kéo theo cả một cây kế thừa. Ngược lại, kế thừa buộc lớp con phụ thuộc chặt vào chi tiết của lớp cha, và sửa lớp cha có thể làm hỏng mọi lớp con.
#include <iostream>
using namespace std;
class DongCoDien {
public:
void noMay() { cout << "Chay dien, khong on\n"; }
};
class DongCoXang {
public:
void noMay() { cout << "Chay xang, no may\n"; }
};
class Xe {
public:
DongCoXang dc; // đổi sang DongCoDien chỉ cần sửa một dòng
void chay() { dc.noMay(); }
};
int main() {
Xe x;
x.chay();
}
Chọn kế thừa khi và chỉ khi lớp con thật sự là một biến thể của lớp cha và cần dùng thay thế được cho lớp cha. Trong phần lớn trường hợp còn lại, khi một đối tượng chỉ chứa đối tượng khác như một bộ phận, hãy chọn composition.
Nhưng ví dụ trên vẫn còn một hạn chế: muốn đổi từ động cơ xăng sang động cơ điện, ta phải sửa mã và biên dịch lại. Kế thừa cũng vậy, thậm chí còn cứng hơn: kiểu của lớp cha được chốt lúc biên dịch, không cách nào đổi. Composition có một lợi thế mà kế thừa vĩnh viễn không có: thay thành phần lúc chương trình đang chạy.
Đổi thành phần lúc chạy
Đây là ưu thế lớn nhất của composition, và nó là lý do thật sự khiến người ta nói "ưu tiên composition hơn kế thừa".
Ý tưởng: thay vì chứa một DongCoXang cụ thể, Xe chứa một con trỏ tới DongCo, mà
DongCo là một lớp cơ sở trừu tượng. Lúc chạy, ta có thể tháo động cơ này ra và lắp động
cơ khác vào, chỉ bằng một lời gọi hàm.
#include <iostream>
#include <memory>
#include <utility>
using namespace std;
class DongCo { // abstract component interface
public:
virtual void noMay() const = 0;
virtual ~DongCo() = default;
};
class DongCoXang : public DongCo {
public:
void noMay() const override { cout << "Chay xang, no may\n"; }
};
class DongCoDien : public DongCo {
public:
void noMay() const override { cout << "Chay dien, khong on\n"; }
};
class Xe {
unique_ptr<DongCo> dc; // HAS-A engine, chosen at run time
public:
explicit Xe(unique_ptr<DongCo> d) : dc(move(d)) {}
void chay() const { dc->noMay(); } // delegate to whatever engine is installed
void thayDongCo(unique_ptr<DongCo> d) { // swap the component while running
dc = move(d);
}
};
int main() {
Xe x(make_unique<DongCoXang>());
x.chay(); // Chay xang, no may
x.thayDongCo(make_unique<DongCoDien>()); // hot swap
x.chay(); // Chay dien, khong on
}
Hãy nghĩ kỹ về những gì vừa xảy ra. Cùng một đối tượng x, cùng một lời gọi
x.chay(), mà hành vi đổi hoàn toàn giữa hai lần gọi. Không có lớp XeXang hay XeDien
nào cả. Nếu ngày mai có thêm động cơ hybrid, ta viết thêm lớp DongCoHybrid và không phải
đụng một dòng nào của lớp Xe.
So sánh với cách làm bằng kế thừa. Muốn có xe xăng và xe điện, ta phải tạo XeXang và
XeDien cùng kế thừa Xe. Một chiếc XeXang sinh ra đã là xe xăng và chết đi vẫn
là xe xăng, không cách nào biến thành xe điện. Tệ hơn, nếu xe còn phân loại theo hộp số
nữa, ta cần XeXangSoSan, XeXangSoTuDong, XeDienSoSan, XeDienSoTuDong. Thêm một trục
phân loại thứ ba là số lớp lại nhân lên. Hiện tượng bùng nổ tổ hợp lớp này là căn bệnh kinh
điển của thiết kế lạm dụng kế thừa, và composition chữa nó gọn gàng: mỗi trục phân loại
thành một thành phần độc lập. Đây cũng là ý tưởng nền của nhiều mẫu thiết kế bạn sẽ gặp về
sau (Strategy, State, Decorator): lấy phần hay thay đổi, gói vào một thành phần riêng, rồi
cho lớp chính chứa thành phần đó thay vì kế thừa nó.
Composition so với aggregation
Đến đây cần phân biệt hai sắc thái của quan hệ "có một", vì chúng khác nhau ở một điểm rất quan trọng: vòng đời.
Composition theo nghĩa chặt là quan hệ sở hữu: thành phần thuộc về đối tượng cha và
chết cùng cha. Động cơ là bộ phận của chiếc xe cụ thể đó; xe đi vào máy ép phế liệu thì
động cơ cũng đi theo. Trong C++, cách diễn đạt tự nhiên nhất là đặt thành phần làm đối
tượng thành viên (DongCo dc;) hoặc giữ nó bằng unique_ptr, tức con trỏ sở hữu độc
quyền. Cả hai đều bảo đảm rằng khi cha bị hủy, thành phần cũng bị hủy theo, tự động, không
cần ta viết dòng nào.
Aggregation là quan hệ "có một" nhưng không sở hữu: đối tượng cha chỉ tham chiếu
tới một đối tượng có sẵn, sống độc lập với nó. Một Lop có một GiaoVien chủ nhiệm, nhưng
giải tán lớp thì giáo viên vẫn còn đó, vẫn dạy lớp khác. Một Xe có một TaiXe, nhưng bán
xe đi thì tài xế không biến mất. Trong C++, aggregation được diễn đạt bằng con trỏ thô
(GiaoVien* gv;), bằng tham chiếu, hoặc bằng shared_ptr khi quyền sở hữu thật sự được
chia sẻ.
| Tiêu chí | Composition | Aggregation |
|---|---|---|
| Sở hữu | có, cha sở hữu con | không, chỉ tham chiếu |
| Vòng đời | con chết cùng cha | con sống độc lập với cha |
| Diễn đạt C++ | đối tượng thành viên, unique_ptr | con trỏ thô, tham chiếu, shared_ptr |
| Chia sẻ | con thuộc về đúng một cha | nhiều cha cùng trỏ tới một con |
| Ví dụ | xe có động cơ, nhà có phòng | lớp có giáo viên, thư viện có độc giả |
| Rủi ro chính | n/a | con trỏ treo nếu con chết trước cha |
Rủi ro của aggregation đáng được nhấn mạnh. Vì đối tượng con sống độc lập, nó hoàn toàn có thể bị hủy trước đối tượng cha. Khi đó con trỏ trong cha trở thành con trỏ treo, và lần gọi tiếp theo qua con trỏ đó là hành vi không xác định. Composition không có rủi ro này, vì vòng đời được ngôn ngữ bảo đảm. Vì vậy khi phân vân, hãy chọn composition; chỉ dùng aggregation khi đối tượng con thật sự phải sống độc lập hoặc được nhiều cha dùng chung.
- Dùng kế thừa cho quan hệ thật ra là "có một", ví dụ cho
OToDienkế thừaPin. Hỏi lại "là một hay có một" trước khi quyết định. - Quên khởi tạo thành phần không có hàm dựng mặc định trong danh sách khởi tạo, khiến mã không biên dịch được.
- Tưởng thứ tự dựng thành viên theo thứ tự viết trong danh sách khởi tạo. Thực tế thứ tự dựng luôn theo thứ tự khai báo các trường trong lớp.
Vì sao người ta cứ chọn nhầm kế thừa cho quan hệ "có một"? Nguyên nhân gốc gần như luôn là
một suy nghĩ kiểu này: "lớp Pin đã có sẵn hàm xa() rồi, tôi cho OToDien kế thừa Pin
là dùng lại được ngay, khỏi viết lại".
Suy nghĩ đó dùng sai tiêu chí. Kế thừa không phải công cụ để dùng lại mã, mà là công cụ
để tuyên bố rằng lớp con thay thế được cho lớp cha ở mọi nơi. Trình biên dịch sẽ tin
lời tuyên bố đó và cho phép truyền một OToDien vào bất cứ hàm nào nhận Pin. Bạn có thật
sự muốn hàm sacPin(Pin& p) nhận nguyên chiếc ô tô không?
Muốn dùng lại mã thì đã có công cụ đúng, đó là composition: giữ một Pin làm trường rồi
gọi pin.xa(). Mã vẫn được dùng lại y hệt, mà bạn không phải đưa ra lời hứa thay thế nào.
Vậy khi phân vân, đừng hỏi "kế thừa có tiết kiệm được mã không", hãy hỏi "tôi có sẵn sàng
để mọi hàm nhận lớp cha đều nhận được lớp con này không".
Bài tập thực hành
Bài tập 1: lớp chứa nhiều thành phần
Viết lớp MayTinh chứa hai thành phần: CPU (có trường tenHang và phương thức
tinhToan() in ra "Dang tinh toan") và RAM (có trường dungLuong tính bằng GB và
phương thức napDuLieu() in ra "Nap du lieu"). Lớp MayTinh có phương thức khoiDong()
gọi lần lượt tinhToan() rồi napDuLieu() của hai thành phần. Trong main, tạo một
MayTinh, đặt giá trị cho các trường rồi gọi khoiDong().
Lời giải chi tiết
Một lớp có thể chứa nhiều thành phần cùng lúc, không bị giới hạn ở một. Ở đây MayTinh
có một CPU và có một RAM. Mỗi thành phần là một trường thành viên riêng, nằm
ngay bên trong MayTinh. Phương thức khoiDong() không tự làm việc của CPU hay RAM mà
ủy nhiệm: gọi cpu.tinhToan() rồi ram.napDuLieu(). Khi MayTinh được tạo, cả hai
thành phần được tạo theo; khi nó hủy, cả hai cũng hủy.
#include <iostream>
#include <string>
using namespace std;
class CPU {
public:
string tenHang;
void tinhToan() { cout << "Dang tinh toan\n"; }
};
class RAM {
public:
int dungLuong; // capacity in GB
void napDuLieu() { cout << "Nap du lieu\n"; }
};
class MayTinh {
public:
CPU cpu; // MayTinh has a CPU
RAM ram; // MayTinh also has a RAM
void khoiDong() { // delegate to both components
cpu.tinhToan();
ram.napDuLieu();
}
};
int main() {
MayTinh may;
may.cpu.tenHang = "Intel";
may.ram.dungLuong = 16;
may.khoiDong();
}
Một lớp cha có thể giữ nhiều thành phần và ủy nhiệm việc cho từng thành phần phù hợp.
Bài tập 2: composition lồng nhau
Mô hình hóa quan hệ Truong chứa Lop, và Lop chứa HocSinh. HocSinh có trường
ten và phương thức chao() in ra tên kèm lời chào. Lop chứa một HocSinh làm lớp
trưởng và có phương thức diemDanh() ủy nhiệm cho lớp trưởng chào. Truong chứa một
Lop và có phương thức khaiGiang() gọi diemDanh() của lớp. Trong main, đặt tên
cho lớp trưởng qua chuỗi truong.lop.lopTruong.ten rồi gọi khaiGiang().
Lời giải chi tiết
Composition có thể lồng nhiều tầng: Truong có một Lop, Lop có một HocSinh. Khi
tạo một Truong, dây chuyền tạo lan xuống tận HocSinh bên trong cùng. Lời gọi cũng đi
theo chuỗi ủy nhiệm: khaiGiang() gọi diemDanh(), rồi diemDanh() gọi chao(). Truy
cập thành phần lồng nhau dùng dấu chấm nối tiếp: truong.lop.lopTruong.ten.
#include <iostream>
#include <string>
using namespace std;
class HocSinh {
public:
string ten;
void chao() { cout << ten << " xin chao\n"; }
};
class Lop {
public:
HocSinh lopTruong; // a class has a monitor (a student)
void diemDanh() { lopTruong.chao(); } // delegate to the monitor
};
class Truong {
public:
Lop lop; // a school has a class
void khaiGiang() { lop.diemDanh(); } // delegate down the chain
};
int main() {
Truong truong;
truong.lop.lopTruong.ten = "An";
truong.khaiGiang();
}
Composition lồng nhau cho phép xây cấu trúc nhiều tầng, mỗi tầng chỉ ủy nhiệm xuống tầng ngay dưới nó.
Bài tập 3: so sánh has-a và is-a
Cho hai cách thiết kế lớp OToDien. Cách thứ nhất: OToDien kế thừa từ Pin
(is-a). Cách thứ hai: OToDien chứa một Pin làm thành phần (has-a). Hãy giải thích
vì sao cách has-a đúng hơn ở đây, rồi viết phiên bản has-a: lớp Pin có trường
dungLuong và phương thức xa() in ra "Pin dang xa"; lớp OToDien chứa một Pin và có
phương thức chay() ủy nhiệm cho pin.xa().
Lời giải chi tiết
Phép thử là đặt câu hỏi: "OToDien là một Pin?" hay "OToDien có một Pin?". Một ô
tô điện không phải là một viên pin; nó chứa pin như một bộ phận. Vì vậy quan hệ đúng
là has-a (composition), không phải is-a (kế thừa). Nếu dùng kế thừa, OToDien sẽ thừa
hưởng mọi thứ của Pin một cách vô lý (ví dụ một ô tô có trực tiếp trường dungLuong
của pin), và việc thay pin khác trở nên cứng nhắc. Với composition, ta chỉ giữ một trường
Pin và có thể thay thế hay nâng cấp dễ dàng.
#include <iostream>
using namespace std;
class Pin {
public:
int dungLuong; // battery capacity
void xa() { cout << "Pin dang xa\n"; }
};
class OToDien {
public:
Pin pin; // a car HAS a battery (composition, not inheritance)
void chay() { pin.xa(); } // delegate to the battery
};
int main() {
OToDien xe;
xe.pin.dungLuong = 75;
xe.chay();
}
Khi đối tượng A là một bộ phận của B chứ không phải cùng loại với B, hãy chọn composition (has-a) thay vì kế thừa (is-a).
Bài tập 4: thành phần bắt buộc có tham số
Lớp TaiKhoan có một ChuSoHuu. ChuSoHuu chỉ có duy nhất một hàm dựng nhận tên
(không có hàm dựng mặc định), vì một chủ sở hữu không tên là vô nghĩa. Hãy viết TaiKhoan
sao cho nó nhận tên chủ và số dư ban đầu, rồi khởi tạo ChuSoHuu đúng cách. Giải thích vì
sao ở đây bắt buộc phải dùng danh sách khởi tạo chứ không gán được trong thân hàm dựng.
Lời giải chi tiết
Mấu chốt nằm ở quy tắc: mọi thành viên phải được dựng xong trước khi thân hàm dựng của
lớp cha chạy dòng đầu tiên. Nếu ta viết TaiKhoan(string t, double sd) { chu = ChuSoHuu(t); }
thì trước khi vào thân hàm, trình biên dịch đã phải dựng trường chu rồi. Nó sẽ đi tìm hàm
dựng mặc định của ChuSoHuu và không thấy, nên báo lỗi ngay tại chỗ khai báo hàm dựng của
TaiKhoan. Lỗi này thường làm sinh viên bối rối vì thông báo trỏ vào dòng mà mình thấy
"chẳng có gì sai".
Cách đúng là chuyển tiếp đối số xuống thành phần ngay trong danh sách khởi tạo:
TaiKhoan(string t, double sd) : chu(t), soDu(sd) {}. Lúc này chu được dựng một lần
duy nhất, đúng bằng hàm dựng nhận tên, và không có lần dựng mặc định vô ích nào. Đây cũng
là cách viết hiệu quả hơn kể cả khi ChuSoHuu có hàm dựng mặc định, vì ta tránh được
cảnh dựng một lần rồi ghi đè.
Chú ý thêm: chu được khai báo trước soDu trong lớp, nên nó cũng được dựng trước, đúng
với thứ tự ta viết trong danh sách khởi tạo. Giữ hai thứ tự này khớp nhau là thói quen tốt.
#include <iostream>
#include <string>
#include <utility>
using namespace std;
class ChuSoHuu {
string ten;
public:
// no default constructor on purpose: an owner must have a name
explicit ChuSoHuu(string t) : ten(move(t)) {}
const string& layTen() const { return ten; }
};
class TaiKhoan {
ChuSoHuu chu; // declared first, so constructed first
double soDu;
public:
// the owner is built ONCE, directly with the right argument
TaiKhoan(string t, double sd) : chu(move(t)), soDu(sd) {}
void in() const {
cout << chu.layTen() << ": " << soDu << "\n";
}
};
int main() {
TaiKhoan tk("An", 1500000);
tk.in(); // An: 1.5e+06
}
Ý chính: khi thành phần không có hàm dựng mặc định, danh sách khởi tạo không còn là tùy chọn mà là bắt buộc. Và ngay cả khi không bắt buộc, nó vẫn là cách viết đúng và hiệu quả.
Tự kiểm tra
Khi nào dùng composition ("có một") thay vì kế thừa ("là một")?
Hỏi: "X là một Y" hay "X có một Y"? Ô tô là một phương tiện → kế thừa. Ô tô có một động cơ → composition. Nguyên tắc thực dụng: ưu tiên composition, vì nó ghép linh hoạt và ít ràng buộc hơn kế thừa.
Câu hỏi tự kiểm
- 1Phép thử chặt để quyết định có nên dùng kế thừa (is-a) hay không là gì?
- 2Khác biệt then chốt giữa composition và aggregation là gì?
- 3Ưu thế nào của composition mà kế thừa vĩnh viễn không có?
- 4Vì sao nên khởi tạo thành phần qua danh sách khởi tạo thay vì gán trong thân hàm dựng?
Tóm tắt bài học
Composition là quan hệ "có một": một đối tượng chứa đối tượng khác làm thành phần, ủy nhiệm việc cho thành phần đó, và vòng đời của thành phần gắn liền với vòng đời của đối tượng chứa.
Để chọn giữa composition và kế thừa, hãy dùng phép thử thay thế chứ đừng chỉ dựa vào cảm nhận ngôn ngữ: chỉ kế thừa khi lớp con dùng thay được cho lớp cha ở mọi nơi lớp cha xuất hiện. Thành phần được khởi tạo qua danh sách khởi tạo, không phải bằng phép gán trong thân hàm dựng, vì mọi thành viên đã bị dựng xong trước khi thân hàm chạy. Thứ tự dựng luôn theo thứ tự khai báo trong lớp.
Ưu tiên composition hơn kế thừa vì ba lý do cụ thể. Nó buộc lỏng hơn, cho phép giấu thành phần thay vì phơi ra toàn bộ giao diện của lớp cha. Nó cho phép đổi thành phần lúc chạy, điều mà kế thừa vĩnh viễn không làm được. Và nó tránh được hiện tượng bùng nổ tổ hợp lớp khi hệ thống có nhiều trục phân loại độc lập.
Cuối cùng, phân biệt hai sắc thái của "có một". Composition là sở hữu, thành phần chết cùng
cha, diễn đạt bằng đối tượng thành viên hoặc unique_ptr. Aggregation là tham chiếu không
sở hữu, đối tượng con sống độc lập, diễn đạt bằng con trỏ hoặc tham chiếu, kèm rủi ro con
trỏ treo. Khi phân vân, chọn composition.
Composition = "có một": đối tượng chứa đối tượng khác làm thành phần, vòng đời gắn liền, và ủy nhiệm việc cho thành phần. Là cách lắp ghép phổ biến và linh hoạt nhất trong thiết kế hướng đối tượng.