依赖注入与控制反转:设备软件里「谁需要谁」怎么不写死

📅 2026/8/15 12:11:52
依赖注入与控制反转:设备软件里「谁需要谁」怎么不写死
这篇解决一个问题设备软件里模块之间互相依赖手臂需要策略、策略需要手臂、搬盘手需要安全手臂。怎么写才不让它们互相#include、互相 new、改一个牵一片。一、先从一个生活例子说起你开一家餐厅。没有依赖注入的写法厨师自己养猪、自己种菜、自己洗碗。厨师要懂养猪、种菜、洗碗的所有细节。猪场换了品种厨师要改养猪方法。洗碗机坏了厨师要修洗碗机。厨师累死而且换个厨师就要重新搭一套猪场菜地。有依赖注入的写法厨师只管炒菜。猪肉有人送来菜有人送来碗有人洗好。厨师不关心猪怎么养的、菜怎么种的、碗怎么洗的。送猪的换了品种厨师不用改炒菜方法。厨师换一个猪场菜地洗碗机都不用动。依赖注入的本质就是你需要什么别人给你送来你自己不生产。在代码里对象需要什么从外面传进来自己不 new。二、回到代码不用依赖注入会怎样设备软件里模块之间依赖关系很常见。以手臂和策略为例最直觉的写法自己 new 自己的依赖class Arm{TransportStrategy* m_transport;public:Arm(){// 手臂自己决定用什么策略m_transport new TargetTransport(this);}};问题一堆问题一手臂知道策略的具体类。Arm的构造函数里写了new TargetTransportArm必须#include TargetTransport.h。换策略要改Arm的构造函数。问题二策略又要用手臂循环依赖。TargetTransport构造函数要传Arm*Arm构造函数里又new TargetTransport(this)。构造顺序写死在Arm里不能独立测试。问题三整盘协作没法配。 Tray 手的策略要知道 BIB 手是谁整盘 clientBIB 手的策略要知道 Tray 手是谁整盘 server。如果手臂自己 new 策略这个互指关系怎么配问题四换设备要改手臂。 设备 A 用 TargetTransport设备 B 用别的策略。手臂自己 new 的换设备要改手臂代码。问题五没法 mock 测试。 测手臂时想用一个假策略但策略是手臂自己 new 的换不了。根本问题对象自己创建自己的依赖导致对象和依赖的具体类绑死。三、依赖注入怎么解决思路对象不自己 new 依赖由外部创建好传进来。第一步手臂不 new 策略只持有指针class Arm{TransportStrategy* m_transport; // 只持有指针public:// 从外面传进来void SetTransport(TransportStrategy* t) { m_transport t; }void WorkFlow(){if (m_transport) m_transport-TransportFlow();}};Arm不#include TargetTransport.h只#include TransportStrategy.h抽象基类。Arm不知道具体是 Target 还是 Fixed。第二步外部创建好所有对象再注入// 初始化代码装配者auto* arm new XArm(1, ArmTray0);auto* arm1 new XArm_BIB(2, ArmBIB0);// 创建策略传入协作对象arm-m_transport new TargetTransport(arm, arm1); // Tray 策略clientarm1arm1-m_transport new FixedTransport(arm1, arm); // BIB 策略serverarm关键变化Arm不 new 策略外部 new 好传进去。策略的协作关系互指也在外部配好。Arm只依赖抽象基类TransportStrategy不依赖具体类。换策略只改初始化代码Arm不改。四、控制反转到底「反转」了什么依赖注入是手段控制反转是思想。没有控制反转对象自己决定用什么依赖自己 new。class Arm{Arm() { m_transport new TargetTransport(this); } // 我自己决定};有控制反转对象不决定由外部决定后传进来。class Arm{void SetTransport(TransportStrategy* t) { m_transport t; } // 你给我什么我用什么};// 外部决定arm-SetTransport(new TargetTransport(arm, arm1));「反转」的是什么是「谁来决定用什么依赖」的控制权。从「对象自己决定」反转为「外部容器/装配者决定」。打个比方没有 IoC厨师自己去冰箱拿食材冰箱里有什么厨师就用什么。有 IoC餐厅把食材配好送到厨师面前厨师不用去冰箱只管用送来的。设备软件里这个「外部装配者」就是Control_InitObj.cpp里的初始化函数。它知道所有对象、所有依赖关系统一创建、统一注入。五、依赖注入的三种方式方式一构造函数注入class Arm{TransportStrategy* m_transport;public:Arm(int id, string name, TransportStrategy* t): m_transport(t) {}};auto* arm new Arm(1, ArmTray0, new TargetTransport(...));优点创建时就有依赖不会忘记注入。缺点参数多时构造函数臃肿运行时换依赖不方便。方式二Setter 注入class Arm{TransportStrategy* m_transport;public:void SetTransport(TransportStrategy* t) { m_transport t; }};auto* arm new Arm(1, ArmTray0);arm-SetTransport(new TargetTransport(...));优点灵活运行时可换。缺点可能忘记调 Setter导致指针为空。方式三接口注入最少见class ITransportUser{public:virtual void SetTransport(TransportStrategy* t) 0;};class Arm : public ITransportUser{public:void SetTransport(TransportStrategy* t) override { m_transport t; }};优点明确声明「我需要被注入策略」。缺点多一个接口设备软件里几乎不用。设备软件里最常用的是 Setter 注入因为初始化时分步创建、分步注入最灵活。六、设备软件里的真实场景场景一手臂注入策略前面讲的例子。手臂不 new 策略外部创建后注入。arm-m_transport new TargetTransport(arm, arm1);场景二搬盘手注入安全手臂搬盘手需要知道「谁是安全检查手臂」避让时检查对方在不在但不自己决定由外部注入。m_pCatchTankTrayArm-m_pSafeArm m_pArmSortBin;搬盘手不#include SortingArm.h只持有ArmBase*指针。换安全手臂只改初始化代码。场景三策略注入合流站分选手策略需要知道合流 Buffer 是谁外部传入。m_pArmSortBin-m_transport new SortICTransport(m_pArmSortBin, m_pBufferMergeBin);SortICTransport构造函数接收Station*不自己找合流站。场景四扫码模块注入通信接口扫码模块需要通信能力但不关心是串口还是网口。class Scanner{IComm* m_comm; // 通信接口public:void SetComm(IComm* c) { m_comm c; }bool Scan(string code) { m_comm-Send(TRIGGER); code m_comm-Recv(); }};// 串口扫码枪auto* scanner new Scanner();scanner-SetComm(new SerialComm(COM1, 9600));// 网口扫码枪auto* scanner2 new Scanner();scanner2-SetComm(new TcpComm(192.168.1.50, 5000));同一个扫码模块串口网口都能用换通信方式只换注入的IComm。场景五报警输出注入数据通道报警模块要输出到日志、数据库、SECS但不自己创建这些通道。class AlarmHandler{vectorIDataOutput* m_outputs;public:void AddOutput(IDataOutput* out) { m_outputs.push_back(out); }void Alarm(string msg){for (auto* out : m_outputs) out-Write(msg);}};// 项目A日志 数据库alarm-AddOutput(new FileLogOutput(D:\\logs\\alarm.log));alarm-AddOutput(new DatabaseOutput(alarm.db));// 项目B日志 SECSalarm-AddOutput(new FileLogOutput(D:\\logs\\alarm.log));alarm-AddOutput(new SecsOutput(secsConfig));报警模块不知道有几种输出全靠注入。加一种输出只AddOutput报警模块不改。场景六测试台注入测试项目测试台要测什么项目由外部配置注入。class Tester{vectorITestItem* m_tests;public:void AddTestItem(ITestItem* item) { m_tests.push_back(item); }TestResult Run(){for (auto* t : m_tests) t-Run();}};// 产品Atester-AddTestItem(new VoltageTest(3.0, 5.0));tester-AddTestItem(new CurrentTest(0.1, 0.5));// 产品Btester-AddTestItem(new FunctionTest());tester-AddTestItem(new WithstandTest(1500, 60));换产品只换注入的测试项测试台代码不改。七、设备软件里的「装配者」长什么样在 Java/C# 的 IoC 框架里有「容器」自动管理依赖。设备软件里没有框架但有一个天然的装配者初始化函数。void CControl::InitMachine(){// 1. 创建所有对象还不互相依赖auto* arm new XArm(1, ArmTray0);auto* arm1 new XArm_BIB(2, ArmBIB0);auto* sortBin new SortingArm(3, ArmSortBin);auto* catchArm new CatchTrayArm(4, CatchArm);auto* buffer new Buffer(5, Buffer0);auto* scanner new Scanner();// 2. 创建依赖对象auto* targetTransport new TargetTransport(arm, arm1);auto* fixedTransport new FixedTransport(arm1, arm);auto* sortTransport new SortICTransport(sortBin, buffer);auto* serialComm new SerialComm(COM1, 9600);// 3. 注入依赖arm-m_transport targetTransport;arm1-m_transport fixedTransport;sortBin-m_transport sortTransport;catchArm-m_pSafeArm sortBin;scanner-SetComm(serialComm);// 4. 注册到全局Actor::g_Actors.push_back(arm);Actor::g_Actors.push_back(arm1);// ...}这个函数就是「装配者」它知道所有对象、所有依赖关系统一创建、统一注入。对象之间不互相知道对方怎么来的只管用传进来的指针。装配者的职责创建所有对象。理清依赖关系。把依赖注入到需要的地方。注册到全局注册表。装配者不该做的事不该写业务逻辑。不该调硬件动作。不该做流程判断。装配者只管「谁需要谁」不管「谁干什么」。八、依赖注入解决了什么问题不用 DI用 DI对象和依赖绑死Arm 里 new Target外部 new 后注入换依赖要改对象改 Arm 构造函数改初始化代码循环依赖构造顺序写死外部控制创建顺序测试没法 mock 依赖注入 mock 对象跨设备复用对象绑死设备对象只依赖接口跨设备复用一句话依赖注入把「创建依赖」的控制权从对象内部移到外部对象只依赖抽象接口不依赖具体实现。九、依赖注入 vs 全局变量 vs 直接 new这三种都能解决「对象需要另一个对象」但耦合度不同。// 方式一全局变量耦合最高void Arm::WorkFlow(){g_pTransport-TransportFlow(); // 隐式依赖全局谁都能改}// 方式二自己 new耦合中class Arm{Arm() { m_transport new TargetTransport(this); } // 依赖具体类};// 方式三依赖注入耦合最低class Arm{void SetTransport(TransportStrategy* t) { m_transport t; } // 只依赖抽象};全局变量自己 new依赖注入耦合最高中最低可见性隐式显式但写死显式且灵活可换不行编译期改运行时换可测不行不行可 mock适合注册表/总控唯一选择时多种选择时不是所有依赖都该用 DI。总控入口用全局变量g_Controller唯一选择的依赖用自己 new多种选择的依赖用 DI。十、依赖注入的坑坑一注入空指针auto* arm new Arm(1, ArmTray0);// 忘了 SetTransportarm-WorkFlow(); // m_transport 是 nullptr崩对策WorkFlow 里判空或初始化后统一检查所有必要依赖是否注入完毕。坑二注入后对象生命周期不匹配void Foo(){auto* transport new TargetTransport(...);arm-SetTransport(transport);} // transport 是局部变量函数结束指针悬空// arm 还在用 transport野指针对策依赖对象的生命周期要长于被注入对象。通常在初始化阶段创建所有对象程序退出才销毁不会悬空。坑三装配者变成上帝函数初始化函数里创建几十个对象、注入几十个依赖几百行。改一个依赖要在几百行里找。对策按模块拆装配函数void InitArms(); // 装配手臂void InitChannels(); // 装配料道void InitTesters(); // 装配测试台void InitSafety(); // 装配安全每个子函数管自己的模块主函数只调子函数。坑四过度注入不是所有依赖都该注入。如果只有一个选择直接在构造函数里 new 更简单。// 不必注入手臂永远需要 Z 轴没有第二种选择class Arm{Arm() { m_zAxis new Axis(...); } // 直接 new 更清晰};// 该注入策略有多种选择class Arm{void SetTransport(TransportStrategy* t) { m_transport t; }};十一、可复用结论依赖注入的本质对象不自己 new 依赖由外部创建好传进来。对象只依赖抽象接口不依赖具体实现。控制反转的本质「谁来决定用什么依赖」的控制权从对象内部反转到外部装配者。设备软件里的装配者初始化函数Control_InitObj.cpp统一创建、统一注入、统一注册。三种注入方式构造函数注入创建时确定、Setter 注入运行时可换、接口注入少用。设备软件最常用 Setter。六大应用场景手臂注入策略、搬盘手注入安全手臂、策略注入合流站、扫码注入通信、报警注入输出、测试台注入测试项。和全局变量/直接 new 的分工注册表用全局唯一选择直接 new多种选择用 DI。坑判空防注入遗漏、生命周期要匹配、装配函数按模块拆、别过度注入。依赖注入在设备软件里不是「Spring 框架的 DI 容器」而是「初始化时谁需要谁就传给谁」的朴素做法。用对了换策略换硬件换通信只改初始化代码业务模块一行不改用错了要么对象之间互相 new 互相 include 改不动要么装配函数变成上帝函数比业务代码还长。关键是分清「这个依赖只有一种选择还是多种选择」多种选择才注入唯一选择直接 new。