Chương 14. Đa hình động và thiết kế giao diện driver
Mục tiêu
- Thiết kế một giao diện thuần ảo (
ISensor) và trình quản lý xử lý nhiều loại cảm biến qua cùng một giao diện. - Hiểu chính xác vì sao thiếu
virtualở destructor gây lỗi, và cách trình biên dịch có thể cảnh báo. - Biết khi nào đa hình động (chương này) là lựa chọn đúng, khi nào nên dùng template (chương 15).
Câu chuyện: thêm cảm biến mới không được sửa code cũ
Firmware gốc chỉ có một loại cảm biến nhiệt độ, code đọc thẳng read_temp_raw(). Sáu tháng sau, sản phẩm thêm biến thể dùng cảm biến khác (chip khác, giao thức khác). Cách tệ: thêm #ifdef SENSOR_V2 rải khắp nơi. Cách tốt: định nghĩa một giao diện chung (ISensor) từ đầu; thêm cảm biến mới là thêm một lớp mới cài đặt giao diện, không sửa code đã có — đúng nguyên lý "mở để mở rộng, đóng để sửa đổi" (nguyên tắc O trong SOLID).
Kiến thức
1. Lớp trừu tượng thuần ảo làm hợp đồng
ISensor bên dưới không có dữ liệu, chỉ có các hàm thuần ảo (= 0): nó là một hợp đồng — bất kỳ ai cài đặt các hàm đó đều được mã ứng dụng chấp nhận, không cần biết chi tiết bên trong.
// Trình quản lý nhiều cảm biến qua một giao diện đa hình chung: mẫu thường gặp
// trong firmware có nhiều loại cảm biến cắm được (plug-in) và chọn lúc chạy.
#include <array>
#include <cstdint>
#include <cstdio>
class ISensor {
public:
virtual ~ISensor() = default; // BẮT BUỘC: xoá qua con trỏ base phải gọi đúng destructor con
virtual bool init() = 0;
[[nodiscard]] virtual int read() = 0;
[[nodiscard]] virtual const char* name() const = 0;
};
class FakeTemp final : public ISensor {
public:
explicit FakeTemp(int start) : value_(start) {}
bool init() override { std::printf("[%s] init\n", name()); return true; }
int read() override { return value_++; }
const char* name() const override { return "FakeTemp"; }
private:
int value_;
};
class FakeHumidity final : public ISensor {
public:
bool init() override { std::printf("[%s] init\n", name()); return true; }
int read() override { return 55; }
const char* name() const override { return "FakeHumidity"; }
};
// Chỉ phụ thuộc giao diện ISensor: thêm cảm biến mới không cần sửa hàm này.
static void poll_all(std::array<ISensor*, 2>& sensors) {
for (ISensor* s : sensors) std::printf("%-14s -> %d\n", s->name(), s->read());
}
int main() {
FakeTemp temp(20);
FakeHumidity humidity;
std::array<ISensor*, 2> sensors{&temp, &humidity};
for (ISensor* s : sensors) s->init();
poll_all(sensors);
poll_all(sensors); // FakeTemp tăng dần mỗi lần đọc, FakeHumidity cố định
return 0;
}
[FakeTemp] init
[FakeHumidity] init
FakeTemp -> 20
FakeHumidity -> 55
FakeTemp -> 21
FakeHumidity -> 55
Các điểm thiết kế:
virtual ~ISensor() = default;— bắt buộc (xem mục 3).[[nodiscard]] virtual int read() = 0;— thuần ảo (= 0): lớp không cài đặt hàm này không thể được tạo đối tượng (compiler báo lỗi ngay), đảm bảo mọi cảm biến thực sự đọc được.class FakeTemp final : public ISensor— từ khoáfinalbáo không ai được kế thừa tiếp từFakeTemp; hữu ích khi bạn chắc chắn đây là điểm dừng của cây kế thừa, và giúp trình biên dịch tối ưu tốt hơn ở một số trường hợp gọi hàm.poll_all()chỉ phụ thuộcISensor, không biếtFakeTemp/FakeHumiditylà gì — thêm loại cảm biến thứ ba không cần sửapoll_all().
2. Destructor ảo: không phải tuỳ chọn
Quy tắc: nếu một lớp có bất kỳ hàm ảo nào, hoặc có thể bị xoá qua con trỏ lớp cơ sở, destructor của nó phải là virtual. Không tuân theo quy tắc này là hành vi không xác định theo chuẩn C++ — không phải "chậm hơn" mà là "kết quả không được đảm bảo", dù trong thực tế thường biểu hiện thành rò rỉ tài nguyên (phần con không được dọn dẹp).
Cách trình biên dịch giúp bạn: với -Wall/-Wextra (thực chất là -Wnon-virtual-dtor/-Wdelete-non-virtual-dtor tuỳ ngữ cảnh), GCC/Clang cảnh báo khi bạn delete một đối tượng qua con trỏ tới lớp có hàm ảo nhưng destructor không ảo:
struct BaseNoVirtual {
virtual void ping() { /* ... */ }
~BaseNoVirtual() { /* KHÔNG có 'virtual' */ }
};
struct DerivedA : BaseNoVirtual { ~DerivedA() { /* giải phóng tài nguyên ở đây */ } };
BaseNoVirtual* bad = new DerivedA();
delete bad; // <-- dòng này bị cảnh báo
Biên dịch với g++ -std=c++20 -Wall -Wextra -fsyntax-only:
warning: deleting object of polymorphic class type 'BaseNoVirtual' which has
non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]
Bản sửa đúng chỉ cần thêm một từ khoá:
// Bản ĐÚNG: destructor của lớp cơ sở khai báo 'virtual', nên xoá qua con trỏ lớp cơ sở
// gọi đúng chuỗi destructor con -> cơ sở, không rò rỉ tài nguyên.
#include <cstdio>
int g_derived_destructed = 0;
struct BaseVirtual {
virtual void ping() { std::puts("BaseVirtual::ping"); }
virtual ~BaseVirtual() { std::puts("~BaseVirtual"); } // 'virtual': bắt buộc vì lớp có hàm ảo
};
struct DerivedB : BaseVirtual {
~DerivedB() override { ++g_derived_destructed; std::puts("~DerivedB (giai phong tai nguyen o day)"); }
};
int main() {
BaseVirtual* p = new DerivedB();
delete p; // gọi ~DerivedB() trước, rồi mới ~BaseVirtual() -- đúng thứ tự, không cảnh báo
std::printf("g_derived_destructed = %d (dung: 1)\n", g_derived_destructed);
return 0;
}
~DerivedB (giai phong tai nguyen o day)
~BaseVirtual
g_derived_destructed = 1 (dung: 1)
Không bao giờ tắt cảnh báo này. Nếu bạn chắc chắn một lớp sẽ không bao giờ bị kế thừa hoặc bị xoá qua con trỏ cơ sở, đánh dấu nó final thay vì bỏ qua cảnh báo.
3. Chi phí và giới hạn (nhắc lại, mở rộng)
Chương 3 đã đo: mỗi đối tượng có hàm ảo tốn thêm một con trỏ vptr; mỗi lời gọi hàm ảo là một lần tra bảng rồi nhảy gián tiếp — chậm hơn gọi trực tiếp, và ngăn trình biên dịch inline (vì không biết lúc biên dịch sẽ gọi hàm của lớp con nào). Với poll_all() gọi 2 cảm biến vài lần mỗi giây, chi phí này không đáng kể. Với vòng lặp điều khiển động cơ chạy hàng chục nghìn lần mỗi giây, chi phí gọi gián tiếp có thể đáng kể — đó là lúc cân nhắc chương 15 (CRTP/template).
Ví dụ thực tiễn: trình quản lý cảm biến có thể cấu hình lúc chạy
Tình huống hợp lý cho đa hình động: một sản phẩm có thể lắp một trong vài loại cảm biến tuỳ đơn hàng, xác định qua một chân ID đọc lúc khởi động. Chương trình đọc chân ID, new (hoặc đặt vào bộ nhớ tĩnh đã cấp phát sẵn — xem chương 20) đối tượng cảm biến tương ứng, rồi từ đó chỉ làm việc qua con trỏ ISensor*. Đây là trường hợp loại đối tượng chỉ biết được lúc chạy — đúng lý do tồn tại của đa hình động.
Góc nhúng
- Mỗi lớp cài đặt
ISensorthêm một vtable dùng chung cho mọi đối tượng của lớp đó (không phải mỗi đối tượng); vtable nằm trong Flash (.rodata), không tốn RAM. - Mỗi đối tượng (không phải lớp) tốn thêm một con trỏ vptr trong RAM (hoặc bất kỳ nơi đối tượng được đặt).
- Trên MCU rất nhỏ (dưới 1000 đối tượng có hàm ảo là chuyện hiếm gặp), chi phí vtable gần như luôn chấp nhận được; đừng tối ưu sớm khi chưa đo.
Lỗi thường gặp
- Lớp cơ sở có hàm ảo mà quên
virtualở destructor. - Quên
overrideở hàm ghi đè — nếu chữ ký sai (nhầm kiểu tham số), thiếuoverridekhiến trình biên dịch âm thầm coi đó là một hàm mới, không ghi đè gì cả; cóoverridesẽ báo lỗi ngay khi chữ ký không khớp. - Gọi hàm ảo từ trong constructor hoặc destructor của lớp cơ sở: tại thời điểm đó, phần "con" của đối tượng chưa được xây dựng (hoặc đã bị huỷ), nên lời gọi luôn phân giải tới phiên bản của lớp cơ sở, không phải lớp con — dễ gây ngạc nhiên.
- Thiết kế giao diện quá lớn (nhiều hàm thuần ảo không phải cảm biến nào cũng cần) — vi phạm nguyên tắc phân tách giao diện (nguyên tắc I trong SOLID); tách thành nhiều giao diện nhỏ nếu cần.
Bài tập
- Thêm
class FakePressure final : public ISensorvàosensor_manager.cppvà mở rộng mảngsensorslên 3 phần tử mà không sửa hàmpoll_all(). - Bỏ
overridekhỏiFakeTemp::read()và cố tình đổi kiểu trả về thànhlong. Quan sát: chương trình còn biên dịch không? Nó còn ghi đè đúng hàm củaISensorkhông? Thêm lạioverridevà xem thông báo lỗi thay đổi thế nào. - Giải thích bằng lời vì sao
finaltrênclass FakeTemp final : public ISensorkhông ảnh hưởng gì đến việcFakeTempcài đặt giao diệnISensor, mà chỉ ngăn một lớp thứ ba kế thừa từFakeTemp. - Viết một
class NullSensor final : public ISensorluôn trả về một giá trị cố định vàinit()luôn thành công — dùng làm "cảm biến giả" khi phần cứng chưa lắp, để phần còn lại của firmware không cần kiểm tra con trỏ null ở khắp nơi. Giải thích lợi ích của mẫu thiết kế "Null Object" này.
Tóm tắt
- Giao diện thuần ảo (
= 0) định nghĩa hợp đồng chung; nhiều lớp cài đặt cho phép mở rộng mà không sửa code cũ. - Destructor của lớp cơ sở có hàm ảo phải là
virtual; trình biên dịch cảnh báo khi bạn quên — đừng tắt cảnh báo đó. overridelà lưới an toàn miễn phí: bắt lỗi chữ ký sai ngay lúc biên dịch.- Đa hình động phù hợp khi loại đối tượng cụ thể chỉ xác định được lúc chạy; nếu biết trước lúc biên dịch, cân nhắc template (chương sau).