4. 类与对象基础
依赖注入,英文叫 Dependency Injection,简称 DI。很多初学者一听这名字就觉得高深,其实说白了,它就是一个“对象自己不 new 东西,等着别人把东西塞给它”的机制。
我刚开始做嵌入式时,写代码特别“实在”。一个传感器驱动里直接 new 一个 I2C 对象,一个业务逻辑里直接 new 一个 Flash 存储对象。那时候觉得这样写多痛快,代码一目了然。直到有一次,我需要把整个项目从 STM32 移植到 GD32,才发现——完了,所有驱动类全耦合在一起,改一个地方,牵一发动全身。那段时间我几乎每天都在改头文件,改构造函数,改初始化顺序。嗯,从那以后,我开始认真研究 DI。
4.1 什么是依赖注入?
依赖注入的核心思想就一句话:一个类不应该自己去创建它依赖的对象,而应该由外部把依赖“注入”进来。
你想想看,如果每个类都自己 new 依赖,那这个类就和具体实现绑死了。比如一个温度采集类,它直接 new 了一个 DS18B20 的驱动对象。哪天你想换成 NTC 热敏电阻,就得改这个类的代码。这违反了开闭原则——对扩展开放,对修改关闭。
依赖注入把“创建依赖”的责任从类内部移到了外部。类只负责声明“我需要什么”,至于谁来提供、怎么提供,它不管。这样,类就变得可测试、可替换、可复用。
依赖注入的本质:控制反转(IoC)的一种实现方式。控制权从类内部反转到了外部容器或调用者手中。
4.2 三种注入方式
DI 的实现方式主要有三种:构造函数注入、属性注入、接口注入。我按实际项目中的使用频率来排个序。
| 注入方式 | 实现方式 | 优点 | 缺点 | 嵌入式适用场景 |
|---|---|---|---|---|
| 构造函数注入 | 通过构造函数参数传入依赖 | 依赖明确,对象创建后不可变 | 构造函数参数过多时难看 | 最常用,推荐首选 |
| 属性注入 | 通过 setter 或公有属性设置依赖 | 灵活,可随时更换依赖 | 依赖可能为空,需额外检查 | 可选依赖、测试时 Mock |
| 接口注入 | 通过接口方法传入依赖 | 标准化,适合框架 | 侵入性强,需实现接口 | 框架层、插件系统 |
4.2.1 构造函数注入
我个人最推荐这种方式。依赖在对象创建时就确定下来,后续不会变。这样对象的状态是完整的,不会出现“依赖还没设置好就被调用”的尴尬情况。
// 不好的写法:类内部自己创建依赖
class TemperatureMonitor {
private:
DS18B20Sensor sensor; // 硬编码依赖
public:
TemperatureMonitor() : sensor(PIN_1) {}
float readTemp() { return sensor.read(); }
};
// 好的写法:依赖通过构造函数注入
class TemperatureMonitor {
private:
ITemperatureSensor& sensor; // 依赖接口
public:
TemperatureMonitor(ITemperatureSensor& s) : sensor(s) {}
float readTemp() { return sensor.read(); }
};
你看,第二种写法里,TemperatureMonitor 不再关心具体是什么传感器。你传 DS18B20 进去,它就测 DS18B20 的温度;你传 NTC 进去,它就测 NTC 的温度。这就是依赖注入带来的灵活性。
4.2.2 属性注入
属性注入也叫 Setter 注入。对象先创建一个空壳,然后通过 setter 方法把依赖塞进去。这种方式适合那些“可选依赖”或者“运行时才确定”的场景。
class DataLogger {
private:
IStorage* storage = nullptr; // 初始为空
public:
void setStorage(IStorage* s) { storage = s; }
void log(const char* data) {
if (storage) {
storage->write(data);
}
}
};
我曾经在一个项目中用过属性注入来处理“日志存储后端”的问题。系统启动时,如果检测到 SD 卡,就注入 SD 卡存储;如果没检测到,就注入内部 Flash 存储。这样 DataLogger 本身不用改一行代码。
注意:属性注入有一个坑——对象创建后,依赖可能还没设置。如果你在 setStorage 之前调用了 log(),程序就会崩溃。所以使用属性注入时,一定要做空指针检查,或者在文档里明确标注“必须先调用 setXXX”。
4.2.3 接口注入
接口注入比较“重”。它要求被注入的类实现一个特定的接口,然后通过这个接口的方法来接收依赖。这种方式在嵌入式里用得不多,但在一些框架层设计中会用到。
// 定义一个注入接口
class IStorageInjector {
public:
virtual void injectStorage(IStorage* storage) = 0;
};
// 业务类实现这个接口
class DataLogger : public IStorageInjector {
private:
IStorage* storage;
public:
void injectStorage(IStorage* s) override {
storage = s;
}
};
接口注入的好处是标准化——所有需要存储的类都实现同一个接口,框架可以统一调用 injectStorage。但坏处也很明显:每个类都要多继承一个接口,代码量上去了。
4.3 DI 容器的概念
手动注入依赖,在小项目里还行。但项目一大了,依赖关系复杂了,手动创建对象、手动注入依赖,就会变成一场噩梦。这时候就需要 DI 容器出场了。
DI 容器,说白了就是一个“自动装配工厂”。你告诉容器:“我需要一个 TemperatureMonitor,它依赖一个 ITemperatureSensor。”容器就会自动创建一个 DS18B20Sensor(或者你配置的其他实现),然后注入到 TemperatureMonitor 的构造函数里,最后把组装好的对象返回给你。
我的经验:在嵌入式领域,我不建议用重量级的 DI 容器框架(比如 Spring 那种)。嵌入式资源有限,跑一个反射机制或者 XML 解析器,代价太大。我通常自己写一个轻量级的“服务定位器”或者“工厂注册表”,用 C++ 的模板或者函数指针来实现简单的 DI 容器。够用就好。
一个极简的 DI 容器核心逻辑大概长这样:
// 极简 DI 容器(伪代码)
class SimpleContainer {
private:
std::map<std::type_index, std::function<void*()>> factories;
public:
template<typename T>
void registerFactory(std::function<T*()> factory) {
factories[typeid(T)] = [factory]() { return factory(); };
}
template<typename T>
T* resolve() {
auto it = factories.find(typeid(T));
if (it != factories.end()) {
return static_cast<T*>(it->second());
}
return nullptr;
}
};
这个容器虽然简陋,但核心思想已经有了:注册工厂、按类型解析。实际项目中,你还可以加上单例模式、生命周期管理、延迟初始化等功能。
4.4 依赖注入在嵌入式中的价值
有人可能会问:“嵌入式系统资源那么紧张,搞这些花里胡哨的设计模式,值得吗?”
我的回答是:值得,但要分场景。
- 单元测试:没有 DI,你很难对硬件相关的代码做单元测试。有了 DI,你可以注入一个 Mock 对象,在 PC 上跑测试,不用烧录到板子上。
- 硬件抽象:DI 天然支持硬件抽象层。你可以在一个平台上用真实驱动,在另一个平台上用模拟驱动,业务代码不用改。
- 代码复用:一个业务逻辑类,只要依赖的是接口,就可以在不同项目间复用。我见过一个 PID 控制算法类,从 STM32 项目直接复制到 ESP32 项目,一行代码没改——因为它的传感器和执行器依赖都是通过构造函数注入的。
一句话总结:依赖注入不是银弹,但它能让你的嵌入式代码从“一次性代码”变成“可维护、可测试、可复用的工程代码”。
4.5 本章知识结构图
下面这张 SVG 图展示了依赖注入的核心概念和三种注入方式的关系。
这张图把本章的核心内容串起来了。从上到下看:依赖注入有三种实现方式,它们都遵循“控制反转”的思想,最终可以通过 DI 容器来简化管理。在嵌入式里,我们更倾向于轻量级的实现方案。
好了,这一章就到这里。DI 的概念其实不难,难的是在实际项目中坚持用。我建议你从下一个模块开始,试着把“new 对象”的地方都改成“构造函数注入”,慢慢你就会体会到它的好处。