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

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; ... }, Xe có một DongCo nằm ngay bên trong nó.
  • Tạo Xe thì DongCo bên trong cũng được tạo; hủy Xe thì DongCo cũng hủy.
  • Lớp cha ủy nhiệm việc cho thành phần: Xe::chay() gọi dc.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

Xe chứa DongCo như một thành phần bên trong 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};
11
12int main() {
13 Xe x;
14 x.dc.maLuc = 150;
15 x.chay();
16}
Ngăn xếp stack
main()
#1Xe
dcDongCo{ maLuc=? }
Bộ nhớ động heap
(trống)
Tạo Xe x. Vì Xe CÓ một DongCo, thành phần dc được tạo NGAY bên trong x (một khối nhớ liền).
1/5

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)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 XeXangXeDien 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íCompositionAggregation
Sở hữucó, cha sở hữu conkhông, chỉ tham chiếu
Vòng đờicon chết cùng chacon sống độc lập với cha
Diễn đạt C++đối tượng thành viên, unique_ptrcon trỏ thô, tham chiếu, shared_ptr
Chia sẻcon thuộc về đúng một chanhiều cha cùng trỏ tới một con
Ví dụxe có động cơ, nhà có phònglớp có giáo viên, thư viện có độc giả
Rủi ro chínhn/acon 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.

Lỗi thường gặp
  • Dùng kế thừa cho quan hệ thật ra là "có một", ví dụ cho OToDien kế thừa Pin. 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.
Nguyên nhân gốc: nhầm "dùng lại mã" với "là một"

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 CPUcó 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 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

Kiểm tra nhanh: composition0/4 đúngchưa trả lời
  1. 1Phép thử chặt để quyết định có nên dùng kế thừa (is-a) hay không là gì?
  2. 2Khác biệt then chốt giữa composition và aggregation là gì?
  3. 3Ưu thế nào của composition mà kế thừa vĩnh viễn không có?
  4. 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.

Ghi nhớ

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.