项目里加了第二种传感器,轮询函数就要改一次;加了第三种,再改一次。继承和多态解决的就是这个问题——不同类型的对象,对外暴露同一套接口,上层代码不需要知道底下是什么。
一、继承:复用已有的类
假设你有一个传感器基类:
1 2 3 4 5 6 7 8 9 10
| class Sensor { public: Sensor(uint8_t addr) : addr_(addr) {}
void set_address(uint8_t addr) { addr_ = addr; } uint8_t address() const { return addr_; }
protected: uint8_t addr_; };
|
温度传感器和湿度传感器都有地址,都需要 set_address,没必要重复写。用继承:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class TempSensor : public Sensor { public: TempSensor(uint8_t addr) : Sensor(addr) {}
float read_temp() { return raw_to_celsius(read_reg(addr_)); }
private: float raw_to_celsius(uint8_t raw); uint8_t read_reg(uint8_t addr); };
class HumiSensor : public Sensor { public: HumiSensor(uint8_t addr) : Sensor(addr) {}
float read_humidity(); };
|
TempSensor 自动拥有 set_address 和 address(),不需要重新写。
继承方式
1 2 3
| class Child : public Base class Child : protected Base class Child : private Base
|
绝大多数情况用 public 继承。protected/private 继承有特殊用途,不常见。
二、多态:同一接口,不同行为
继承解决了代码复用,多态解决了另一个问题:用统一的方式操作不同类型的对象。
先看没有多态时的问题:
1 2 3
| void read_sensor(TempSensor *s) { s->read_temp(); } void read_sensor(HumiSensor *s) { s->read_humidity(); }
|
用多态,定义一个统一的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| class Sensor { public: Sensor(uint8_t addr) : addr_(addr) {} virtual float read() = 0; virtual ~Sensor() {}
protected: uint8_t addr_; };
class TempSensor : public Sensor { public: TempSensor(uint8_t addr) : Sensor(addr) {} float read() override { return raw_to_celsius(read_reg(addr_)); } private: float raw_to_celsius(uint8_t raw); uint8_t read_reg(uint8_t addr); };
class HumiSensor : public Sensor { public: HumiSensor(uint8_t addr) : Sensor(addr) {} float read() override { return read_reg(addr_) / 2.55f; } private: uint8_t read_reg(uint8_t addr); };
|
现在可以统一操作:
1 2 3 4 5 6 7 8 9 10
| void poll(Sensor *s) { float val = s->read(); log(val); }
TempSensor temp(0x48); HumiSensor humi(0x40);
poll(&temp); poll(&humi);
|
三、virtual、override、纯虚函数
virtual:告诉编译器这个函数可以被子类覆盖,调用时根据对象的实际类型决定调用哪个版本。
override:C++11 新增,声明这个函数是覆盖基类的虚函数。可以不写,但强烈建议写——编译器会检查基类里是否真的有这个虚函数,防止函数签名写错时默默创建了一个新函数:
1 2 3 4 5 6 7 8 9
| class Base { virtual void func(int x); };
class Child : public Base { void func(int x) override; void func(float x) override; void func(float x); };
|
纯虚函数 = 0:声明基类不提供实现,子类必须实现。含有纯虚函数的类是抽象类,不能直接实例化:
1 2
| Sensor s(0x48); Sensor *p = new TempSensor(0x48);
|
四、虚析构函数
这是一个经典陷阱:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class Sensor { public: ~Sensor() { } };
class TempSensor : public Sensor { public: TempSensor() { buf_ = new uint8_t[64]; } ~TempSensor() { delete[] buf_; } private: uint8_t *buf_; };
Sensor *p = new TempSensor(); delete p;
|
delete 通过基类指针删除对象时,如果析构函数不是虚函数,只会调用基类析构,子类的析构函数被跳过,资源泄漏。
解决:基类析构函数加 virtual:
1 2 3 4
| class Sensor { public: virtual ~Sensor() {} };
|
规则:只要类有虚函数,析构函数就应该是虚的。
五、vtable:多态的代价
虚函数是怎么实现的?编译器为每个含有虚函数的类生成一张虚函数表(vtable),每个对象里有一个指向该表的指针(vptr)。
1 2 3 4 5 6 7 8
| TempSensor 对象在内存里: ┌──────────────┐ │ vptr │──→ TempSensor::vtable ├──────────────┤ ┌─────────────────────┐ │ addr_ │ │ TempSensor::read() │ ├──────────────┤ │ TempSensor::~Temp.. │ │ ... │ └─────────────────────┘ └──────────────┘
|
调用 s->read() 时,实际执行的是:
- 通过
vptr 找到 vtable
- 在 vtable 里找到
read 的地址
- 跳转执行
代价:
- 每个对象多一个
vptr:4 字节(32位)或 8 字节(64位)。1000 个对象就是 4KB 额外开销
- 间接调用,无法内联:普通函数调用可以被编译器内联优化,虚函数调用必须查表,多一次内存访问
- icache 压力:间接跳转对 CPU 分支预测不友好
在 PC 上这些开销可以忽略。在 Cortex-M 上,内存本来就几十 KB,高频调用的函数能否内联直接影响性能。
六、嵌入式里的权衡
适合用虚函数的场景:
- 设备驱动抽象层:
UartBase、SpiBase,不同硬件平台实现不同,调用频率低
- 状态机:每个状态是一个对象,
handle_event() 是虚函数
- 初始化阶段的策略选择
不适合用虚函数的场景:
- 高频中断处理函数(每微秒调用一次的代码)
- 内存极度紧张的场合(每个对象 4 字节 vptr 都很贵)
- 实时性要求严格的控制循环
替代方案:函数指针 / 模板
如果需要多态但不想要 vtable 开销,C++ 有两种方法:
1 2 3 4 5 6 7 8 9 10
| struct SensorOps { float (*read)(void *ctx); };
template<typename T> void poll(T &sensor) { float val = sensor.read(); }
|
模板是嵌入式里替代虚函数的常用手段,代价是编译时间变长、代码体积可能增大(每种类型生成一份代码)。
七、完整例子:传感器抽象层
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| class ISensor { public: virtual ~ISensor() {} virtual bool init() = 0; virtual float read() = 0; virtual const char *name() const = 0; };
class Stm32TempSensor : public ISensor { public: Stm32TempSensor(I2C_HandleTypeDef *hi2c, uint8_t addr) : hi2c_(hi2c), addr_(addr) {}
bool init() override { return HAL_I2C_IsDeviceReady(hi2c_, addr_, 3, 100) == HAL_OK; }
float read() override { uint8_t data[2]; HAL_I2C_Master_Receive(hi2c_, addr_, data, 2, 100); return ((data[0] << 8 | data[1]) >> 4) * 0.0625f; }
const char *name() const override { return "TMP102"; }
private: I2C_HandleTypeDef *hi2c_; uint8_t addr_; };
void log_all(ISensor **sensors, size_t count) { for (size_t i = 0; i < count; ++i) { printf("%s: %.2f\n", sensors[i]->name(), sensors[i]->read()); } }
|
换平台时只需要换 Stm32TempSensor 的实现,log_all 和所有上层代码一行不用改。
总结
- 继承:子类自动拥有基类的成员和方法,用
public 继承
virtual:运行时根据对象实际类型调用对应函数
override:明确标记覆盖,让编译器帮你检查签名
- 纯虚函数
= 0:定义接口,强制子类实现
- 虚析构函数:只要类有虚函数,析构就要加
virtual
- vtable 的代价:每个对象多一个指针,调用多一次间接跳转,无法内联
- 嵌入式里:低频的抽象层用虚函数没问题,高频路径考虑模板或函数指针