Chương 13. Kế thừa và tổ hợp: is-a hay has-a
Mục tiêu
- Phân biệt quan hệ "is-a" (kế thừa đúng chỗ) và "has-a" (tổ hợp) bằng ví dụ phần cứng cụ thể.
- Nhận ra dấu hiệu kế thừa bị dùng sai — nguyên nhân phổ biến của thiết kế cứng nhắc.
- Biết quy tắc thực dụng: trong nhúng, ưu tiên tổ hợp, chỉ kế thừa khi thật sự có quan hệ "là một loại của".
Câu chuyện: cảm biến "là một" cái bus?
Một lập trình viên mới viết class TempSensor : public I2cBus vì "gõ nhanh hơn, không cần viết lại các hàm đọc/ghi thanh ghi". Vài tuần sau, dự án cần hai cảm biến dùng chung một bus vật lý (đúng như board thật: một bus I2C, nhiều thiết bị trên đó). Nhưng mỗi TempSensor kế thừa lại tự chứa một I2cBus riêng — không có cách nào biểu diễn "hai đối tượng cùng dùng một bus" bằng kế thừa. Phải viết lại toàn bộ bằng tổ hợp. Bài học: kế thừa mô tả loại (taxonomy), không phải cách tái sử dụng code.
Kiến thức
1. Câu hỏi kiểm tra: "X là một loại Y" hay "X có một Y"
- Là một loại (is-a):
Ledlà một loạiOutputDevice.DerivedExceptionlà một loạiException. Quan hệ này hợp lý cho kế thừa:Ledcó thể dùng ở bất cứ đâu cần mộtOutputDevice, và điều đó đúng về mặt ý nghĩa (nguyên tắc thay thế Liskov). - Có một (has-a):
TempSensorcó mộtI2cBusđể giao tiếp; nó không "là" cái bus. Quan hệ này nên biểu diễn bằng tổ hợp: một thành viên hoặc tham chiếu tới đối tượng kia.
Dấu hiệu cảnh báo kế thừa sai chỗ: lớp con ghi đè hầu hết hàm của lớp cha để "vô hiệu hoá" hành vi không mong muốn, hoặc lớp con không thật sự thay thế được cho lớp cha ở mọi nơi.
2. Ví dụ sai và đúng trên cùng bài toán
// So sánh "has-a" (tổ hợp) và "is-a" (kế thừa) trên cùng một bài toán: một cảm biến
// nhiệt độ giao tiếp qua bus I2C.
#include <cstdint>
#include <cstdio>
// ---- Bus I2C giả lập: một thiết bị dùng CHUNG bus với nhiều thiết bị khác ----
class I2cBus {
public:
explicit I2cBus(int id) : id_(id) {}
[[nodiscard]] std::uint8_t read_register(std::uint8_t addr, std::uint8_t reg) const {
std::printf(" I2C%d: doc dia chi 0x%02X, thanh ghi 0x%02X\n", id_, addr, reg);
return 0x19; // giá trị giả lập
}
private:
int id_;
};
// ---- SAI (thường gặp): kế thừa "is-a" trong khi quan hệ thật là "has-a" ----
// TempSensorBad "LÀ MỘT" I2cBus? Không hợp lý: cảm biến không phải là cái bus, nó CHỈ
// DÙNG bus. Kế thừa kiểu này còn lộ ra ngoài các hàm nội bộ của I2cBus không liên quan
// đến cảm biến, và không cho phép hai cảm biến chia sẻ MỘT bus vật lý (mỗi bản kế thừa
// tạo bus riêng).
class TempSensorBad : public I2cBus {
public:
explicit TempSensorBad(int bus_id) : I2cBus(bus_id) {}
[[nodiscard]] int read_celsius() const { return read_register(0x48, 0x00); }
};
// ---- ĐÚNG: tổ hợp "has-a" — cảm biến GIỮ MỘT THAM CHIẾU tới bus dùng chung ----
class TempSensor {
public:
TempSensor(I2cBus& bus, std::uint8_t address) : bus_(bus), address_(address) {}
[[nodiscard]] int read_celsius() const { return bus_.read_register(address_, 0x00); }
private:
I2cBus& bus_; // không sở hữu bus; bus sống lâu hơn cảm biến (do người gọi đảm bảo)
std::uint8_t address_;
};
int main() {
I2cBus bus(1);
TempSensor temp(bus, 0x48); // hai cảm biến CÙNG DÙNG một bus vật lý
TempSensor humidity(bus, 0x40);
std::printf("temp = %d\n", temp.read_celsius());
std::printf("humidity = %d\n", humidity.read_celsius());
TempSensorBad bad(1); // tạo "bus riêng" bên trong — không phản ánh phần cứng thật
std::printf("bad = %d\n", bad.read_celsius());
return 0;
}
I2C1: doc dia chi 0x48, thanh ghi 0x00
temp = 25
I2C1: doc dia chi 0x40, thanh ghi 0x00
humidity = 25
I2C1: doc dia chi 0x48, thanh ghi 0x00
bad = 25
TempSensorBad : public I2cBus biên dịch được và chạy được — đó chính là điều nguy hiểm: không có lỗi cú pháp nào báo cho bạn biết thiết kế sai. Vấn đề chỉ lộ ra khi yêu cầu thay đổi (hai cảm biến chung bus). TempSensor (đúng) giữ một I2cBus&: không sở hữu bus, chỉ tham chiếu tới nó; hai đối tượng TempSensor khác nhau có thể cùng tham chiếu một bus, đúng như phần cứng thật.
3. Kế thừa dùng đúng chỗ: giao diện thiết bị đầu ra
// Kế thừa "is-a" hợp lý: Led và Buzzer THỰC SỰ LÀ một loại OutputDevice
// (cùng giao diện on()/off(), khác cách hiện thực).
#include <cstdint>
#include <cstdio>
class OutputDevice {
public:
virtual ~OutputDevice() = default; // destructor ảo: bắt buộc khi có kế thừa + xoá qua con trỏ base
virtual void on() = 0;
virtual void off() = 0;
[[nodiscard]] bool is_on() const { return on_; }
protected:
bool on_ = false;
};
class Led : public OutputDevice {
public:
explicit Led(std::uint8_t pin) : pin_(pin) {}
void on() override { on_ = true; std::printf("Led(pin=%u) BAT\n", static_cast<unsigned>(pin_)); }
void off() override { on_ = false; std::printf("Led(pin=%u) TAT\n", static_cast<unsigned>(pin_)); }
private:
std::uint8_t pin_;
};
class Buzzer : public OutputDevice {
public:
explicit Buzzer(std::uint32_t freq_hz) : freq_hz_(freq_hz) {}
void on() override { on_ = true; std::printf("Buzzer(%u Hz) KEU\n", static_cast<unsigned>(freq_hz_)); }
void off() override { on_ = false; std::printf("Buzzer im lang\n"); }
private:
std::uint32_t freq_hz_;
};
// Mã ứng dụng chỉ biết "OutputDevice", không quan tâm cụ thể là Led hay Buzzer.
static void alarm_sequence(OutputDevice& d) {
d.on();
d.off();
}
int main() {
Led led(5);
Buzzer buzzer(2000);
OutputDevice* devices[] = {&led, &buzzer}; // một mảng chứa các loại thiết bị khác nhau
for (OutputDevice* d : devices) alarm_sequence(*d);
std::printf("led.is_on()=%d buzzer.is_on()=%d\n", led.is_on(), buzzer.is_on());
return 0;
}
Led(pin=5) BAT
Led(pin=5) TAT
Buzzer(2000 Hz) KEU
Buzzer im lang
led.is_on()=0 buzzer.is_on()=0
Ở đây kế thừa hợp lý vì: Led và Buzzer thực sự đều là "một loại thiết bị output" theo đúng nghĩa dùng trong chương trình — cả hai có khái niệm bật/tắt, và hàm alarm_sequence() dùng được với bất kỳ OutputDevice nào, không cần biết cụ thể là gì. Đây là điểm khác biệt với ví dụ sai ở trên: TempSensorBad không thể dùng ở nơi cần một I2cBus theo cách có ý nghĩa (không ai muốn "đọc thanh ghi của một cảm biến" như thể nó là cả cái bus).
Chú ý virtual ~OutputDevice() = default; — bắt buộc phải có khi lớp có hàm ảo và có thể bị xoá qua con trỏ lớp cơ sở; chương 14 giải thích chi tiết hậu quả nếu thiếu.
4. Kế thừa riêng tư và bảo vệ (nhắc nhanh)
class D : private B hoặc protected B tồn tại trong C++ nhưng hiếm khi cần trong firmware. Nếu bạn thấy mình muốn kế thừa riêng tư ("dùng lại code của B nhưng không muốn D là một B"), đó thường là dấu hiệu nên dùng tổ hợp thay vì kế thừa dưới bất kỳ hình thức nào.
Ví dụ thực tiễn: khi nào một driver "nên" kế thừa
Bảng quyết định nhanh cho firmware:
| Tình huống | Nên dùng |
|---|---|
Nhiều loại cảm biến, mã ứng dụng xử lý qua một giao diện chung (read()) |
Kế thừa từ lớp trừu tượng (interface) — chương 14 |
| Một driver cần dùng một bus/ngoại vi dùng chung với driver khác | Tổ hợp (giữ tham chiếu/con trỏ tới bus) |
| Muốn tái sử dụng vài hàm tiện ích (ví dụ tính CRC) giữa nhiều lớp không liên quan | Hàm tự do (free function) hoặc lớp tiện ích riêng, không kế thừa |
| Nhiều biến thể phần cứng của cùng một khái niệm (LED thường, LED RGB, LED qua PWM) | Kế thừa từ OutputDevice, hoặc template nếu chọn được lúc biên dịch (chương 15) |
Góc nhúng
- Tổ hợp bằng tham chiếu (
I2cBus&) không tốn thêm bộ nhớ ngoài kích thước một con trỏ; không có vtable liên quan đến quan hệ has-a này. - Kế thừa từ lớp có hàm ảo thêm đúng một vptr cho toàn bộ cây kế thừa (không cộng dồn theo số tầng nếu không có đa kế thừa phức tạp) — xem lại chương 3.
- Đa kế thừa (
class D : public B1, public B2) tồn tại nhưng hiếm dùng trong firmware; nó làm layout đối tượng và độ phân giải tên phức tạp hơn đáng kể. Ưu tiên tổ hợp hoặc một giao diện thuần ảo duy nhất.
Lỗi thường gặp
- Kế thừa để "tiết kiệm gõ phím" thay vì vì quan hệ is-a thật sự.
- Không thể trả lời được câu "một
Derived*có dùng thay choBase*ở MỌI nơi mà vẫn đúng nghĩa không?" — nếu không, đó là dấu hiệu sai chỗ. - Giữ tham chiếu (
I2cBus&) tới một đối tượng có vòng đời ngắn hơn đối tượng giữ nó (dangling reference) — người tạoTempSensorphải đảm bảobussống lâu hơntemp. - Đa kế thừa nhiều lớp cơ sở không liên quan "cho tiện", dẫn tới nhập nhằng tên hàm khó gỡ.
Bài tập
- Thêm một lớp
PressureSensordùng chungI2cBusvớiTempSensortrongmain(); chứng minh cả ba cảm biến có thể tồn tại đồng thời trên cùng mộtI2cBus bus(1);. - Viết một phản ví dụ: một lớp
Rectanglevà lớp conSquare : public Rectangleghi đèset_width()để luôn đặt cảwidthvàheightbằng nhau. Giải thích bằng ví dụ cụ thể vì sao điều này vi phạm nguyên tắc thay thế Liskov (gợi ý: viết một hàm nhậnRectangle&, đặtwidth=5, height=10, rồi kiểm tra diện tích). - Trong
actuator_hierarchy.cpp, thêmclass RgbLed : public OutputDevicevới ba giá trị màu;on()/off()có ý nghĩa gì với RGB LED? Nếu không rõ ràng, đó có phải dấu hiệu cần thiết kế lại giao diệnOutputDevicekhông? - Giải thích bằng lời (không cần code): vì sao
TempSensorgiữI2cBus&(tham chiếu) thay vìI2cBus(giữ hẳn một bản sao) là lựa chọn đúng ở đây.
Tóm tắt
- Kế thừa mô tả "là một loại của" (is-a); tổ hợp mô tả "có một" (has-a). Nhầm hai quan hệ này là nguồn thiết kế cứng nhắc phổ biến nhất.
- Firmware nên ưu tiên tổ hợp: driver giữ tham chiếu/con trỏ tới bus hoặc ngoại vi dùng chung.
- Kế thừa hợp lý khi nhiều lớp con thật sự thay thế được cho nhau qua cùng một giao diện.
- Đa kế thừa và kế thừa riêng tư tồn tại nhưng hiếm cần trong firmware; nghi ngờ trước khi dùng.