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 构造函数注入 属性注入 接口注入 核心思想:类不自己创建依赖,由外部注入 控制反转(IoC) → 可测试、可替换、可复用 DI 容器:自动装配依赖 嵌入式建议:使用轻量级工厂或服务定位器,避免重量级框架

这张图把本章的核心内容串起来了。从上到下看:依赖注入有三种实现方式,它们都遵循“控制反转”的思想,最终可以通过 DI 容器来简化管理。在嵌入式里,我们更倾向于轻量级的实现方案。

好了,这一章就到这里。DI 的概念其实不难,难的是在实际项目中坚持用。我建议你从下一个模块开始,试着把“new 对象”的地方都改成“构造函数注入”,慢慢你就会体会到它的好处。