3. 依赖倒置原则(DIP)精讲

依赖倒置原则,英文叫 Dependency Inversion Principle,简称 DIP。很多工程师一听这名字,觉得挺玄乎。其实说白了,就两句话:

  • 高层模块不应该依赖低层模块,两者都应该依赖抽象。
  • 抽象不应该依赖细节,细节应该依赖抽象。

我刚开始做嵌入式那几年,对这句话的理解也停留在字面上。直到有一次,我负责一个数据采集系统,底层换了三次传感器,每次都要改上层的处理逻辑。改到最后,我自己都分不清哪段代码是干嘛的了。嗯,从那以后,我才真正明白 DIP 的分量。

3.1 什么是依赖倒置?

传统的软件设计,高层模块会直接调用低层模块。比如你的业务逻辑直接操作 GPIO 寄存器,或者直接调用某个具体的驱动函数。这种依赖关系是自上而下的,高层依赖低层。

依赖倒置,就是把这种关系反过来。让高层和低层都去依赖一个抽象的接口。高层不关心底层怎么实现,底层也不关心高层怎么用它。它们只认接口。

核心思想: 面向接口编程,而不是面向实现编程。

我个人的习惯是,在项目启动阶段,先定义好接口,再考虑实现。这样即使后面换硬件平台,换驱动库,上层代码基本不用动。

3.2 高层模块与低层模块的解耦

我们来看一个反面教材。假设你有一个温度采集系统,高层模块是温度监控器,低层模块是某个具体的温度传感器驱动。

// 低层模块:具体传感器驱动
class DS18B20 {
public:
    float readTemperature() {
        // 操作 GPIO,读取 1-Wire 协议数据
        return 25.5;
    }
};

// 高层模块:温度监控器
class TemperatureMonitor {
private:
    DS18B20 sensor;  // 直接依赖具体实现
public:
    void checkTemperature() {
        float temp = sensor.readTemperature();
        if (temp > 30.0) {
            // 触发报警
        }
    }
};

这段代码有什么问题?问题大了。如果明天你把传感器换成 SHT30,或者换成 NTC 热敏电阻,你就得改 TemperatureMonitor 类的内部代码。这就是典型的紧耦合。

我曾经在一个项目中见过类似的代码,底层驱动换了三次,上层业务逻辑被改得面目全非,最后不得不重构整个模块。那感觉,就像你刚装修完房子,发现水管走错了,得砸墙重来。

怎么解耦?引入一个抽象接口。

// 抽象接口
class ITemperatureSensor {
public:
    virtual float readTemperature() = 0;
    virtual ~ITemperatureSensor() {}
};

// 低层模块:具体实现
class DS18B20 : public ITemperatureSensor {
public:
    float readTemperature() override {
        // 操作 GPIO,读取 1-Wire 协议数据
        return 25.5;
    }
};

class SHT30 : public ITemperatureSensor {
public:
    float readTemperature() override {
        // 操作 I2C 总线,读取 SHT30 数据
        return 26.3;
    }
};

// 高层模块:只依赖抽象
class TemperatureMonitor {
private:
    ITemperatureSensor* sensor;  // 依赖抽象
public:
    TemperatureMonitor(ITemperatureSensor* s) : sensor(s) {}
    void checkTemperature() {
        float temp = sensor->readTemperature();
        if (temp > 30.0) {
            // 触发报警
        }
    }
};

你看,现在 TemperatureMonitor 不再依赖具体的传感器。它只认 ITemperatureSensor 这个接口。你传给它 DS18B20 它就读 DS18B20,传给它 SHT30 它就读 SHT30。这就是解耦。

我的建议: 在嵌入式项目中,接口尽量设计成纯虚类(C++)或者函数指针结构体(C语言)。不要在里面塞具体的业务逻辑,只定义契约。

3.3 抽象接口的设计技巧

接口设计得好不好,直接决定了你的系统能灵活到什么程度。我总结了几个实战中常用的技巧。

3.3.1 接口要小,职责要单一

一个接口只做一件事。不要搞一个大而全的接口,里面什么方法都有。比如温度传感器接口,就只定义读温度的方法。不要在里面加什么初始化、校准、自检之类的东西。那些可以放到另一个接口里,或者通过组合的方式实现。

// 好的设计:接口职责单一
class ITemperatureSensor {
public:
    virtual float readTemperature() = 0;
};

// 不好的设计:接口太臃肿
class ISensor {
public:
    virtual float readTemperature() = 0;
    virtual void initialize() = 0;
    virtual void calibrate() = 0;
    virtual void selfTest() = 0;
};

3.3.2 参数和返回值要通用

接口的参数和返回值,尽量用标准类型,不要用跟具体硬件绑定的类型。比如返回温度,就用 float 或者 int,不要返回某个寄存器的原始值。原始值怎么换算,那是具体实现的事,接口不关心。

避坑指南: 我曾经见过一个接口,返回值是一个结构体,里面包含了传感器的型号、批次号、固件版本。结果每次换传感器,上层都要跟着改结构体。这就是接口设计没做好,把不该暴露的细节暴露了。

3.3.3 考虑资源受限环境

嵌入式系统资源有限,接口设计不能太花哨。比如虚函数会带来一定的性能开销,在极端实时场景下要慎重。我个人的做法是:

  • 对于实时性要求高的模块,用函数指针结构体代替虚函数。
  • 对于非实时模块,用 C++ 纯虚类,代码可读性更好。
// C 语言风格的接口设计
typedef struct {
    float (*readTemperature)(void);
    int (*initialize)(void);
} TemperatureSensorInterface;

3.3.4 接口要稳定,不要频繁变动

接口一旦发布,就不要轻易改。因为接口的消费者可能有很多个。你改一个接口,所有依赖它的模块都得跟着改。所以接口设计要经过充分的推敲,最好在项目早期就定下来。

我记得有一次,一个同事在项目中期改了一个接口的签名,结果导致三个模块需要同步修改,测试用例全部重跑。那周的加班,大家都记忆犹新。

3.4 DIP 在嵌入式中的典型应用场景

场景 传统做法(紧耦合) DIP 做法(松耦合)
传感器驱动 业务逻辑直接调用具体传感器 API 定义 ISensor 接口,业务逻辑只依赖接口
通信协议 应用层直接操作 UART/SPI 寄存器 定义 ITransport 接口,应用层只调用 send/receive
存储模块 数据管理直接调用 Flash 驱动 定义 IStorage 接口,数据管理只依赖接口
显示模块 业务逻辑直接操作 LCD 控制器 定义 IDisplay 接口,业务逻辑只调用 draw/refresh

这些场景,你想想看,是不是每个嵌入式项目都会遇到?只要用 DIP 把接口抽出来,后续的维护、测试、移植都会轻松很多。

3.5 依赖倒置的核心逻辑图

下面这张图,展示了依赖倒置前后的对比。左边是传统设计,高层直接依赖低层;右边是 DIP 设计,高层和低层都依赖抽象接口。

依赖倒置原则(DIP)核心逻辑 传统设计(紧耦合) 高层模块 低层模块(具体实现) 高层直接依赖低层 换低层必须改高层 DIP 设计(松耦合) 高层模块 抽象接口 低层模块(具体实现) 高层和低层都依赖抽象 换低层不影响高层

你仔细看右边的图。高层模块不再直接指向低层模块,而是指向中间的抽象接口。低层模块也指向抽象接口。这样一来,高层和低层之间就没有直接依赖关系了。这就是依赖倒置的精髓。

一句话总结: 依赖倒置不是说不依赖,而是让依赖关系指向更稳定的抽象层,而不是易变的具体实现。

好了,这一章的内容就到这里。DIP 是接口隔离和依赖注入的基础,理解透了,后面的内容你会学得很快。