1⃣ 减少功能模块之间的耦合度(Decreases coupling between functionality blocks)
理解:
在传统设计中,一个类或函数通常会直接创建或依赖另一个类,这会造成强耦合(tight coupling)。例如:
classService{Repository repo;// 直接依赖具体实现public:voiddoWork(){repo.save();}};这种设计中,Service和Repository紧密绑定在一起,如果Repository改变,Service也必须修改。
依赖注入的做法:
将依赖作为参数传入或通过框架注入,降低耦合度:
classService{Repository&repo;// 依赖通过注入获得public:Service(Repository&r):repo(r){}voiddoWork(){repo.save();}};数学形式化表示:
假设一个函数或类FFF依赖某个组件DDD,原设计中:
F=f(D) F = f(D)F=f(D)
其中FFF内部直接创建或调用DDD,耦合度高。使用依赖注入后,DDD由外部传入:
F(D)=f(D),D由外部注入 F(D) = f(D), \quad D \text{由外部注入}F(D)=f(D),D由外部注入
此时FFF和DDD的耦合度降低,因为FFF不再控制DDD的创建。
2⃣ 使类/函数独立于其底层依赖(Independence of underlying dependencies)
理解:
依赖注入的核心思想是“依赖由外部提供”,而不是内部创建。这样,一个类或函数就可以独立于其实现细节。例如,Service不关心Repository是数据库实现还是内存实现,只需要接口即可。
数学化理解:
设接口为III,具体实现为CCC,原设计中:
F=f(C) F = f(C)F=f(C)
耦合了具体实现。使用依赖注入后:
F(I)=f(I) F(I) = f(I)F(I)=f(I)
此时FFF只依赖接口III,而具体实现CCC可以任意替换:
C1,C2,…,Cn∈Implementations of I C_1, C_2, \dots, C_n \in \text{Implementations of } IC1,C2,…,Cn∈Implementations ofI
3⃣ 提高代码的复用性和可测试性(Reusability & Testability)
理解:
- 复用性:因为类或函数只依赖接口,不依赖具体实现,所以可以在不同场景下复用。
- 可测试性:在单元测试中,可以注入 mock 或 stub 对象,而不需要依赖真实复杂环境。
数学表达:
原函数:
F=f(Dreal) F = f(D_\text{real})F=f(Dreal)
测试时需要真实依赖DrealD_\text{real}Dreal,难度大。使用依赖注入:
F(Dmock)=f(Dmock) F(D_\text{mock}) = f(D_\text{mock})F(Dmock)=f(Dmock)
这里DmockD_\text{mock}Dmock可以是任意模拟对象,使得FFF的行为可控和可测试。
4⃣ 改善代码的长期可维护性(Better long-term maintainability)
理解:
- 当依赖变化时,不需要修改使用依赖的类,只需要修改注入的具体实现。
- 便于扩展,符合开闭原则(Open/Closed Principle):对扩展开放,对修改封闭。
数学化理解:
假设依赖变化:
Dold→Dnew D_\text{old} \to D_\text{new}Dold→Dnew
原设计中:
F=f(Dold)⇒F必须修改 F = f(D_\text{old}) \Rightarrow F \text{必须修改}F=f(Dold)⇒F必须修改
依赖注入后:
F(Dnew)=f(Dnew) F(D_\text{new}) = f(D_\text{new})F(Dnew)=f(Dnew)
只需更换注入对象,无需修改FFF,从而提高可维护性。
总结
依赖注入本质上是一种将依赖外部化、降低耦合、提高灵活性和可测试性的设计模式。数学上可理解为从具体依赖CCC转变为接口依赖III,使得函数或类成为对具体实现透明的抽象:
F(C)→Dependency InjectionF(I) F(C) \xrightarrow{\text{Dependency Injection}} F(I)F(C)Dependency InjectionF(I)
1⃣ 定义与做法
在没有依赖注入的情况下,要测试某个模块或函数,必须构建一个“几乎完整的环境”。这个环境包含了:
- 配置(configs)
- 数据库(databases)
- 各种组件(components)
即使只是想测试一个小功能,也要把整个系统的关键部分都搭起来。
数学化表达:
假设系统有nnn个模块:
S=M1,M2,…,Mn S = {M_1, M_2, \dots, M_n}S=M1,M2,…,Mn
如果要测试模块MiM_iMi,在没有依赖注入时,需要构建至少一个“完整环境”EEE:
E⊆S且 Mi∈E E \subseteq S \quad \text{且 } M_i \in EE⊆S且Mi∈E
通常EEE很大,即使MiM_iMi只是其中一个模块。
2⃣ 测试性质
- 这种方法不是单元测试(Unit Testing),而是集成测试(Integration Testing)。
- 测试过程会同时触发系统中多个模块的行为。
- 测试目标可能很模糊,因为错误可能源于任何依赖模块,而不仅仅是被测模块。
数学化表达:
假设被测模块为MiM_iMi,其他模块为MjM_jMj(j≠ij \neq ij=i),测试输出为OOO:
O=f(M1,M2,…,Mi,…,Mn) O = f(M_1, M_2, \dots, M_i, \dots, M_n)O=f(M1,M2,…,Mi,…,Mn)
其中MiM_iMi是关注模块,但输出OOO受到所有模块的影响,因此难以定位错误。
3⃣ 缺点分析
① 缺乏针对性(Lack of specificity)
理解:
错误可能来源于系统的任何部分,无法精确定位到被测模块。
ErrorSource∈M1,M2,…,Mn \text{ErrorSource} \in {M_1, M_2, \dots, M_n}ErrorSource∈M1,M2,…,Mn
② 设置与排错更困难(More difficult setup / error investigation)
理解:
为了构建“完整环境”,测试人员需要准备各种配置、数据库和依赖组件,复杂度高。
数学表示环境依赖关系:
E=f(C,DB,Components) E = f(C, DB, \text{Components})E=f(C,DB,Components)
其中CCC为配置,DBDBDB为数据库,Components 为其他模块。任何一个部分配置错误,都会导致整个测试失败。
③ 反馈周期更长(Longer feedback loop)
理解:
- 构建环境和运行测试需要较长时间。
- 小改动也可能触发完整环境重建,反馈慢。
Tfeedback≫Tunit test T_\text{feedback} \gg T_\text{unit\ test}Tfeedback≫Tunit test
④ 可能完全依赖回归 A/B 测试(Can lean entirely on regression A/B testing)
理解:
- 由于难以单独测试某模块,测试往往依赖对比系统整体表现的回归测试。
- 这类似于“黑盒测试”,只关注系统输出变化,而非内部逻辑。
数学上可以理解为:
ΔO=Onew−Oold \Delta O = O_\text{new} - O_\text{old}ΔO=Onew−Oold
只检测输出变化,而不分析模块内部的错误。
总结
Pocket Universe Testing的特点是:
- 必须搭建几乎完整的环境才能测试一个小模块。
- 测试覆盖系统多个模块,属于集成测试而非单元测试。
- 缺点明显:
- 难以定位错误
- 设置复杂
- 反馈慢
- 依赖整体回归测试
数学化理解总结:
- 系统S=M1,M2,…,MnS = {M_1, M_2, \dots, M_n}S=M1,M2,…,Mn
- 被测模块MiM_iMi
- 测试输出O=f(M1,M2,…,Mn)O = f(M_1, M_2, \dots, M_n)O=f(M1,M2,…,Mn)
- 错误定位困难,反馈周期长
依赖注入(Dependency Injection)的本质
核心一句话
依赖注入不是一种“技术”,而是一种“函数式抽象”:
把依赖从实现中抽离出来,使组件只基于“输入 → 输出”的纯接口运作。
1. Usage(用途层面)
使用 DI 的目的不是减少代码,而是约束依赖的形状,让系统更像数学函数:
数据进来 → 结果出去(Data In → Results Out)
中间的依赖不由函数自己创建,而是由外界提供。
你可以用数学形式表示:
f(x;d)→y f(x; d) \rightarrow yf(x;d)→y
- xxx:业务输入(data in)
- ddd:外部注入的依赖(dependency)
- yyy:函数结果(results out)
DI 的关键在于:函数不再自己构造ddd,而是别人给它。
2. Interface(接口)
DI最关键的部件是依赖的“接口”,因为:
- 你不是注入“东西”(class/ object)
- 你注入的是行为抽象(interface)
数学上可以理解为注入一个算子(operator):
d:A→B d: A \rightarrow Bd:A→B
于是业务函数变成组合(composition):
f(x;d)=g(x,d(x)) f(x; d) = g(x, d(x))f(x;d)=g(x,d(x))
这称为:
接口捕获行为,而不是捕获实现。
3. Capture(捕获)
DI 的本质行为就是:
捕获(capture)调用点环境中的依赖,并绑定为一个参数。
有点像 λ 演算的“闭包”:
λx.f(x,d) \lambda x. f(x, d)λx.f(x,d)
只不过:
- 闭包自动捕获
- DI 人工捕获(constructor / setter / injector)
等价类比:
工厂注入 = 手写闭包绑定
构造注入 = 强制显式闭包参数
Service Locator = 把捕获反过来放到运行时(被视为反模式)
4. Synthesize(合成)
把依赖注入后,系统中的所有函数和组件不再是互相 new,而是变成:
可组合的纯函数图(composable graph)
它们变成数学上的一个有向无环图 DAG:
Component=f1∘f2∘f3∘… Component = f_1 \circ f_2 \circ f_3 \circ \dotsComponent=f1∘f2∘f3∘…
DI 容器(container)实际上做的就是:
- 为每个节点提供依赖参数
- 合成(synthesize)整个“函数网络”
这个过程完全没有魔法,本质就是:
构造函数参数绑定的自动图合成
5. Implementation(实现层面)
使用 DI 意味着实现代码必须遵守一个原则:
实现中不能自己 new 依赖
否则就违反了数学模型,把隐含依赖塞进函数内部,使其不可组合。
你希望的是:
classController{public:Controller(IRepository&repo):repo(repo){}Resultrun(Input x){returnrepo.query(x);}private:IRepository&repo;};而不是:
Controller::Controller(){repo=newSqlRepo();//}区别就是:
- 前者:显式依赖,可组合
- 后者:隐式依赖,无法测试,不可替换
6. No Side Effects(无副作用)
这不是字面含义的“没有 side effect”
而是:
依赖产生的副作用不留在函数内部,而是被“推”到外部世界。
这让组件在数学意义上变成更接近“纯函数”:
y=f(x;d) y = f(x; d)y=f(x;d)
而不是:
y=f(x)+hidden side effects y = f(x) + \text{hidden side effects}y=f(x)+hidden side effects
DI 的目标不是完全纯函数,而是:
让副作用集中在依赖中,而不是散落在业务逻辑中。
所以:
- 数据库连接 = 副作用
- HTTP 客户端 = 副作用
- 这些副作用被注入进来,而不是在函数里隐式创建
从而使: - 测试简单(mock 掉 d 即可)
- 逻辑清晰(业务只处理 x→y,不碰副作用)
- 组合可能(d 可替换)
总结成一句最本质的话
Dependency Injection = 显式传递依赖,使系统趋近于数学函数组合。
用数学表达:
DI:f(x)⟶f(x;d) \text{DI}: \quad f(x) \longrightarrow f(x; d)DI:f(x)⟶f(x;d)
把隐式依赖ddd显式化,使组件达到:
- 可测试
- 可组合
- 可替换
- 接口驱动
- 无隐藏副作用
这才是 DI 的“精髓”,而不是“用容器创建对象”。
Dependency Injection 系统分析(详细解读)
一、总体架构抽象(高层心智模型)
把系统视为一组组件(Component)与依赖/服务(Dependency / Service),并由 DI(依赖注入器)负责把正确的实现注入到需要它们的组件上。你的流程可以抽象为两层注入:
- Dependency Injection(依赖注入):把基础服务实现(Base Implementations,如 Pricing、Sending、Sizing)注入到系统中,被多个更高层组件复用。
- Component Injection(组件注入):把这些注入好的基础服务进一步组合或注入到具体业务组件(如 Marshalling、CancelTrade、RepriceTrade、ExecuteTrade)中。
用数学化的函数表示组件与依赖的组合: - 组件CCC是对输入xxx和注入依赖集合DDD的映射:
C:(x;D)↦yC: (x; D) \mapsto yC:(x;D)↦y - 整个系统是组件组合的有向无环图(DAG):
System=Cn∘Cn−1∘⋯∘C1System = C_n \circ C_{n-1} \circ \dots \circ C_1System=Cn∘Cn−1∘⋯∘C1
二、关键实体角色(Data Holders / Broker / Security / Trade)
把这些视为“数据输入源”或“上下文”(context/state):
- Data Holders:持久化或瞬态数据容器,比如订单簿、市场数据缓存、配置参数等。对外提供只读或读写接口HHH。
- Broker:路由/中介,负责把订单发送到正确的执行端。可抽象为算子BBB:
B(order,ctx)↦sendresultB(order, ctx) \mapsto send_resultB(order,ctx)↦sendresult - Security:标的物信息(合约、符号、交易规则)和权限控制。提供查询接口SSS。
- Trade:业务交易对象/消息,包含 tradeId、qty、price、side 等。记作TTT。
这些实体既是Data In(业务输入),也是 DI 注入中“上下文依赖”的一部分。
三、把“依赖注入”数学化(Dependency Injection 层)
1) 依赖/组件函数模型
把 Base Implementations(基础实现)视为一组函数/算子集合D=d1,d2,…D = {d_1, d_2, \dots}D=d1,d2,…,例如:
- dpriced_{price}dprice:定价器(Pricing)
- dsendd_{send}dsend:发送器(Sending)
- dsized_{size}dsize:拆分/尺量器(Sizing)
业务组件(例如 ExecuteTrade)是这些算子的组合:
ExecuteTrade(T;D,H,B,S)=dsend(,dprice(dsize(T,H),S),B,) ExecuteTrade(T; D, H, B, S) = d_{send}( , d_{price}( d_{size}(T, H), S ), B , )ExecuteTrade(T;D,H,B,S)=dsend(,dprice(dsize(T,H),S),B,)
解释:先用dsized_{size}dsize根据规则把TTT拆解/合理化,再用dpriced_{price}dprice估价,最后用dsendd_{send}dsend送出并借助 BrokerBBB。
2) DI 的职责
DI 的任务是把DDD的具体实现diimpld_i^{impl}diimpl绑定到组件的形式化依赖上:
DI:interface i↦diimpl DI: { \text{interface } i } \mapsto { d_i^{impl} }DI:interfacei↦diimpl
运行时,组件接收到的是实现后的函数(算子),于是系统变为纯函数组合的实际执行图。
四、Component Injection 层(业务组件注入与生命周期)
在 Component Injection 阶段,DI 把具体实现注入到业务组件中(例如 Marshalling、CancelTrade、RepriceTrade、ExecuteTrade):
- Marshaller:负责把 Trade 转为消息格式(binary/json/protobuf),可依赖
SchemaService、Config。
Marshall(T;schema)↦messageMarshall(T; schema) \mapsto messageMarshall(T;schema)↦message - CancelTrade:需要
Broker、Auth、Repo(查找原始 Trade)等依赖。 - RepriceTrade:需要
Pricing、MarketData、RiskService。 - ExecuteTrade:如上所述组合 Pricing/Sizing/Sending。
注入方式:常见三类
- 构造注入(Constructor):组件在构造时获得所有依赖(推荐用于不可变依赖)。
C=new Component(d1impl,d2impl)C = \text{new } Component(d_1^{impl}, d_2^{impl})C=newComponent(d1impl,d2impl) - 设值注入(Setter):运行后可替换依赖(适合可热切换的策略)。
- 接口注入 / 运行时解析(Service Locator):组件在运行时向容器索取依赖(一般不推荐,隐藏依赖)。
五、数据流示意(从 Data In 到 Execute)
把你的箭头展开为数据流步骤,既有数学表示也有文字说明:
- 输入:TTT(Trade),HHH(Data Holders),SSS(Security),BBB(Broker)
- 组件注入:DIDIDI注入dsize,dprice,dsendd_{size}, d_{price}, d_{send}dsize,dprice,dsend到ExecuteTradeExecuteTradeExecuteTrade
ExecuteTrade(T)≡ExecuteTrade(T;dsize,dprice,dsend,H,B,S)ExecuteTrade(T) \equiv ExecuteTrade(T; {d_{size}, d_{price}, d_{send}}, H, B, S)ExecuteTrade(T)≡ExecuteTrade(T;dsize,dprice,dsend,H,B,S) - 处理流水线(函数组合):
T′=dsize(T,H) P=dprice(T′,S) m=Marshall(T′,P) r=dsend(m,B) return r \begin{aligned} &T' = d_{size}(T, H) \ &P = d_{price}(T', S) \ &m = Marshall(T', P) \ &r = d_{send}(m, B) \ &\text{return } r \end{aligned}T′=dsize(T,H)P=dprice(T′,S)m=Marshall(T′,P)r=dsend(m,B)returnr
这里每一步都是一个纯接口调用(副作用集中在d_send, 数据库写入等处)。 - Cancel / Reprice / Marshall类似,只是组合顺序与依赖集合不同。
六、实现细节、设计要点与工程考虑
1) 生命周期与作用域(Scope)
- Singleton(单例):像 MarketDataCache、PricingEngine(有状态但共享)
- Transient(短生命周期):请求级别的
ExecuteTrade对象 - Scoped(会话/事务级):在一个交易会话中的依赖共享
用数学上说,依赖的状态sss随作用域不同被共享或隔离:
dimpl(x;sscope) d^{impl}(x; s_{scope})dimpl(x;sscope)
2) 无副作用 & 可测性
- 业务逻辑尽量遵循纯函数风格:y=f(x;d)y = f(x; d)y=f(x;d)。
- 把副作用(网络、DB、日志)放到依赖实现里(
d_send、Repo),便于 mock。
3) 并发与线程安全
- 单例依赖需保证线程安全(例如定价缓存的读写锁或无锁结构)。
- 若依赖维护会话状态,应使用 Scoped 生命周期或显式 session 对象。
4) 事务与补偿(Transactional / Idempotency)
- ExecuteTrade、CancelTrade、RepriceTrade 可能跨外部系统:要设计幂等与补偿逻辑(saga pattern)。
- 标记已提交/回滚的状态保存在 Repo(Data Holder),并由依赖实现保证一致性。
5) 安全(Security)
Auth、ACL作为注入依赖,业务代码只调用接口验证:
allowed=Auth.check(user,action,resource)\text{allowed} = Auth.check(user, action, resource)allowed=Auth.check(user,action,resource)- 加密/审计(Audit)也作为可注入横切依赖。
6) 性能 & 延迟敏感设计
- 定价或市场数据请求应支持异步/批量化,依赖返回 Future/Promise:
dprice(T)→Future[P]d_{price}(T) \rightarrow \text{Future}[P]dprice(T)→Future[P] - DI 容器应支持异步初始化(lazy / eager)以控制启动时间与资源占用。
七、示例伪代码(表示依赖注入如何组织)
// 注册依赖(Dependency Injection) container.registerSingleton(MarketDataCache) container.registerSingleton(BrokerImpl) container.registerTransient(PricingService -> PricingImpl) container.registerTransient(SizingService -> SizingImpl) container.registerTransient(SendingService -> SendingImpl) // 组件注入 container.registerFactory(ExecuteTrade, (c) => { return new ExecuteTrade( c.resolve(SizingService), c.resolve(PricingService), c.resolve(SendingService), c.resolve(Broker), c.resolve(MarketDataCache) ) }) // 使用 executor = container.resolve(ExecuteTrade) result = executor.run(trade)八、错误处理、重试与回滚策略
- 发送失败(d_send):提供幂等 retry(指数退避)与最终告警;必要时执行
CancelTrade或补偿事务。 - 定价失败:可回退到最近一次可靠价格或拒绝执行并记录审计。
- 并发改价(Reprice):RepriceTrade 应保证重放安全(idempotent)。数学上,幂等性意味着:
∀T,;Reprice(Reprice(T))=Reprice(T)\forall T,; Reprice(Reprice(T)) = Reprice(T)∀T,;Reprice(Reprice(T))=Reprice(T)
九、测试策略(表征测试 + 单元/集成测试)
- 表征测试(characterization testing):记录系统在给定实现下的行为(尤其对于遗留实现)。DI 能让你在替换实现时把行为差异降到最小,从而更容易做表征测试。
- 单元测试:Mock
d_price,d_send,Broker,测试 ExecuteTrade 的纯逻辑。 - 集成测试:用真实或近似实现(例如 test Broker)验证跨组件流程(Marshalling → Send → Repo 写入)。
十、设计建议(工程实践小结)
- 明确接口(interface-first):优先设计行为契约,再实现注入。
- 把副作用封装进依赖,业务逻辑保持纯净。
- 优先构造注入,辅助用 setter 进行可选/热替换。
- 使用作用域管理(singleton/scoped/transient)明确资源边界。
- 实现异步/非阻塞接口应对定价和发送的延迟特性。
- 为安全、审计、幂等、回滚设计可注入策略(比如
AuditService,IdempotencyKeyService)。 - 保持 DI 容器配置清晰并在启动时验证依赖图(防止循环依赖)。
十一、把你的原始图映射成公式(最后汇总)
用符号化流水线表示你提供的箭头关系:
- 原始输入集合:H,B,S,T{H, B, S, T}H,B,S,T
- 基础实现集合:D=dprice,dsend,dsizeD = {d_{price}, d_{send}, d_{size}}D=dprice,dsend,dsize
- 组件集合:C=Marshall,CancelTrade,RepriceTrade,ExecuteTradeC = {\text{Marshall}, \text{CancelTrade}, \text{RepriceTrade}, \text{ExecuteTrade}}C=Marshall,CancelTrade,RepriceTrade,ExecuteTrade
系统执行为:
ExecuteTrade(T)=dsend(Marshall(dprice(dsize(T,H),S)),B) \text{ExecuteTrade}(T) = d_{send}\big( Marshall( d_{price}( d_{size}(T, H), S ) ), B \big)ExecuteTrade(T)=dsend(Marshall(dprice(dsize(T,H),S)),B)
其它组件同理,例如:
CancelTrade(T)=dsend(MarshallCancel(T),B) \text{CancelTrade}(T) = d_{send}\big( MarshallCancel(T), B \big)CancelTrade(T)=dsend(MarshallCancel(T),B)
最后,DI 的数学作用就是把具体diimpld_i^{impl}diimpl插入到上述表达式中,从抽象算子变为可执行算子。
Dependency Injection Basics
—— Link-time Dependency Injection(链接时依赖注入)
链接时依赖注入是一种在编译期/链接期决定系统依赖实现的方式。
特点是:
- 无运行时开销
- 无框架、无容器
- 通过链接器、
#ifdef、LIBPATH、目标文件选择实现 - 依赖选择完全在“外部”进行,而不是代码内部决定
一、什么是 Link-time Dependency Injection?
它的核心思想是:
同一个接口(函数/类)可以有多个实现,最终实际使用哪个,是在链接时由构建系统决定的,而不是代码自己决定的。
数学上可以把它视为函数符号绑定:
- 假设系统引用一个函数:
f(x)f(x)f(x) - 有两个实现:
- 生产版:fprod(x)f_{\text{prod}}(x)fprod(x)
- 测试版:ftest(x)f_{\text{test}}(x)ftest(x)
- 链接器会选择其中一个:
f(x)≡fprod(x)或ftest(x)f(x) \equiv f_{\text{prod}}(x) \quad\text{或}\quad f_{\text{test}}(x)f(x)≡fprod(x)或ftest(x)
你最终的二进制程序中只会包含一个最终实现。
二、注入方式(Linking / LIBPATH / #ifdef)
1)通过链接器路径(LIBPATH)注入
不同构建脚本使用不同路径:
prod build: link lib/production/*.o test build: link lib/test/*.o2)通过编译开关#ifdef注入
#ifdefTESTING#include"TradeRepository_test.h"#else#include"TradeRepository_prod.h"#endif3)通过替换 .cpp 文件注入
生产与测试提供同名函数:
pricing.cpp(真实实现)pricing_test.cpp(测试实现)
链接谁,运行就用谁。
关键点
代码调用方完全不知道实际实现是谁。
它只知道接口符号,例如函数名PriceTrade()。
三、Twin Implementations(双实现策略)
你提到的 “Twin implementations” 是 Link-time DI 的核心模式:
1.生产实现(Real Implementation)
包含完整逻辑,例如:
- 真正的 PricingEngine
- 真正的 Broker
- 真正的 MarketData
- 真正的网络/数据库调用
也就是:
f(x)=fprod(x;realdeps)f(x) = f_{\text{prod}}(x; real_deps)f(x)=fprod(x;realdeps)
2.测试实现(Test Implementation)
特点:
- 逻辑简单
- 快速可控
- 没有副作用(不会真的发订单、连数据库等)
- 替代部分真实组件
数学上:
f(x)=ftest(x;mock)f(x) = f_{\text{test}}(x; mock)f(x)=ftest(x;mock)
这些测试实现通常存在于:
src/test/或另一个分支。
3. 选择逻辑发生在构建阶段
- 一个链接动作 = 一个测试场景
- 每次换测试,都要重新编译/链接新的二进制
四、优点(为什么金融/高频系统喜欢这种方案)
零运行时开销
没有接口表、没有虚函数、没有 DI 容器。
生产代码零污染
生产代码不包含用来注入依赖的类/框架/工厂函数。
极高性能
由于所有绑定在链接期完成,性能等同于直接函数调用。
测试可控性好
你可以完全替换系统底层组件,例如:
- 把真实订单发送器替换为
FakeSender - 把真实 market data 替换为
DeterministicMarketData
代码使用者(调用方)不需要了解任何注入机制
调用者只写:
price=PricingEngine::Price(trade);但不会知道背后是 test 定价器还是生产定价器。
五、缺点(你也提到的 “Limited Testing”)
❶测试粒度粗(one link = one scenario)
要切换测试,只能重新链接整个程序。
数学上:
- 你只能选择
f=ftest或f=fprodf = f_{\text{test}} \quad\text{或}\quad f = f_{\text{prod}}f=ftest或f=fprod - 不能做到:
fpricing=test,fsending=prodf_{\text{pricing}} = test,\quad f_{\text{sending}}=prodfpricing=test,fsending=prod
除非用复杂的 link set 工程。
❷组合受限(不能组合不同测试版本)
现代 DI(运行时 DI,如 C#、Java)可组合:
services.AddSingleton<IPricer,MockPricer>();services.AddSingleton<ISender,RealSender>();但 Link-time DI 要达到同样效果需要:
- 多分支
#ifdef - 多个不同版本库
- 多条链接命令
复杂度高。
❸不支持运行时切换
比如不能:
- 在测试中动态替换价格源
- 在同一个二进制中模拟不同行为
- 在运行中 mock 某个依赖
六、为什么说“代码不受污染”
你说的:
• No code changes/contamination in actual production application
意思是:
- 不需要依赖注入框架
- 不需要工厂模式
- 不需要虚类/接口
- 不需要构造函数注入
- 不需要 ServiceLocator 或容器
例如生产代码里没有:
classIPricer{virtualPriceprice(Trade)=0;};没有任何“注入结构”。
生产代码是干净的、普通的、直接调用函数的。
七、Link-time DI 的数学心智模型
假设有一组依赖:
D=d1,d2,…,dnD = {d_1,d_2,\dots,d_n}D=d1,d2,…,dn
系统中引用了接口函数:
f1,f2,…,fnf_1, f_2, \dots, f_nf1,f2,…,fn
生产版链接器绑定:
fi↦diprod f_i \mapsto d_i^{prod}fi↦diprod
测试版链接器绑定:
fi↦ditest f_i \mapsto d_i^{test}fi↦ditest
构建得到两个不同二进制:
Binaryprod=fi=diprod Binary_{prod} = {f_i = d_i^{prod}}Binaryprod=fi=diprod
Binarytest=fi=ditest Binary_{test} = {f_i = d_i^{test}}Binarytest=fi=ditest
这就是 Link-time DI 的全部数学定义。
八、真实示例(C++ / 高频交易系统常用)
pricing.h
doubleprice_trade(constTrade&t);pricing.cpp(生产实现)
doubleprice_trade(constTrade&t){returnexpensive_model(t);}pricing_test.cpp(测试实现)
doubleprice_trade(constTrade&t){return42.0;// 固定价格,便于测试}构建脚本
生产:
g++ main.cpp pricing.cpp sending.cpp -o prod测试:
g++ main_test.cpp pricing_test.cpp sending_test.cpp -o testmain.cpp 完全看不到 test/pricing_test 的存在。
九、总结(超级精炼)
Link-time Dependency Injection 的本质:
在链接阶段为同一套接口绑定不同的实现,从而在不改变生产代码的前提下,注入不同的行为。
其数学模型是:
f(x)≡fimpl(x) f(x) \equiv f_{\text{impl}}(x)f(x)≡fimpl(x)
其中fimplf_{\text{impl}}fimpl在链接时决定,而非运行时。
Twin Implementations
- 生产实现:真实逻辑
- 测试实现:简化/虚拟逻辑
- 通过链接切换场景
- “一链接 = 一个测试场景”
Link-time Dependency Injection(链接时依赖注入)示例深度解析
代码其实展示了最经典、最纯粹的 C++ Link-time Dependency Injection 模式:
- 生产代码完全不知道“测试依赖”的存在
- 测试只需提供同名函数/同名方法
- 链接时决定最终调用哪个实现
- 无任何运行时开销
- 不需要接口、虚函数、注入容器、工厂类等
这在高性能系统(尤其是金融 HFT / 低延迟系统)中常见。
1. 代码结构回顾
ActionHandler.cpp(业务逻辑组件)
structActionHandler{Coms coms_;voidexecute(constAction&){Request req;// ...autoresult=coms_.send(req);// Dependencycheck_response(result);}};关键点:
ActionHandler使用了Coms这个依赖- 调用
coms_.send(req)发送请求 - 这里完全不知道 send() 是“真实发送”还是“测试版本的假发送”
这就是 Link-time DI 的核心特征:
调用者不知道依赖的真实实现,真实实现由链接器决定。
Com.cpp(生产实现)
ResultCom::send(constRequest&req){...returnresult;}这是生产环境真正的网络发送逻辑。
数学表达:
sendprod(req)=result \text{send}_{prod}(req) = resultsendprod(req)=result
Test/Com.cpp(测试实现)
Request global_req;ResultCom::send(constRequest&req){global_req=req;returnfixed_result;}测试版本的 send:
- 不会真的发送请求
- 会把请求保存到
global_req用于断言 - 返回固定结果
fixed_result - 不依赖外部系统(如网络)
数学表达:
sendtest(req)=fixedresult \text{send}_{test}(req) = fixed_resultsendtest(req)=fixedresult
且记录:
globalreq=req global_req = reqglobalreq=req
2. DI 的关键:两个 send() 具有相同符号
两个文件中都有:
ResultCom::send(constRequest&req);链接器在最终链接时“选择其中一个”进入可执行文件。
这就相当于数学中的符号绑定:
假设业务代码调用的函数符号是:
send(req) send(req)send(req)
而你提供两个备选实现:
- 生产版:sendprodsend_{prod}sendprod
- 测试版:sendtestsend_{test}sendtest
最终绑定关系为:
send≡sendprod send \equiv send_{prod}send≡sendprod
或:
send≡sendtest send \equiv send_{test}send≡sendtest
链接器通过目标文件(.o/.obj)和库路径(LIBPATH)确定究竟绑定哪个。
3. 为什么这就是依赖注入?
因为“依赖”在这里是:
D=send(req) D = { \text{send}(req) }D=send(req)
业务代码没有创建依赖,也没有选择依赖,而是由外部环境(链接器)注入这个依赖。
抽象上可以写成:
ActionHandler(req)=check_response(send(req)) ActionHandler(req) = check\_response(send(req))ActionHandler(req)=check_response(send(req))
其中sendsendsend的定义不是业务代码控制,而是:
send:={sendprod,用于生产构建sendtest,用于测试构建 send := \begin{cases} send_{prod}, & \text{用于生产构建} \\ send_{test}, & \text{用于测试构建} \end{cases}send:={sendprod,sendtest,用于生产构建用于测试构建
业务逻辑是“纯的”,而依赖是在构建阶段外部注入的。
4. 为什么这是最纯粹、零成本的 DI 方式?
没有虚函数(无 vtable)
虚函数 DI 会多一个间接调用:
virtualResultsend(constRequest&)=0;但 Link-time DI 只需要直接调用:
coms_.send(req);// 直接绑定到最终实现速度等同于普通函数调用。
没有接口对象,没有指针,没有工厂
现代 DI(如 C#/Java 的依赖容器)要求:
- 接口
- 容器
- 注册
- 构造注入
- 运行时绑定
- Mock 框架
但这里全部不需要。
生产代码无需改动任何一行
生产代码看起来完全正常:
autoresult=coms_.send(req);不会出现:
#ifdef TESTMockComsFakeSenderDIContainer
非常干净。
测试替换实现就是“扔进另一个 .cpp 文件并链接”
例如:
prod build: g++ ActionHandler.cpp Com.cpp -o prod test build: g++ ActionHandler.cpp Test/Com.cpp -o test这个构建动作就等于:
send↦sendtest send \mapsto send_{test}send↦sendtest
5. 这个测试实现为什么好?
测试版send():
global_req=req;returnfixed_result;提供了两个利器:
1) 可以断言“是否发送了正确的请求”
因为:
globalreq=req global_req = reqglobalreq=req
你可以写:
CHECK(global_req.type==ExpectedType);2) 可以控制响应行为,避免依赖外部世界
因为:
sendtest(req)=fixedresult send_{test}(req) = fixed_resultsendtest(req)=fixedresult
你可以构造任意测试场景:
- 成功
- 失败
- 超时
- 错误码
完全不需要真实网络环境。
6. 整体数学模型总结
业务逻辑:
y=f(x,send(x)) y = f(x, send(x))y=f(x,send(x))
其中send(x)是依赖。
在链接时,选择不同实现:
send:={sendprod(x),生产版sendtest(x),测试版 send := \begin{cases} send_{prod}(x), & \text{生产版} \\ send_{test}(x), & \text{测试版} \end{cases}send:={sendprod(x),sendtest(x),生产版测试版
最终变成:
- 生产可执行文件:
y=f(x,sendprod(x)) y = f(x, send_{prod}(x))y=f(x,sendprod(x)) - 测试可执行文件:
y=f(x,sendtest(x)) y = f(x, send_{test}(x))y=f(x,sendtest(x))
7. 用一句话概括你的示例
生产系统调用真实的 Com::send(),
测试系统调用 Test/Com.cpp 里的假的 Com::send(),
链接器在构建时决定谁生效。
这就是“链接时依赖注入”。
Link-time Dependency Injection
(链接时依赖注入)——详细理解
Link-time DI 的核心思想是:
依赖的选择不是在运行时决定,而是在“链接阶段”由编译器/链接器,以不同目标文件组合完成。
换句话说:
程序中使用的函数、类、模块,其真实实现取决于最终链接时加入了哪一个.cpp文件。
1. 它是如何工作的?
你有两个版本的实现:
- 生产版本(真实业务逻辑)
- 测试版本(用于测试的简单替代实现)
代码引用的接口通常完全相同:
// Com.cpp ——生产代码ResultCom::send(constRequest&req){...}测试代码提供同名、同签名的实现:
// Test/Com.cpp ——测试替代版本Request global_req;ResultCom::send(constRequest&req){global_req=req;returnfixed_result;}然后在链接时,通过LIBPATH或目标文件路径切换决定使用哪个实现:
- 链到
Com.o→ 使用生产逻辑 - 链到
Test/Com.o→ 使用测试逻辑
这种方法属于一种“外部注入”(Externally Injected),不需要修改生产代码。
2. 特点(优点与限制)
优点(非常有限)
- 生产代码无需修改
不需要引入接口,不用依赖注入框架,也不需要条件编译。 - 简单粗暴
通过不同链接脚本、路径、构建配置,就能切换功能。 - 可用于简单场景测试
例如在某些古老代码库、缺乏接口设计的系统中。
3. 缺点(这是为什么它被认为不推荐)
(1) 难以管理(Logistics Nightmare)
如果你有很多模块,每个模块有不同版本:
实现数=∏i=1nki \text{实现数} = \prod_{i=1}^{n} k_i实现数=i=1∏nki
其中kik_iki是第iii个模块的实现分支数量。
链接组合会呈指数级爆炸,测试几乎变得不可能管理。
(2) 容易产生 Undefined Behavior / ODR 违反
C++ 的 ODR(One Definition Rule)要求:
同一个符号在整个程序中必须只有一个定义 \text{同一个符号在整个程序中必须只有一个定义}同一个符号在整个程序中必须只有一个定义
而你在不同路径下放置多个相同符号(多个Com::send),一旦链接脚本没写好就可能产生:
- 链到两个实现 → UB
- 链错实现 → 难以排查
- 链接器静默替换 → 得到你不期望的结果
这种情况极其危险。
(3) 测试粒度不是真正的“单元测试”
虽然你想测一个组件,但由于链接时替换是全局的,这通常导致:
- 很难隔离依赖
- 很难测边界条件
- 仍然类似集成测试
它不具备运行时依赖注入(DI)或 Mock 框架那种可控性。
(4) 整体非常脆弱且令人困惑
随着项目变大:
- 哪些源文件被编译?
- 哪些路径生效?
- 测试版本是否链接正确?
- 是否意外混入生产实现?
调试非常痛苦。
这也是大型 C++ 系统几乎不采用 Link-time DI 的原因。
4. 警告 —— 不要使用它
给出的明确警告:
Warning: Don’t use it
原因总结:
- 测试可控性差
- 管理成本高
- 极易制造 UB 或 ODR 违例
- 测试覆盖面不透明、反馈慢
- 难以自定义不同的依赖组合
- 随工程规模增长而迅速失控
现代 C++ 更推荐: - 构造函数注入
- 接口 + 多态
- 模板参数注入
- 函数指针/函数对象注入
- Service Locator(谨慎使用)
- Mock 框架(如 gmock)
这些都是可控、透明、可组合的 DI 方法。
5. 总结(精华版)
Link-time DI 本质上是:
一种通过链接阶段选择不同实现的“静态依赖注入”。
它的主要问题是:
- 粒度粗
- 组合有限且难以管理
- 会导致 ODR 与 UB 风险
- 不是真正的单元测试
- 测试环境极为脆弱
因此被认为几乎不应该使用。
Dependency Injection Basics
——通过继承(Inheritance / Virtual Functions)实现依赖注入
在所有依赖注入(DI)方法中,“继承 + 虚函数”是一种经典、稳定、适用于 C++ 老代码库的办法。相比 Linking(链接时注入),继承的方式:
- 不需要篡改编译/链接流程
- 接口清晰
- 可扩展性好
- 可组合性高
- 是最常见的 C++ 面向对象依赖注入方式之一
下面进行详细说明。
1. 基本思想
使用继承进行 DI 的核心是:
将依赖抽象为一个接口(通常是抽象基类),再通过虚函数机制让不同实现按需替换。
例如:
structICom{virtualResultsend(constRequest&)=0;virtual~ICom()=default;};然后生产代码与测试代码都实现该接口:
structRealCom:ICom{Resultsend(constRequest&req)override{...}};structTestCom:ICom{Resultsend(constRequest&req)override{returnfixed_result;}};使用方只依赖ICom,而不是依赖具体类。
2. 如何注入依赖?
依赖注入通过构造函数或Setter完成:
structActionHandler{ICom*com_;// 依赖:接口ActionHandler(ICom*com):com_(com){}voidexecute(constAction&a){Request r;...autoresult=com_->send(r);check_result(result);}};主程序:
RealCom com;ActionHandlerhandler(&com);测试:
TestCom com;ActionHandlerhandler(&com);// 注入测试依赖3. 为什么继承非常适合 DI?
(1)接口天然适合作为“依赖的抽象层”
C++ 的虚函数允许:
- 统一接口
- 多种实现
- 运行时动态绑定
从数学抽象角度可理解为:
ActionHandler∘ICom=业务逻辑 \text{ActionHandler} \circ \text{ICom} = \text{业务逻辑}ActionHandler∘ICom=业务逻辑
而你替换不同的实现:
ICom={RealCom,用于生产环境TestCom,用于测试环境 \text{ICom} = \begin{cases} \text{RealCom}, & \text{用于生产环境} \\ \text{TestCom}, & \text{用于测试环境} \end{cases}ICom={RealCom,TestCom,用于生产环境用于测试环境
因此:
ActionHandler(RealCom)≠ActionHandler(TestCom) \text{ActionHandler(RealCom)} \neq \text{ActionHandler(TestCom)}ActionHandler(RealCom)=ActionHandler(TestCom)
但两者对使用方具有一致的“接口行为”。
4. 特点总结
继承注入的优势
- 可以处理多方法、复杂接口
接口类可以拥有几十个函数,没有问题。 - 利用 C++ 已经成熟的虚函数机制
无需外部框架,不依赖元编程、不依赖编译技巧。 - 对旧系统非常友好
在已有类前加一个面向接口的抽象层即可。 - 扩展简单
新功能 = 继承接口并实现新的类。 - 运行时可灵活选择实现
5. 继承 DI 的缺点
虽然继承是经典方法,但它并非完美:
✘(1)虚函数有运行时开销
调用成本为:
O(1)但包含一次虚表指针间接跳转 \text{O(1)} \quad\text{但包含一次虚表指针间接跳转}O(1)但包含一次虚表指针间接跳转
在高性能场景(如 SIMD 或 tight loop)可能不可接受。
✘(2)接口难以向下兼容
一旦接口新增方法:
- 所有实现必须更新,否则无法编译
- 增加维护成本
✘(3)不适用于模板或纯函数式代码路径
泛型代码更适合:
- 模板注入
- 函数对象注入(callables)
6. 示例:继承 DI vs Linking DI 的对比
| 特性 | Linking DI | Inheritance DI |
|---|---|---|
| 控制粒度 | 粗糙(链接级别) | 细粒度(对象级别) |
| 可维护性 | 极差 | 好 |
| 明确性 | 难知道谁被链接 | 非常明确 |
| UB / ODR 风险 | 高 | 低 |
| 单元测试支持 | 很差 | 强大 |
| 高度模块化 | 差 | 好 |
| 修改旧代码需要程度 | 低 | 中等(加接口) |
| 结果: |
Inheritance DI 是推荐使用的典型 DI 方法。
7. 总结(精华版)
通过继承进行依赖注入的核心:
- 定义抽象基类接口
- 使用虚函数 override实现多个版本
- 在构造时将所需实现作为参数注入
- 使用方只依赖接口,不依赖具体类
数学上可以理解为依赖的函数映射:
Client=f(Dependency) \text{Client} = f(\text{Dependency})Client=f(Dependency)
而依赖注入就是改变Dependency\text{Dependency}Dependency的实现。
通过继承进行依赖注入(Dependency Injection via Inheritance)
这种方式是 C++ 中最传统、最常见的依赖注入手段之一。
核心思想:
把依赖抽象成接口(通常基类有虚函数),业务逻辑依赖该接口,通过传入不同派生类对象来切换实现。
1. 基本示例结构(图示化)
有一个依赖类CalcEngine,具有多种方法:
CalcEnginevirtualboolexecute(...);virtualboolapply(...);virtualboolcalculate(...);virtualboolcommit(...);测试想要替换整个计算引擎 → 派生一个测试实现:
TestCalcEngine:publicCalcEngineboolexecute(...)override;boolapply(...)override;boolcalculate(...)override;boolcommit(...)override;接着注入到业务逻辑:
boolprocess(CalcEngine&engine,...){// Injection happens here...engine.apply(...);// during test will call TestCalcEngine::apply()...returnengine.calculate(...);}关键点:测试时只需注入TestCalcEngine实例 → 业务逻辑不需要改变任何代码。
2. 使用接口(纯虚类)进行更干净的 DI
在上面例子中,生产版本直接作为基类,这在复杂系统中可能变得混乱。因此更好的设计是引入一个接口类:
CalcEngineInterface ←——— 纯虚接口(抽象)virtualboolexecute(...)=0;virtualboolapply(...)=0;virtualboolcalculate(...)=0;virtualboolcommit(...)=0;↑ derives CalcEngine ←——— 真实实现(生产) ↑ derives TestCalcEngine ←——— 测试实现(手写 stub 或 mock)这会让接口更加清晰独立。
3. 与 Mock 框架结合(如 gMock)
在面向测试的接口上,你也可以使用 mock 框架:
structTestCalcEngine:CalcEngineInterface{MOCK_METHOD(bool,execute,(...),(override));MOCK_METHOD(bool,apply,(...),(override));MOCK_METHOD(bool,calculate,(),(override));MOCK_METHOD(bool,commit,(...),(override));};这样你就可以在测试中写:
EXPECT_CALL(mockEngine,calculate()).WillOnce(Return(true));这是单元测试中最常见的操作方式。
4. 优点总结
(1)接口可承载很多方法
继承支持大量虚函数,适合复杂模块:
接口方法数=n,n 可很大 \text{接口方法数} = n, \quad n \text{ 可很大}接口方法数=n,n可很大
测试可一次性 mock 多个能力。
(2)可以模拟复杂依赖
继承可以 mock:
- 状态变化
- 方法调用顺序
- 多种行为模式
(3)适用于旧代码库
只需:
- 将旧类变成虚函数
- 或添加一个接口基类
即可支持依赖注入。
(4)运行时替换简单
使用基类引用或指针即可实现:
process(realEngine);// 生产process(mockEngine);// 测试5. 缺点(也是继承 DI 常被批评的地方)
✘(1)接口可能变得非常混乱
随着项目增长,接口可能拥有多个方法,其中有些可能是为了测试而添加(坏味道)。
例如出现这种类:
CalcEngineInterface + execute() + apply() + calculate() + commit() + resetStateForTest() ← 测试专用,污染接口出现这种现象意味着设计开始偏离单一职责原则 SRP。
✘(2)大量纯虚函数意味着大量的 stub/mocks
如果接口有nnn个纯虚函数:
测试最少必须提供 n 个 override \text{测试最少必须提供} \ n \text{ 个 override}测试最少必须提供n个override
这在大型类中很痛苦。
gMock 提供了自动 mock,但手写 stub 会是一场灾难。
✘(3)接口与数据混合(Bad smell)
错误的做法是让接口类中包含数据成员:
structCalcEngineInterface{intsharedState;// 破坏抽象virtualboolcalculate()=0;};接口应只表达行为,不应包含数据。
✘(4)虚函数带来额外运行时成本(vtable 间接跳转)
一次虚函数调用的成本约等于:
一次指针解引用+一次函数间接跳转 \text{一次指针解引用} + \text{一次函数间接跳转}一次指针解引用+一次函数间接跳转
在高性能场景(如 SIMD、渲染、数值计算)可能不可接受。
✘(5)接口侵入旧代码
要引入接口时必须:
- 引入基类
- 将方法改为
virtual - 重新组织类结构
这对一些“结构僵硬”的遗留代码是一种侵入式改动。
6. 总结(精华版)
通过继承实现 DI = 使用抽象基类作为依赖,通过多态注入不同实现。
优点:
- 接口丰富、可 mock 多方法
- 适合旧项目
- 行为清晰
- 运行时选择实现简单
缺点:
- 接口可能变大、变乱
- 必须 override 所有纯虚方法
- 虚表性能开销
- 数据和接口可能混杂
- 侵入式修改旧类结构
通过模板进行依赖注入(Dependency Injection via Templates)
这是 C++ 中最强大、最灵活、零运行时开销的一种依赖注入方式,适用于现代 C++(特别是 C++17 / C++20 之后)。
核心思想
业务函数不依赖特定类,而是依赖一个“满足必要方法”的模板类型。
只要类型T拥有业务代码调用的那些方法,就可以被注入。
这本质上是:
Duck Typing(鸭子类型) \text{Duck Typing(鸭子类型)}Duck Typing(鸭子类型)
即:
看上去像鸭子、叫起来像鸭子,就把它当鸭子。
1. 基本示例解析
template<typenameCalcEngine>boolprocess(CalcEngine&engine){// Template Injection// ...engine.apply(rdata);// Dependencyrdata.data_="2";// ...returnengine.calculate(rdata);}注意:
process()并不关心CalcEngine的真实类型- 它只需要
CalcEngine拥有apply()和calculate() - 无需虚函数,无需接口类,无需继承
这是编译期(compile-time)依赖注入。
2. 注入不同实现:真实实现 + 测试实现
真实引擎(生产环境)
structRealCalcEngine{RealCalcEngine(...);boolapply(constData&rdata);boolcalculate(constData&rdata);};测试引擎(测试环境)
可以手写 stub:
structTestCalcEngine{boolapply(constData&rdata);boolcalculate(constData&rdata);};也可以用 mock 框架:
structTestCalcEngine{MOCK_METHOD(bool,apply,(constData&));MOCK_METHOD(bool,calculate,(constData&));};3. 为什么模板 DI 强大?
(1)只需实现被调用的方法
如果process()只调用:
apply()calculate()
那测试引擎只要实现这两个即可:
需要的 stub 数量=业务代码实际调用的方法数 \text{需要的 stub 数量} = \text{业务代码实际调用的方法数}需要的stub数量=业务代码实际调用的方法数
而不是虚函数接口里的全部。
(2)无运行时开销(零 virtual call)
继承方式使用虚函数(vtable):
- 需要间接跳转
- 带来一点性能损耗
模板 DI 是静态绑定:
virtual 消失⇒零成本抽象 \text{virtual 消失} \quad \Rightarrow \quad \text{零成本抽象}virtual消失⇒零成本抽象
(3)类型安全、可优化
因为类型在编译期已知,编译器可以:
- 内联函数
- 优化函数调用
- 进行死代码消除
对性能敏感场景(如 SIMD、数据库内核、交易撮合引擎)非常关键。
4. C++20 Concepts(概念)使模板 DI 更优雅
你可以定义一个“接口要求”,让模板更可读:
template<typenameT>conceptCalcEngineT=requires(T t,constData&d){{t.calculate(d)}->std::convertible_to<bool>;{t.apply(d)}->std::convertible_to<bool>;};使用:
template<CalcEngineT Engine>boolprocess(Engine&engine){engine.apply(rdata);returnengine.calculate(rdata);}Concepts 的意义:
- 编译期检查所需方法是否存在
- 错误信息清晰
- 更接近“接口”的语义,但不需要虚函数
概念相当于数学上的约束:
Engine∈T∣T has methods apply(), calculate() Engine \in { T \mid T \text{ has methods apply(), calculate() } }Engine∈T∣Thas methods apply(), calculate()
5. 对比:模板 DI vs 继承 DI
| 维度 | 继承 DI | 模板 DI |
|---|---|---|
| 绑定时间 | 运行时 | 编译期 |
| 性能 | 有 vtable 成本 | 零成本抽象 |
| 接口强约束 | 强(虚函数) | 弱(duck typing) |
| 使用难度 | 简单 | 可能引发模板错误 |
| Mock 支持 | gMock 很强 | 也支持 |
| 可扩展性 | 中等 | 非常强 |
| 需要实现的方法数量 | 所有纯虚方法 | 只要业务使用到的 |
| 与旧代码兼容性 | 强 | 中等 |
模板 DI 是最灵活和性能最好的方案
但对错误提示的要求高,对复杂编译器也更有压力。
6. 总结(精华版)
模板依赖注入 = 编译期 Duck Typing + 零成本抽象 + 灵活的 stub/mock。
优点:
- 只需实现业务实际使用的方法
- 零运行时开销(无虚表)
- 适合高性能系统
- C++20 concepts 可定义清晰接口约束
缺点: - 错误信息在 C++17 很难理解
- 增加编译时间
- 对旧代码库迁移成本较高
是现代 C++最推荐的 DI 手段(尤其在你这种高性能 SIMD 场景非常适合)。
什么是 “Dependency Injection via Templates”?
通过模板实现依赖注入是 C++ 常用的一种零成本抽象(zero-cost abstraction)技法,用来替代传统依赖注入中常见的:
- 虚函数(runtime polymorphism)
- 接口类(interface class)
- 晚绑定(dynamic dispatch)
它的核心思想是:
让模板参数(Template Parameter)提供特定的“方法集合”,对调用方来说,这个模板参数就是一种“接口”。
这种方法本质上是一种compile-time duck typing。
基本思想
假设函数process()需要一个“可计算引擎(CalcEngine)”:
template<typenameCalcEngine>boolprocess(CalcEngine&engine){// 注入// ...engine.apply(rdata);// 依赖rdata.data+=engine.scale();// 依赖returntrue;}这里没有虚函数、没有接口类、没有继承。
只要你传入的CalcEngine实现了apply()和scale(),它就能用。
这就是“Create a class that satisfies the calls made on the class”
——只需要提供被调用的方法即可。
类似 Concept 的“接口”
C++20 可以用 Concept 限定模板参数:
template<typenameT>conceptCalcConcept=requires(T t,RData&r){{t.apply(r)};{t.scale()}->std::convertible_to<int>;};然后:
boolprocess(CalcConceptauto&engine);这就像声明一个接口,但:
- 检查发生在编译期
- 调用是静态绑定(没有虚表开销)
机制的本质(通俗解释)
依赖注入 via 模板,本质是:
你让调用方决定依赖类型,而不是 process() 决定依赖类型。
但这个依赖是在编译期被“注入”的。
若用数学类比:
- 传统虚函数是运行时函数指针查找,大致行为是
f(x)=vtable[slot](x) f(x) = vtable[slot](x)f(x)=vtable[slot](x) - 模板依赖注入是编译期展开,可视为
fT(x)=Tv(x) f_T(x) = T_v(x)fT(x)=Tv(x)
即 “把 T 的方法直接代入展开”。
优点(文中提到的)
1. 可以 mock 很多方法
你只需要 mock 你会用到的那部分:
structMockEngine{voidapply(RData&r){}intscale(){return42;}};不需要完整实现“接口”,非常轻量。
2. 编译期,无虚函数开销(零成本)
调用:
engine.apply(rdata);被编译器 “内联 + 优化”,最终可能只是几条指令。
没有:
- vtable
- 指针查表
- 动态调度开销
3. 类型要求由模板约束(例如 Concepts)描述
这就是:
“Create a class – aka a concept – that satisfies the calls”
你只需要提供刚好被使用的方法。
这是structural typing(结构子类型),不是 C++ 传统的 nominal typing(面向命名的继承接口)。
缺点(文中提到的)
下面逐条解释:
1. Templates all the way down
(模板一路向下,代码被模板污染)
使用模板 DI 的地方,通常所有调用链都需要变成模板:
template<classEngine>voidprocess(Engine&);template<classEngine>voidsystem_process(Engine&);只要链路中有一层需要注入依赖,那层函数就成为模板,该函数调用的所有函数也必须变成模板。
这会导致:
- 可读性下降
- 调用层级全是模板
- 生成很多实例化代码
2. Hard to add into legacy code
(难以加入到老旧代码中)
如果原始代码是:
classIEngine{virtualvoidapply(...)=0;virtualintscale()=0;};并且大量使用指针:
IEngine*engine;process(engine);那迁移到模板需要:
- 解耦 interface
- 把函数改成模板
- 重写调用链
改动非常大。
3. Increased compilation times
(编译时间显著增长)
每个模板参数类型都会实例化 process()。
如果你传入 10 个不同引擎类型:
- 生成 10 个 process() 的机器码
- 编译时间成倍增长
- 二进制大小膨胀
这是模板广泛展开的副作用。
4. More hieroglypical(更难读,如“象形文”)
模板错误信息非常深奥:
error: no matching function for call to 'apply'可能一路展开 40 行,进入 STL、concepts、requires 表达式…
阅读体验像看一堆象形文字。
总结(版)
| 特性 | 描述 |
|---|---|
| 优点 | 无虚表开销、零运行时成本、mock 灵活、代码可被大量优化、概念描述能力强 |
| 缺点 | 模板泛滥、难加入旧代码、编译慢、调试和错误信息更复杂 |
| 一句话总结: |
模板注入是性能最强的依赖注入方式,但代价是代码复杂度和编译成本。
什么是 “Dependency Injection via Type Erasure”?
通过类型擦除实现依赖注入的核心思想是:
用一个统一的“调用包装器”(如
std::function),把任意满足指定函数签名的“可调用对象”(callable)变成同一种类型。
例如:
usingCalculateYield=std::function<double(constData&,...)>;那么:
- lambda
- std::bind
- 普通函数
- 函数对象(functor)
- 成员函数包装
- 甚至 std::packaged_task
只要满足签名:
KaTeX parse error: Expected 'EOF', got '&' at position 25: …uble(const Data&̲, ...)}
都能注入(inject)进来。
为什么叫 “类型擦除(Type Erasure)”?
因为不同类型的可调用对象(lambda、函数、functor)本来是不同类型。
例如:
[](constData&d){returnd.x*0.1;};// closure type 1[](constData&d){returnd.x*0.2;};// closure type 2doublefoo(constData&d);// function pointerstructFunctor{doubleoperator()(Data)...};// functor type但std::function<double(const Data&)>会把它们全部“擦除成同一种类型”。
所谓“擦除”就是:
用户看不到原始类型,只看到统一的接口。
你给的例子逐行解析
依赖类型定义
usingCalculateYield=std::function<double(constData&,...)>;这句定义了本类要“依赖”的外部功能:
一个可调用对象,输入 Data,返回 double。
构造函数注入(Injection)
YieldProcessor(CalculateYield yield_calc):YieldCalculator_(std::move(yield_calc)){};你把“依赖”(yield 计算器)从外部传入。
注意:这里不需要知道yield_calc的具体类型。
(它可能是 lambda、函数、functor…)
因为已经被类型擦除成std::function。
使用依赖(Dependency)
autoyield=YieldCalculator_(data,...);只要语法满足函数签名,就能被调用。
测试(Testing)示例解析
autoy_calculator=[](constYieldData&ydata){returnydata.data_*0.01;};// 提供 mock 行为(依赖)这个 lambda 就是测试中的“替身对象(mock)”。
注入:
YieldProcessorprocessor(y_calculator);调用:
autoyield=processor.process(rdata);EXPECT_EQ(yield.realised,1);通过不同 lambda 就能模拟不同的行为,非常适合测试。
优点(文中提到的)
1. “Invokable on any callable target”
只要能调用,就能注入。
不需要创建一个实现类、继承、虚函数等。
2. Versatile(非常灵活)
- 轻松注入 lambda
- 轻松写 mock
- 不需要模板(代码可读性较高)
- 可以在运行时注入不同的行为(模板做不到)
例如你可以这样:
if(config.use_new_method)proc=YieldProcessor(new_yield_calc);elseproc=YieldProcessor(old_yield_calc);运行时即可切换依赖行为。
3. 不污染调用链
(与模板 DI 相反)
使用模板 DI 时,所有调用链都变成模板:
template<classEngine>process(Engine&);但类型擦除只需要固定类型:
CalculateYield YieldCalculator_;缺点(虽然你没列出,但我补充完整)
1. 有运行时成本(比模板 DI 慢)
std::function会做堆分配(small object optimization 不一定触发)- 调用是类型擦除后的间接调用,而非模板展开的内联
成本通常约为:
std::function 调用开销≈一次函数指针调用 \text{std::function 调用开销} \approx \text{一次函数指针调用}std::function调用开销≈一次函数指针调用
在大量小函数调用路径中,这可能很贵。
2. 不支持编译期检查(不像 Concepts 那样严格)
类型擦除意味着:
- 不会告诉你一个成员方法缺失。
- 只在调用时发现错误。
3. 捕获闭包的大小未知,需要分配
不同 lambda 有不同大小:
[]{return x;} // 可能捕获 8 字节 []{return a*b;} // 捕获多个变量 >16 字节std::function 会进行动态存储(或采用小对象优化 SOO),都会有一定成本。
与模板 DI 的对比(表格)
| 特性 | 模板 DI | 类型擦除 DI (std::function) |
|---|---|---|
| 绑定时机 | 编译期 | 运行时 |
| 调用开销 | 零开销(可内联) | 函数指针级别开销 |
| mock 容易度 | 非常容易 | 非常容易 |
| 代码可读性 | 随模板复杂变差 | 好得多 |
| 编译时间 | 增加 | 不变 |
| 二进制大小 | 增加(模板实例化) | 小很多 |
| 灵活性 | 静态(编译期) | 高(运行时可替换) |
一句话总结:
模板 DI = 性能最强但代码复杂
类型擦除 DI = 性能适中但类型最灵活
总结(版)
通过类型擦除实现依赖注入:
- 把各种 callable “抹成同一个类型”
- 用 std::function 捕获依赖
- 运行时调度
- 测试 mock 最方便
- 唯一缺点是运行时开销和内存分配
比起模板 DI,更加: - 灵活
- 易读
- 易整合到老代码
但性能比模板 DI 差一些。 - 依赖注入(Dependency Injection)
- 类型擦除 via
std::function(type erasure) - 测试(mock 注入)
完整注释版代码(使用 std::function 的类型擦除 DI)
// 使用 std::function 作为类型擦除的机制:// 任何可调用对象(lambda、函数指针、functor)只要能匹配签名// double(const Data&, ...)// 都可以赋值给 CalculateYield。usingCalculateYield=std::function<double(constData&,...)>;// ========== Dependency Type ==========// YieldProcessor 依赖一个“收益计算器”,但它不关心具体实现是什么。// 通过 std::function 实现类型擦除:只关心能否被调用,而不是具体类型。structYieldProcessor{// 构造函数:依赖注入(Dependency Injection)// 外部传入一个可调用对象(yield_calc),用来计算收益。Processor(CalculateYield yield_calc):YieldCalculator_(std::move(yield_calc)){};// 存储注入的依赖// ...autoprocess(Data&data){// ... 处理其他逻辑// 使用注入的依赖进行收益计算// 调用方式统一为:"YieldCalculator_(data, ...)"// 无论其真实类型是 lambda、函数指针还是 function object,// 都已通过 std::function 被类型擦除。autoyield=YieldCalculator_(data,...);// ...returnyield;}private:// 保存被注入的依赖(类型擦除后的可调用体)CalculateYield YieldCalculator_;};// ========================================================================// ========================== Testing Section ==============================// ========================================================================// Dependency Injection via type erasureTEST(Processor,test_yield){// ========== Dependency ==========// 定义一个 mock 的收益计算器。// 用 lambda 来模拟非常简单的收益计算逻辑:data × 1%// 通过 lambda,我们可以在测试中“捕获”外部行为,避免真实实现的复杂性。autoy_calculator=[](constYieldData&ydata){returnydata.data_*0.01;};// ========== Injection ==========// 将 mock 的计算器注入 Processor。// 这就是依赖注入:让 Processor 不自己构建依赖,而是从外部提供。YieldProcessorprocessor(y_calculator);// 测试输入数据YieldData rdata{100};// 调用被测方法autoyield=processor.process(rdata);// ========== Verification ==========// 根据 mock 的逻辑:100 * 0.01 = 1// 验证 Processor 是否正确使用依赖(是否调用了注入的 mock)EXPECT_EQ(yield.realised,1);}解析重点总结(随代码一起理解)
1. 依赖注入(DI)
YieldProcessor不再创建自己的收益计算逻辑,而是依赖外部传入:
Processor(CalculateYield yield_calc)让测试可以替换真实逻辑。
2. 类型擦除(type erasure via std::function)
std::function完成“统一接口”,隐藏真实类型:
usingCalculateYield=std::function<double(constData&,...)>;无论你传入:
- lambda
- 函数指针
- 仿函数(function object)
- bind 表达式
都能工作。
3. Mock 测试的关键
测试中使用 lambda 作为 mock:
autoy_calculator=[](constYieldData&ydata){returnydata.data_*0.01;};验证 Processor 是否正确调用依赖。
详细解读:通过类型擦除(type erasure)做依赖注入(std::function/std::move_only_function/std::invoke)
下面把你给的要点逐条拆开、补充背景、举例,并指出常见陷阱与替代方案。
核心概念(一句话版)
类型擦除 + 可调用包装器(例如std::function或std::move_only_function)把“任何能被调用的对象”统一成同一类可存储/调用的对象,从而可以把实现(依赖)作为参数注入进来:这就是“依赖注入 via type erasure”。
std::function是一个通用的多态函数包装器,能够存储、复制并调用任何CopyConstructible的 callable。std::move_only_function是一个“只能移动”的可调用包装器(C++23 起有),在无法或不希望拷贝目标时很有用。
而std::invoke则提供了统一的“如何调用任意 callable(包括成员指针 / 函数对象 / 函数指针)”的语义,方便实现通用调用。
优点(详细)
- 能调用满足签名的任意可调用目标
- 不论是普通函数、函数指针、lambda、bind 表达式、仿函数、成员指针,只要签名匹配(或满足可调用要求),都能被封装并注入。
- 灵活、易于测试(mock)
- 测试时把
std::function/std::move_only_function参数替换成简短 lambda,就能隔离被测模块,避免引入复杂依赖。
- 测试时把
- 统一了调用接口
- 调用端只需写
callable(args...)或通过std::invoke,不需要关心底层目标是什么,代码更通用。
- 调用端只需写
- 不同语义的包装器可选
- 如果你需要不可复制、仅移动的语义,可以选
std::move_only_function(C++23);如果需要拷贝语义就用std::function。
- 如果你需要不可复制、仅移动的语义,可以选
缺点 / 限制(详细)
- “只能替代一个方法签名” 的观察
- 使用
std::function<R(Args...)>实际上只能替换单一签名的 callable(或者一组同签名的不同实现)。如果对象有多个方法需要替换,std::function只能覆盖单个入口点;为多个方法你需要多个std::function成员或更复杂的包装(见替代方案一节)。
- 使用
- 运行时开销(类型擦除的代价)
std::function的调用不是编译时内联的:实现通常为对具体callable的间接调用(通过内部虚表样式的机制),因此其开销常常和运行时虚函数调用相当(都是间接调用、可能有一次指针跳转以及可能的堆分配)。精确成本依赖实现与具体平台,但原则上不可与模板内联(zero-overhead)相提并论。
- 可能的堆分配(取决于实现与目标大小)
- 许多
std::function实现有 Small Buffer Optimization(SBO):若目标对象足够小则内嵌存储,否则会分配堆内存。但这不是语言强制的行为、不同实现(libstdc++, libc++, MSVC)细节不同,且可观测(即会影响性能)。因此不能单纯假设“用小 lambda 就一定不分配”。
- 许多
- 类型信息/RTTI 与可控性差异
std::function需要可拷贝的目标(copyable),而std::move_only_function支持不可拷贝但可移动的目标;两者在运行时暴露/不暴露的细节略有差异(如target()/target_type()支持差异)。
代码示例(对比:std::function / std::move_only_function / std::invoke)
#include<functional>#include<utility>// 1) std::function(可拷贝的类型擦除)usingCalculateYield=std::function<double(constData&)>;structYieldProcessor{CalculateYield yieldCalc;// 注入点doubleprocess(constData&d){returnyieldCalc(d);// 调用(间接)}};// 2) std::move_only_function(只移动,不拷贝,C++23)#include<utility>// C++23 提供 std::move_only_functionusingMoveCalculateYield=std::move_only_function<double(constData&)>;// 3) std::invoke 用法示例(当 callable 可能是成员指针时)#include<functional>structS{doublef(intx)const;};S s;automemptr=&S::f;doubler=std::invoke(memptr,s,42);// 等价于 (s.*memptr)(42)上面
yieldCalc(d)背后的实际调用机制在std::function中通常是通过内部的 type-erased wrapper 来间接跳转到真实目标;而模板直接传入 callable(template<typename F> void foo(F f) { f(); })则可以在编译期内联、优化。
常见误区与实践建议
- 误区:小 lambda 就永远不分配
事实:许多实现有 SBO,但阈值因实现而异;而且某些 callable(捕获较多,或具有复杂类型)仍会分配。不要盲目假设“不会分配”。在性能敏感路径上,用基准或分析工具确认。 - 误区:
std::move_only_function是“更快的std::function”
事实:std::move_only_function的语义(不可拷贝)更适合某些场景,但其调用开销仍是类型擦除风格的间接调用;它并不自动比std::function更快——只是提供不同的所有权语义。 - 性能敏感路径的建议
- 优先考虑模板 DI(编译期注入):
template<typename F> struct Processor { F yieldCalc; ... }—— 零间接调用、可内联,但会导致二进制代码膨胀(每个F一份实现)。 - 如果需要运行时多态且低开销,可自实现小型 vtable 或函数表:把要替换的方法收集成一组函数指针,减少分配并更明确控制 ABI。
- 用
std::function/std::move_only_function在测试和不太敏感的路径:它们极大提高灵活性与可维护性。
- 优先考虑模板 DI(编译期注入):
当需要“替换多个方法”时怎么办?
std::function只能表示一个函数签名;如果你有一个对象的多个方法需要被替换(例如一个策略对象有 N 个方法),常见做法有:
- 传入一个“接口结构/策略对象”(类/结构体,包含多个
std::function成员),每个成员表示一个可替换方法。优点:直观;缺点:每个std::function都有自己的开销。 - 使用模板策略(编译期注入整个策略类型):把整个策略类型作为模板参数,能最大化性能,但增加模板复杂度。
- 抽象基类 + 虚函数(传统的运行时多态):简单明确,语义一致;代价是虚调用开销和继承复杂度。
- 自行实现小型 function-table(手工 type-erasure):在要求很高的场合,用固定大小的内联缓冲和显式函数指针表来替代标准
std::function,以更精准地控制分配与性能。
小结(要点回顾)
- 类型擦除(
std::function/std::move_only_function)非常灵活,适合测试与解耦,但会带来运行时间接调用开销,并且可能发生堆分配(视实现与目标大小)。 std::move_only_function提供了“只移动”的所有权语义(C++23),在需要不可拷贝目标时比std::function更合适。- 如果性能是关键,先测量,必要时用模板注入或自定义更轻量的运行时多态方案替代。
Null Valued Objects(空值对象)模式 —— 详细理解
核心定义
Null Object / Null Valued Object(空值对象)是一种设计模式:
给某个接口提供一个“什么都不做”的实现,用于在测试或某些运行场景下禁用依赖的真实行为。
特点:
- 满足类型要求(能编译、能通过接口约束)
- 无功能(不执行真实逻辑)
- 丢弃所有输入参数
- 返回固定结果(如
true、0、空对象等) - 让系统的一部分被“无害化”(disabled)
为什么需要 Null Valued Objects?
在测试或者构建系统时,有些部分暂时不应参与执行,比如:
- 数据库
- 网络连接
- 文件系统
- 第三方 API
- 长耗时逻辑
- 尚未开发完成的模块
如果用 Null Object,测试时可以: - 避免真实连接数据库
- 避免执行副作用
- 仍然让类型检查通过,代码流程连续
这样单元测试就能集中于你真正关心的部分(通常是业务逻辑)。
代码解析(你的示例)
1. 接口:DBInterface
DBInterfacevirtualboolcommit(...)=0;virtualboolrollback(...)=0;virtualboolstatement(...)=0;这是一个运行时多态接口(面向对象的虚函数接口),描述数据库应有的行为。
测试时我们不想执行真正的数据库操作。
2. NullDB(空对象)
NullDB:publicDBInterface{boolcommit(...)override{returntrue;}boolrollback(...)override{returntrue;}boolstatement(...)override{returntrue;}};解释:
- 每个函数都简单返回 true
(表示“成功”或至少“不要阻断流程”) - 完全无副作用
- 所有参数被忽略(discarded)
这就是典型的Null Object pattern。
这如何“禁用系统某些部分”?
看process(DBInterface& db, …)
autoprocess(DBInterface&db,...){// ...db.apply(...);// 在 NullDB 中:不做任何事// ...db.commit(...);// 在 NullDB 中:不做真实 commitreturnresults;}因为NullDB覆盖了所有数据库操作并让它们“什么都不做”,
所以整个数据库相关逻辑**被禁用(disabled)**了。
流程仍能继续(最关键的点)
虽然真实操作没执行,但函数签名依旧满足:
- 类型匹配
- 返回值提供了“成功”信息(通常是
true)
因此流程不会被中断。
Null Object 与 Stub 的关系
你给出的描述里提到:
A stub with no functionality — only satisfying type requirements
这是正确的,但 Null Object 比普通 Stub 更正式一些:
Stub
- 为测试提供虚假行为
- 通常是“最小化实现”
- 可返回固定值
Null Object
- 专门的“全空实现”
- 所有方法固定做空操作
- 返回固定的“空结果”
- 强调其可替代真实依赖来禁用系统的一部分
也就是说:
Null Object 是一种特殊、更加规范化的 Stub。
空值对象的数学视角(帮你安排使用公式)
Null object 的行为可以抽象成一个函数:
f(x)=c,∀x f(x) = c,\quad \forall xf(x)=c,∀x
其中:
$x$是输入参数(会被忽略)$c$是固定返回值(如true或空对象)
在你的代码中:
NullDB.commit(x)=true \text{NullDB.commit}(x) = \text{true}NullDB.commit(x)=true
NullDB.statement(x)=true \text{NullDB.statement}(x) = \text{true}NullDB.statement(x)=true
这些关系式说明:空对象函数对输入不敏感,总是给相同输出。
使用场景总结
| 场景 | 为什么用 Null Object |
|---|---|
| 单元测试 | 禁用外部依赖(例如 DB / 网络) |
| 模块未完成 | 提供“占位行为”,让其他模块先跑起来 |
| 减少副作用 | 禁止写入文件、提交数据库等操作 |
| 容错 | 当依赖不可用时提供“无害行为”保证系统继续运行 |
小结(重点)
- Null Object = 满足接口 + 空逻辑 + 固定返回值
- 常用于测试,不想让真实系统(DB/网络)运行。
- 参数被完全忽略。
- 返回值通常是“成功”,使流程不中断。
- Null Object 是一种特殊且正式的 Stub。
图示的整体含义:动作(Action)→ 执行(execute)→ 通讯模块(Com)→ 请求(Req)→ 结果(result)
这张架构图描述的是一个系统中典型的“动作执行流程”:
- 外部输入一个Action
- 系统调用对应的execute(…)方法
- execute 内部依赖一个组件Com
- Com 通过调用
send(...)发出请求到Req - Req 返回结果
- execute 将结果处理后返回给调用者(result)
这个流程是典型的:
业务动作 -> 调用执行器 -> 使用被注入的组件 -> 交互 -> 返回结果
图中每个元素的解释
(1) Action(动作)
图顶部的黄色椭圆。
表示系统外部输入的一个操作行为,可以是:
- 一笔交易
- 一个命令
- 用户请求
- 一条业务事件
可以理解为:
“系统要执行什么?”
(2) execute(…)(执行器)
居中的大蓝框。
这是系统接收Action后调用的核心处理函数:
Resultexecute(constAction&act);它负责:
- 验证请求
- 调用依赖的模块(例如 Com)
- 整合或转换结果
- 最终返回 result
(3) Com(通讯组件 / Command Handler)
蓝色圆角矩形。
它是 execute(…) 所依赖的组件,负责:
- 与下游系统通讯
- 构建或发送请求
- 处理网络或外部系统交互
注意:Com 是一个依赖(Dependency)。
它不是 execute 内部生成的,而是通过 DI 注入的。
(4) Req(请求目标 / Request Endpoint)
橙色圆形。
代表实际的执行端,例如:
- 网络服务
- 数据库
- 外部 API
- 消息队列
- 或者 mock 对象(测试时)
Com 对它调用:
send(...)箭头表示这是一条“通信链路”。
(5) result(返回结果)
左侧绿色椭圆。
最终的输出值,可能是:
- 执行结果(success/fail)
- 查询数据
- 状态更新
- 错误码
一般情况下:
result=f(execute,Com,Req) \text{result} = f(\text{execute}, \text{Com}, \text{Req})result=f(execute,Com,Req)
关键点:图中的 “Types of Dependency Injection: Setter Dependency Injection”
这张图用于说明Setter 依赖注入的一个典型使用场景:
execute(…) 依赖一个组件 Com
而 Com 并不是在 execute 内部创建的
而是从外部通过 Setter 注入进去的
例如:
structProcessor{voidsetCom(std::shared_ptr<Com>com){// Setter Injectioncom_=std::move(com);}Resultexecute(constAction&act){returncom_->send(act.req());// 使用注入的依赖}private:std::shared_ptr<Com>com_;};Setter Injection 特点:
- 依赖通过 setter 方法设置进去
- 可在运行时替换依赖
- 在测试时可注入 mock 或 fake
- 适用需要动态交换依赖的场景
结合图示的完整流程说明(详细解析)
① Action 输入 (图顶)
execute(Action) \text{execute}(\text{Action})execute(Action)
这是调用链的入口。
② execute(…) 调用
execute(…) 接收到 Action 后,会执行一系列操作:
- 解析 Action
- 取出必要字段
- 调用注入的依赖:Com
③ execute 调用 Com
图中 Com 在 execute 内部:
com_->send(...)Com 被注入进来,因此 execute 不控制 Com 的真实类型。
- 生产环境 → 真正的 Com 实现
- 测试环境 → MockCom / FakeCom / NullCom
也就是说:
Com 的行为可以通过 DI 完全替换,所以 execute 的逻辑可测试、可独立运行。
④ Com 发送请求到 Req
箭头标记 “send”:
Req=Com.send(payload) \text{Req} = \text{Com.send(payload)}Req=Com.send(payload)
Req 可以是:
- 真正的 API endpoint
- mock endpoint(测试用)
- null endpoint(禁用外部依赖)
⑤ Req 返回响应 → execute 汇总结果
最终 execute 会将结果转变为:
result \text{result}result
这是左边绿色椭圆。
这一页的整体思想(总结)
图示说明的是:
execute 本身不负责通讯,而是依赖一个可以替换的组件 Com
这个组件通过 Setter 注入
execute 只负责 orchestrate(编排流程)
因而 execute 的逻辑是可测试的、可组合的、可扩展的
它展示了业务编排层(execute)如何与基础设施层(Com / Req)解耦。
Setter Dependency Injection —— 详细理解
Setter Dependency Injection(Setter 依赖注入)是一种通过setter 函数注入依赖对象的方式。
示例代码回顾(带语义解释)
classDataProcessor{public:voidsetSender(std::unique_ptr<Com>sender)// Injection{sender_=std::move(sender);}boolexecute(){...sender_->send(...);// Testing...}private:std::unique_ptr<Com>sender_;// Dependency};解析(详细)
1. Setter 注入是什么?
Setter Dependency Injection指的是依赖对象(这里是Com类型的 sender)不是通过构造函数传入,也不是类内直接创建,而是通过单独的 setter 函数进行注入:
依赖对象=在对象构造后,通过 set 函数赋予 \text{依赖对象} = \text{在对象构造后,通过 set 函数赋予}依赖对象=在对象构造后,通过set函数赋予
这种模式通常用于:
- 测试时替换依赖(注入 mock/stub)
- 某些组件在构造时暂时不可用,需要后置注入
2. setSender(…) // Injection
这里的setSender(...)就是依赖注入的入口(Injection point)。
你向对象传入一个依赖对象的实例(可以是真实实现,也可以是 mock)。
系统结构变成:
DataProcessor←setCom 实例 \text{DataProcessor} \xleftarrow{\text{set}} \text{Com 实例}DataProcessorsetCom实例
3. sender_->send(…) // Testing
execute()调用依赖:
- 如果你在测试时注入一个 mock sender
→ 能拦截调用
→ 验证 send 是否被正确调用 - 不需要真实网络、不需要真实系统
这是 DI 的主要目的:
将行为与其所依赖的外部系统隔离,从而方便测试
注意点
Note 1: Signatures have not changed
类方法接口保持不变:
execute()的签名不变- 类的构造函数也不需要改变
Setter 注入不会改变原本函数签名,是它的一个优点。
Note 2: Class spends time in an unusable state
这是 Setter 注入最大的问题。
假设你这样使用:
DataProcessor p;// 此时 sender_ 为空p.execute();// 程序会崩!因为 sender_ 尚未注入也就是说,在调用setSender()之前对象是不可用状态。
换句话说:
∃,t:sender=∅⇒类不可使用 \exists, t: sender_ = \varnothing \quad \Rightarrow \quad \text{类不可使用}∃,t:sender=∅⇒类不可使用
这种设计会导致:
- 易犯错(忘记注入)
- 对象生命周期不安全
- 依赖注入顺序要求严格
Note 3: Set function probably not used in Production
在生产环境中,依赖通常在构造时就已经固定,不会动态切换。
Setter 注入更多是为了测试:
- 生产:真实 sender
- 测试:注入 mock sender
所以 setter 多半只在测试代码里用,生产中 rarely used。
Setter 注入的总结
| 特性 | 描述 |
|---|---|
| 灵活性 | 可以随时替换依赖 |
| 可测试性 | mock/stub 注入方便 |
| 安全性 | 容易产生未初始化状态 |
| 生产代码使用频率 | 通常只在测试用 |
数学式总结 Setter 注入的核心问题:
构造后sender=∅⇒不可用 \text{构造后} \quad sender_ = \varnothing \quad \Rightarrow \quad \text{不可用}构造后sender=∅⇒不可用
Dependency Injection Basics —— 详细理解
这张图描述了最基本的依赖注入流程。核心逻辑如下:
Action 触发 execute(…)
图左上角的Action代表某个输入、请求或操作,例如:
- 用户操作
- 系统事件
- 业务逻辑调用
Action 触发某个函数,例如:
autoresult=execute(action);execute(…) 内部依赖于组件Com
图中 execute(…) 的框内部包含组件Com。
这里表达的意思是:
execute() 自身不完成所有任务,它依赖另一个对象(Com)来完成子任务。
比如:
boolexecute(constAction&act){...com.send(...);// 依赖注入对象...}于是组件之间的关系可以用公式表达:
execute(Action)⟶Com.send(...) execute(\text{Action}) \longrightarrow Com.\text{send}(...)execute(Action)⟶Com.send(...)
Com.send(…) 发送请求到 Req
图中 Com 发送(send)到 Req(可能是:
- 网络请求
- 数据库操作
- 消息队列
- I/O 设备
依赖流为:
Com.send(...)⟶Req Com.\text{send}(...) \longrightarrow ReqCom.send(...)⟶Req
execute() 最终产生 result
图中从 execute 到 result 的箭头表示最终的返回结果:
result=execute(Action) \text{result} = execute(\text{Action})result=execute(Action)
Method Dependency Injection —— 详细理解
第二张图展示了方法级别的依赖注入(Method Injection)。
也就是说:
依赖不是构造时注入
不是成员变量注入
而是作为方法参数直接传入
方法注入的关键区别
注意图中的 execute:
execute(..., &)这表示 execute 函数接受一个引用参数,例如:
Responseexecute(constAction&act,Com&com);Action 和 Com 都作为输入参数传给 execute()
两者都在函数签名中出现:
execute(Action,;Com) execute(\text{Action}, ; Com)execute(Action,;Com)
图中两个黄色椭圆分别代表:
- Action:输入动作参数
- Com:依赖对象,由外部传入
execute(…) 内部使用注入的 Com
与前一张图不同:
- 依赖 Com不再是 execute 内部固定的成员变量
- Com 是变量,是可注入的,是测试可替换的
公式表示:
Com 是由调用者提供,不属于 DataProcessor 本身 Com \text{ 是由调用者提供,不属于 DataProcessor 本身}Com是由调用者提供,不属于DataProcessor本身
Com.send(…) → Req
相同的依赖调用链:
execute⟶Com.send(...)⟶Req execute \longrightarrow Com.\text{send}(...) \longrightarrow Reqexecute⟶Com.send(...)⟶Req
execute(…) 返回 result
结果同样从 execute 输出:
Response=execute(Action,Com) \text{Response} = execute(\text{Action}, Com)Response=execute(Action,Com)
Method Injection 的优点(图中隐含)
- 不污染类的内部结构
— execute 不持有依赖,不需要成员变量。 - 没有未初始化风险
Setter Injection 会产生未初始化状态:
sender=∅ sender = \varnothingsender=∅
而 Method Injection 强制依赖在函数调用时存在。 - 测试非常容易
你只需:
MockCom mock;execute(act,mock);- 可以按需切换依赖
不同场景传入不同实现。
图中最后一行:
Response Execute(…, Com&);
就是标准 Method Dependency Injection 的函数签名:
ResponseExecute(constAction&,Com&);表达:
依赖对象;Com;通过函数参数注入 依赖对象 ; Com ; 通过函数参数注入依赖对象;Com;通过函数参数注入
最终总结(超清晰对比)
| DI 类型 | 注入位置 | 类是否持有依赖? | 是否可能未初始化? | 测试友好度 |
|---|---|---|---|---|
| Constructor Injection | 构造函数 | 持有 | 不可能 | |
| Setter Injection | setter 函数 | 持有 | 可能未初始化 | |
| Method Injection | 方法参数 | 不持有 | 不可能 |
Method Dependency Injection
(方法级依赖注入)
给定代码:
boolexecute(...,Com&sender)// Injection{// ...sender.send(...);// Dependency// ...}这里体现的核心思想是:
依赖(Com)由调用者显式传入某个方法,而不是由对象的成员持有或由构造函数注入。
也就是说,方法需要什么,就通过参数直接告诉它。
特点解析()
1. 方法签名发生了变化
因为新增了一个方法参数Com& sender,所以函数签名改变。
也就是说:
- 原来的函数可能是
f(a,b,c) f(a, b, c)f(a,b,c) - 现在变成了
f(a,b,c,sender) f(a, b, c, \text{sender})f(a,b,c,sender)
因此外部所有调用点都需要修改(缺点之一)。
优点
- 非常灵活:方法调用者可以自由决定传入什么依赖对象。
- 便于测试:
在测试时可以传入 mock/stub 的通信组件:
MockCom→execute \text{MockCom} \rightarrow \text{execute}MockCom→execute
Dependency Injection Basics
(依赖注入基础)
对比 Method DI,这里展示了两种不同的方式:
方式 A:构造函数注入(Constructor Injection)
classProcessor{public:Processor(...,Com&com);// 注入...private:Com&com_;// Dependency Capture};解释
依赖Com通过构造函数注入,因此整个对象生命周期内使用的依赖都是同一个。
数学上可以看成:
Processor=f(config,Com) \text{Processor} = f(\text{config}, \text{Com})Processor=f(config,Com)
这个依赖随后被保存在:
com_∈Processor \text{com\_} \in \text{Processor}com_∈Processor
因此:
方法签名无需改变
ResponseExecute(...);// 无需传入 Com!因为Execute()内部会使用构造函数注入的com_。
方式 B:方法注入(Method Injection)
classProcessor{public:Processor(...);ResponseExecute(...,Com&com);// 注入private:...};解释
依赖不是固定的,而是每次调用Execute()时由外部提供。
因此,函数签名发生变化,但构造函数保持不变。
数学类比:
Execute(req,Com) \text{Execute}(\text{req}, \text{Com})Execute(req,Com)
依赖“临时”注入,不被存储。
构造函数注入 vs 方法注入(数学方式说明)
构造函数注入(依赖固定)
Processor(C)→has com \text{Processor}(C) \rightarrow \text{has com}Processor(C)→has com
Execute(req)→use com_ \text{Execute}(\text{req}) \rightarrow \text{use com\_}Execute(req)→use com_
方法注入(依赖可变)
Execute(req,C)→use C \text{Execute}(\text{req}, C) \rightarrow \text{use C}Execute(req,C)→use C
清晰总结()
| 特性 | 构造函数注入 | 方法注入 |
|---|---|---|
| 注入时机 | 对象创建时 | 方法调用时 |
| 灵活性 | 低(固定依赖) | 高(每次可变) |
| 函数签名是否变化 | 不变 | 需要新增参数 |
| 适用于 | 固定依赖(如日志、持久化、网络) | 方法级别临时依赖、上下文依赖 |
| 易测试性 | 好 | 更好 |
Conceptual Dependency Injection —— 概念性依赖注入
目标:控制系统中所有依赖,做到可测试、可替换、可扩展。
1.Control all Dependencies in a system
(控制系统中的所有依赖)
解析
依赖注入的根本目的:
识别系统中的所有功能模块,并让每个模块的依赖都可被外部控制。
这意味着系统可以被重新组合、测试或模拟。
数学上可以理解为系统SSS的行为依赖于依赖集合DDD:
S=f(D1,D2,...,Dn) S = f(D_1, D_2, ..., D_n)S=f(D1,D2,...,Dn)
通过依赖注入,我们希望:
Di 可由外部提供 D_i \text{ 可由外部提供}Di可由外部提供
而不是写死在系统内部。
2.Identify functional blocks
(识别功能块)
解析
将系统按功能分解成独立的“积木块”(模块):
- 数据处理模块
- 网络通信模块
- 日志模块
- 存储模块
- UI/渲染模块
- 等等
这样每个模块都可以独立注入依赖、独立测试。
3.Allow Injection of flexible functionality
(允许注入灵活的功能)
解析
依赖注入的目的之一是:
让模块可以接收“不同的实现版本”。
例如:
- 真实数据库 vs mock 数据库
- 真实网络 vs 本地缓存
- 正式逻辑 vs 测试版本逻辑
即:
Block(I)→Block 的行为取决于注入的 I \text{Block}(I) \rightarrow \text{Block 的行为取决于注入的 I}Block(I)→Block的行为取决于注入的I
4.Capture inputs, control outputs
(捕获输入,控制输出)
解析
通过 DI,让测试代码可以完全控制模块的输入输出。
例如在测试中,可人为指定:
Input=I0,Output=O0 \text{Input} = I_0,\quad \text{Output} = O_0Input=I0,Output=O0
这样你就能验证:
f(I0)=O0;? f(I_0) = O_0 ; ?f(I0)=O0;?
这在 **表征测试(Characterization Testing)**中特别有用。
5.Where to insert Dependencies
(在哪里放置依赖?)
系统中的依赖分两大类:
类别 A:Invariant Dependencies 不变依赖(固定依赖)
规则:
把所有不变的依赖丢进构造函数。
如日志系统、数据库连接池、线程池等,它们对对象生命周期来说是“固定的”。
数学上:
Object=f(fixed dependencies) \text{Object} = f(\text{fixed dependencies})Object=f(fixed dependencies)
类别 B:其他依赖全部放入方法调用(方法注入)
这些依赖随调用不同而改变,例如:
- 请求上下文
- Session 状态
- 本次任务的策略
- 网络目标地址
数学化描述:
method(x,y,Ddynamic) \text{method}(x, y, D_{\text{dynamic}})method(x,y,Ddynamic)
6.Just add a virtual hop for called functions
(对于需要替换的函数,加一个虚函数跳转)
解析
当你需要让一个函数可替换(例如用于 mock),就:
- 在类里面把函数声明为
virtual - 通过虚函数表实现运行时替换
数学上,这相当于:
f(x)→v(f)(x) f(x) \rightarrow v(f)(x)f(x)→v(f)(x)
其中v(f)v(f)v(f)表示“可以被替换”的版本。
7.Add virtual to function declarations
(在函数声明上加 virtual)
这允许不同实现被注入,例如:
- RealCom
- MockCom
- TestCom
从而:
Processor(C)→Processor 使用 C 的虚函数实现 \text{Processor}(C) \rightarrow \text{Processor 使用 C 的虚函数实现}Processor(C)→Processor使用C的虚函数实现
8.Use function forwarding of calls
(使用函数转发/代理)
例如:
virtualvoidSend(...)=0;voidSendMessage(...){Send(...);}顶层 API(SendMessage)保持稳定,而细节(Send)可被替换。
数学上:
Fpublic(x)=Fvirtual(x) F_{\text{public}}(x) = F_{\text{virtual}}(x)Fpublic(x)=Fvirtual(x)
9.Ignores bad interfaces( 忽略糟糕的接口)
作者的警告:
DI 本身并不能修复烂接口。
即使你加了 virtual、加了注入,如果接口设计糟糕,DI 无法挽救。
10.Warning: Real World starts here
( 警告:现实世界从这里开始)
意思是:
理论很美好,但现实世界里 DI 会暴露很多丑陋的地方——尤其是坏接口、历史遗留代码、过度灵活导致的复杂度。
这时你需要:
- 折衷
- 重构
- 简化接口
- 重新设计模块边界
总结(极简)
| 概念 | 解释 |
|---|---|
| 控制依赖 | 把系统拆成块,让依赖可控 |
| 不变依赖 | 放到构造函数 |
| 可变依赖 | 放到方法里 |
| 虚函数跳转 | 允许替换实现(mock/测试) |
| 函数转发 | 让公共 API 稳定,底层可换 |
| 警告 | DI 无法拯救坏接口 |
Applied Dependency Injection
Dependency Injection roadblocks(依赖注入的实践障碍)
在真实工程里,推行 DI(依赖注入)常会遇到阻碍。下面逐条解释。
1.Objects full creation hidden inside functions/classes
(对象的完整创建过程隐藏在函数/类内部)
详细解释
这意味着:
函数或类内部自己偷偷“new”出依赖对象,而不是从外部传入。
例如:
voidProcessor::Run(){NetworkClient client;// 在函数内部创建依赖client.Send(...);}这种写法让 DI 变得困难,因为依赖已经被“锁死”在方法内部。
数学化表达:
Run()=use(new Client) \text{Run}() = \text{use}( \text{new Client} )Run()=use(new Client)
无法替换,也无法测试。
而我们希望:
Run(C)=use(C) \text{Run}(C) = \text{use}(C)Run(C)=use(C)
这样外部才能注入 mock、fake、test 版本。
为什么这是 roadblock(障碍)?
因为你无法:
- 注入 mock/fake 版本
- 控制函数内部的行为
- 在测试中替换依赖
- 做 A/B 测试或表征测试
因此模块变成:
不可测试+不可替换 \text{不可测试} \quad + \quad \text{不可替换}不可测试+不可替换
2.No handle to inject new functionality
(没有“入口”注入新的功能实现)
详细解释
当类内部创建了依赖对象,同时:
- 没有构造函数参数
- 没有 setter
- 没有方法参数
- 没有虚函数
那么你根本没有办法注入新功能。
就像:
classEngine{public:voidCompute(){Logger logger;// 没有办法替换logger.Log("...");}};数学上:
Engine=闭包系统:内部依赖无法外部控制 \text{Engine} = \text{闭包系统:内部依赖无法外部控制}Engine=闭包系统:内部依赖无法外部控制
导致:
Engine∗real=Engine∗test \text{Engine}*{\text{real}} = \text{Engine}*{\text{test}}Engine∗real=Engine∗test
你无法改变其行为。
3.Default class constructors initialized via Singletons/Globals
(默认构造函数使用单例/全局变量初始化)
这是大型老系统中最常见、也是最严重的障碍。
例如:
classProcessor{public:Processor():db_(GlobalDB::Get()),// 依赖全局单例logger_(SingletonLogger::GetInstance()){}};详细解释:
当默认构造函数直接使用单例或全局变量:
- 依赖被强绑定到单例
- 无法替换为 mock/fake
- 单例有可能是“全局状态污染”源头
- 单元测试会变得不可控
- 多线程中会产生隐性共享状态
数学化:
Processor=f(G1,G2,...,Gn) \text{Processor} = f(G_1, G_2, ..., G_n)Processor=f(G1,G2,...,Gn)
其中GiG_iGi是单例或全局状态。
因此:
- 所有 Processor 的实例都共享相同的依赖
- 测试环境与生产环境无法隔离
- DI 没有切入点
正确方式应该是:
Processor(D1,D2)=use external dependencies \text{Processor}(D_1, D_2) = \text{use external dependencies}Processor(D1,D2)=use external dependencies
总结(表格)
| 障碍 | 含义 | 为什么妨碍 DI |
|---|---|---|
| 内部创建依赖 | 在函数内部 new | 无法替换、无法测试 |
| 没有注入入口 | 无构造参数/方法参数 | 外部无法控制依赖 |
| 使用单例/全局构造默认依赖 | 默认依赖写死 | 测试困难、状态污染 |
一句话总结(超级清晰版)
依赖注入失败的根本原因是:对象内部把依赖“藏起来”了,而不是从外部传进来。
Dependency Injection Hazards
—— 依赖注入中的危险 / 代码陷阱
这一节展示的是:
看起来像是在用依赖注入,其实仍然隐藏着危险的依赖构造方式。
Hazard 1:Object construction isolated inside functions
(危险 1:对象构造被“隔离”在函数内部)
给出的代码:
Class Handler{boolprocessA(Data&data,...){// ...Processorproc(<fixed args>);// ...returnproc.apply(data);}boolprocessB(Data&data,...){// ...Processor&proc=ProcessorSingleton::instance->getProcessor(proc_tag);// ...returnproc.apply(...);}};1⃣ processA 的问题分析
❶ 内部直接创建依赖对象
Processorproc(<fixed args>);这意味着:
- Processor 的构造完全由
processA决定 - 无法从外部注入不同 Processor 实现
- 依赖被“锁死”在函数内部
数学上,表示为:
processA=f(data,Processor(fixed args)) \text{processA} = f(\text{data}, \text{Processor}(\text{fixed args}))processA=f(data,Processor(fixed args))
即 Processor 是不可能被替换的。
❷ 测试困难
由于 Processor 无法通过注入方式替换成 Mock:
Test(processA)=forced to use real Processor \text{Test}(\text{processA}) = \text{forced to use real Processor}Test(processA)=forced to use real Processor
这会导致:
- 单元测试不“单元”
- 测试必须依赖真实 Processor 的副作用(例如 IO/网络/DB)
2⃣ processB 的问题分析(使用 Singleton)
Processor&proc=ProcessorSingleton::instance->getProcessor(proc_tag);❶ 使用单例是 DI 的重大危险点
单例意味着:
- 全局共享状态
- 无法替换
- 无法隔离
- 无法控制生命周期
- 无法在不同测试中使用不同 Processor
数学上:
processB=f(data,G) \text{processB} = f(\text{data}, G)processB=f(data,G)
其中GGG是全局单例。
这使得: - 行为不透明
- 测试不可控
- 依赖耦合度极高
❷ 单例破坏依赖注入的核心原则
依赖注入的核心理念是:
模块行为由外部注入控制 \text{模块行为由外部注入控制}模块行为由外部注入控制
而单例把它变成:
模块行为由全局状态控制 \text{模块行为由全局状态控制}模块行为由全局状态控制
这完全反其道而行之。
Hazard 2:Factory 看似更好,但仍然隐藏危险
第二段代码:
Class Handler{boolprocessA(Data&data,...){Processorproc(<fixed args>);returnproc.apply(data);}boolprocessB(Data&data,...){Processor&proc=Factory->getProcessor(proc_tag);returnproc.apply(...);}};表面上:
- 将单例换成了 Factory
- 看似模块化、抽象化
但实质问题仍在:
1⃣ Factory 依然是全局依赖(如果没有注入)
如果 Factory 不是通过构造注入或方法注入,而是全局访问:
externProcessorFactory*Factory;那么这和单例没有本质区别。
数学表达:
processB=f(data,F) \text{processB} = f(\text{data}, F)processB=f(data,F)
其中FFF是全局 Factory。
2⃣ Factory 提取了对象构造,但仍未提供注入入口
如果 Factory 本身不可替换,那么 Processor 依然不可测试:
Processor=F(proc_tag) \text{Processor} = F(\text{proc\_tag})Processor=F(proc_tag)
但测试时你无法替换FFF。
也就是说你只是把依赖隐藏得更深了。
3⃣ 依赖隐藏导致行为不透明
Handler 在行为上依赖:
- Processor 具体怎么创建
- Factory 具体实现是什么
- Factory 的内部缓存、副作用
这些都不是从函数签名能读出来的。
本节核心总结(数学表达)
| 情况 | 数学描述 | DI 问题 |
|---|---|---|
| 内部 new Processor | process=f(D,Processor(real)) \text{process} = f(D, \text{Processor(real)})process=f(D,Processor(real)) | 无法替换、无法注入 |
| 使用 Singleton | process=f(D,G) \text{process} = f(D, G)process=f(D,G) | 全局状态污染、不可测试 |
| 使用全局 Factory | process=f(D,F(G)) \text{process} = f(D, F(G))process=f(D,F(G)) | 仍然是隐藏依赖,仍然不可注入 |
| 本质问题: |
依赖不是参数的一部分,而是隐藏在函数内部或全局里的。
符合 DI 的应该是:
process(D,P) \text{process}(D, P)process(D,P)
其中PPP是外部传入的 Processor 实现。
最后一句话总结
函数内部构建依赖、使用单例、使用不可注入的工厂,都是依赖注入的大敌,因为它们把依赖藏起来了,并拒绝外部控制。
Applied Dependency Injection
Dependency Injection roadblocks(依赖注入的障碍)
这一节指出 DI 在真实世界中经常遇到的两个大问题:
- 隐式对象构造(上一节已经解释)
- Reaching through multiple objects(跨多层对象取依赖)
下面重点解析第二部分。
Hazard:Reaching through multiple objects
(危险:跨多个对象层级去访问依赖)
代码结构示例(概念性)
类似这样的访问链:
handler.engine().logger().writer().output().write(data);或者:
auto&db=app->service()->context()->database();这是典型的 “reaching through”——跨越多个对象层级去取依赖。
这会导致两个严重问题:
1⃣ Long chains of mock classes needed as boilerplate
(需要长长的 mock 链作为样板代码)
详细解释
例如:
你的代码要访问:
A → B → C → D → Target在测试中,如果你只能用 DI 模式“替代最底层的 Target”,那么你必须:
- mock A
- mock B
- mock C
- mock D
- 最后才能给 Target 注入 fake/mock
这会产生: - 一连串 mock class
- 大量重复的模版样板代码
- 极高的维护成本
- 类图变得臃肿、脆弱
数学上:
Test(A)⇒need mocks for B,C,D \text{Test}(\text{A}) \Rightarrow \text{need mocks for } B, C, DTest(A)⇒need mocks forB,C,D
而这些 mock 和最终要替换的依赖其实毫无关系,只是为了把调用链撑起来。
我们期望的则是:
Test(A,Target) \text{Test}(\text{A}, \text{Target})Test(A,Target)
中间部分自动隔离。
举个具体例子
如果你的代码写成:
handler.getEngine().getDatabase().getConnection().query(...);那么测试就需要 mock:
- MockEngine
- MockDatabase
- MockConnection
才能让 query 能换成 mock。
这就是“mock explosion”(mock 爆炸)。
2⃣ Breaks the Principle of Least Knowledge
(破坏了 “最少知识原则”,又叫 Law of Demeter)
最少知识原则(Law of Demeter,简称 LoD)可以用数学形式表达:
对象只应与它直接的朋友通信,不应知道太多其他对象。 \text{对象只应与它直接的朋友通信,不应知道太多其他对象。}对象只应与它直接的朋友通信,不应知道太多其他对象。
更严格表述:
A 只能调用以下对象的方法: 1.,A 自身 2.,A 的成员 3.,A 方法的参数 4.,A 方法内部创建的对象(短期) A \text{ 只能调用以下对象的方法:} \ \quad 1., A \text{ 自身} \ \quad 2., A \text{ 的成员} \ \quad 3., A \text{ 方法的参数} \ \quad 4., A \text{ 方法内部创建的对象(短期)}A只能调用以下对象的方法:1.,A自身2.,A的成员3.,A方法的参数4.,A方法内部创建的对象(短期)
但 reaching-through 像这样:
a.b().c().d().e().f();违反了该原则,因为 A 不仅知道 B,还知道:
- B 的内部结构
- C 的存在
- D 的层级
- E 的接口
数学化描述:
A⇒B⇒C⇒D⇒E⇒f A \Rightarrow B \Rightarrow C \Rightarrow D \Rightarrow E \Rightarrow fA⇒B⇒C⇒D⇒E⇒f
A 现在对整个链条都有隐性依赖,导致: - 高耦合
- 难重构
- 难测试
- 接口脆弱
- 修改一处引发全局雪崩
结合两类 roadblocks 的更深层理解
我们现在可以这样总结 DI 的两个大障碍:
1.依赖藏在内部(隐藏构造)
数学表达:
Module(x)=f(x,hidden dependencies) \text{Module}(x) = f(x, \text{hidden dependencies})Module(x)=f(x,hidden dependencies)
不可替换。
2.依赖链太深(Reaching through chain)
数学描述为多重耦合:
A=f(B(f(C(f(D(f(E))))))) A = f(B(f(C(f(D(f(E)))))))A=f(B(f(C(f(D(f(E)))))))
链越长:
- 越难替换
- 越难注入
- 越难测试
而我们希望模块依赖是扁平的、直接的:
A=f(B1,B2,B3) A = f(B_1, B_2, B_3)A=f(B1,B2,B3)
或用构造注入:
classA{public:A(B1&b1,B2&b2,B3&b3);};极简总结
| 问题 | 原因 | 后果 |
|---|---|---|
| 隐藏对象构造 | 在函数内部 new 或用单例 | 无法注入、不可测试 |
| 跨层级取对象 | handler.engine().db().conn()… | mock 爆炸、耦合过高、违反最少知识原则 |
一句话总结
隐藏依赖 + 深层对象链 = 依赖注入的两大杀手,它们让 DI 和测试几乎不可能实现。
Dependency Injection Hazards
依赖注入的隐患:跨越多个对象访问(Reaching Through Multiple Objects)
问题示例
voidProcessor::buildQuoteNZFlag(constSide&side){// ...constExch::TickHelper&hp=updater_.processingContext().exchanges().get(side.exchangeNumber()).legacyTickHelper();// ...}这一行代码:
updater_.processingContext().exchanges().get(...).legacyTickHelper()存在“跨越多个对象访问”(Reaching Through Multiple Objects)的典型问题。
也就是说,一个函数(buildQuoteNZFlag)为了取得一个数据(legacyTickHelper),必须依次访问多个层级的对象。
为什么这是一个隐患?
1.违反迪米特法则(Law of Demeter)
**Law of Demeter(迪米特法则)**的核心思想:
一个对象只应该与它的直接朋友交互。不要到处“穿透”对象结构去拿东西。
这和软件工程中减少耦合是一致的。
在现有代码中:
Processor依赖updater_- 又依赖
processingContext() - 又依赖
exchanges() - 又依赖
get(...) - 再依赖
legacyTickHelper()
这样做的问题:
- 高度耦合:
Processor了解太多上下文结构 - 可测试性差:需要一长串 mock 对象链
- 难以维护:改变任何中间对象的 API 都会导致这里的代码被迫修改
- 破坏封装性:内部结构暴露太多
更好的方式:将所需对象直接注入
改写后的版本:
voidProcessor::buildQuoteNZFlag(constSide&side,constLegacyTickHelper&hp){// ...}改动点:
Processor不再穿透多层对象来寻找LegacyTickHelper- 相反,它直接接收所需依赖 hp
- 依赖注入(Dependency Injection)的思想体现出来:
依赖不应该自己去“找”,而应该被“提供”给它。
为什么这样更好?
1.与迪米特法则一致
Processor 只和它的直接依赖交互。
2.可测试性更好
你可以简单传入:
FakeTickHelper mockHelper;processor.buildQuoteNZFlag(side,mockHelper);不需要构造一整套复杂的 processingContext/exchange/updater 等等。
3.降低耦合、提高可维护性
内部结构怎么变化都不会影响 Processor。
总结()
Reaching through multiple objects的意思是:
一个对象为了取得一个数据,要穿透多个层级的中间对象,这会造成过度耦合、难 mock、难测试、难维护。
依赖注入(DI)的改进办法是:
将函数需要的对象(例如LegacyTickHelper)直接作为参数传入,减少链式访问。
迪米特法则(Law of Demeter)的原则是:
只与直接的朋友交互,不要与“朋友的朋友”的朋友交互。
Applied Dependency Injection — 依赖注入的应用障碍(Roadblocks)
依赖注入(Dependency Injection, DI)在大型系统中十分有用,但真正应用时会遇到一些典型困难。下面逐条解释并给出深入理解。
1. Objects full creation hidden inside functions/classes
对象的完整创建被隐藏在函数或类内部
很多代码在类或函数内部这样写:
Processor::Processor(){helper_=std::make_unique<LegacyTickHelper>();}或者:
voidFoo::bar(){Databasedb("config.json");// ...}这样会导致:
- 外部无法替换
LegacyTickHelper或Database - 也无法注入 mock 或替代实现
- 测试时只能使用真实对象,难以控制其行为
问题本质
依赖是自己创建的,而不是外部提供的
→ 这与依赖注入的核心思想Inversion of ControlInversion\ of\ ControlInversionofControl完全相反。
2. No handle to inject new functionality
无法提供“注入点”来替换功能
当一个类内部自己创建依赖时:
- 你无法传入 mock(例如测试用的
MockDatabase) - 你也无法传入替代策略(例如不同的缓存策略)
- 甚至无法在不同部署环境下更换实现
这就叫没有“注入句柄”(handle for injection)。
DI 的目标是:
依赖不应该自己去找,而应该被“提供”给它 \text{依赖不应该自己去找,而应该被“提供”给它}依赖不应该自己去找,而应该被“提供”给它
但隐藏式构造破坏了这一点。
3. Default class constructors initialized via Singletons / Globals
默认构造器里使用全局变量或单例
例如:
Processor::Processor():db_(GlobalDB::instance()){}或:
auto&cfg=Config::instance();这样的做法常见但危险:
- 隐藏依赖来源(不透明)
- 造成隐式耦合(与全局状态绑定)
- 单元测试时难以替换(mock 全局状态通常很复杂)
- 全局状态难控制,容易造成非确定性(non-deterministic)行为
本质上,全局单例破坏了 DI 提倡的:
显式依赖>隐式依赖 \text{显式依赖} \quad > \quad \text{隐式依赖}显式依赖>隐式依赖
4.Reaching through multiple objects
跨越多个层级对象来访问依赖
典型代码:
updater_.context().exchange().get(...).helper()问题:
- 需要 mock整个链条(非常痛苦)
- 破坏迪米特法则(Law of Demeter)
- 让依赖关系复杂且脆弱
遵循 Law of Demeter:
只与直接朋友交互,不要与朋友的朋友交互 \text{只与直接朋友交互,不要与朋友的朋友交互}只与直接朋友交互,不要与朋友的朋友交互
改进方式: - 将最底层需要的数据提前“送上来”
- 或直接作为参数注入
5. Long chains of mock classes needed as boilerplate
测试时需要构造一长串 mock class
例如,为了测试:
helper=updater_.context().exchanges().get(...).helper();测试者必须构造:
- MockUpdater
- MockContext
- MockExchanges
- MockExchangeItem
- MockHelper
这叫mock 地狱(mock hell)。
DI 的目标是:
减少 mock,而不是增加 mock \text{减少 mock,而不是增加 mock}减少mock,而不是增加mock
6. Breaks the principle of least knowledge
破坏最少知识原则(Law of Demeter)
跨层级访问让一个类过度关注其他类的内部结构。
结果:
- 依赖链变长
- 代码变脆弱
- 修改任何一个中间对象都可能破坏调用者
遵循迪米特法则能让对象更“自洽”(self-contained)。
7. Disentangling getting information from setting state
将“获取信息”和“改变状态”混在一起
这是很多代码的深层问题。
许多函数内部即:
- 从对象 A 读取信息
- 然后立即修改对象 B 的状态
例:
intval=config_.getRate();// 读信息cache_.update(val);// 改状态当读取与写入逻辑混在一起时:
- 无法独立测试“逻辑本身”
- 不适合提炼成纯函数(pure function)
- 不适合做 DI(因为不清楚真实依赖)
DI 原则:
先把 “获取信息” 与 “设置状态” 拆开。
从逻辑上来说:
纯函数:只依赖输入,返回输出,不改状态 \text{纯函数:只依赖输入,返回输出,不改状态}纯函数:只依赖输入,返回输出,不改状态
DI 的关键之一,就是让逻辑变成纯的(pure),即:
- fixed inputs
- deterministic output
- 无隐藏副作用
这样才易于注入、替换、测试。
总结()
依赖注入遇到的典型障碍包括:
- 对象在内部创建,无法替换
- 没有“注入点”,无法传入 mock
- 依赖单例/全局变量,造成隐式耦合
- 跨越多个对象访问(违反迪米特法则)
- mock 链太长,测试负担过大
- 违反最少知识原则,使对象知道太多不该知道的东西
- 获取信息和设置状态混在一起,难以做 DI 也难以纯化逻辑
本质目标是:
让依赖变得显式、简单、可替换、可测试 \text{让依赖变得显式、简单、可替换、可测试}让依赖变得显式、简单、可替换、可测试
#⃣ 背景:为什么“获取”和“设置”混在一起是 DI 的障碍?
依赖注入要求:逻辑尽量纯(pure)、输入与输出分离、可复用、可测试。
但很多实际代码把:
- 读取数据(get)
- 写入状态(set)
混在一段流程中,如你给的第一段示例:
if(handle.getBidPrice(&decimalPrice))bid_.setPrice(...);elseif(handle.getBidPrice(&doublePrice))bid_.setPrice(...);...这种代码带来严重问题:
- 业务逻辑散落在条件中,不可复用
- 难以注入 mock,因为读取与写入耦合
- 难测试:每次测试都需要构造完整的状态和依赖对象
- 逻辑不纯,不可进行组合/重写
DI 的核心思想是:
把“获取数据”独立成纯函数,让“设置状态”只处理最终结果 \text{把“获取数据”独立成纯函数,让“设置状态”只处理最终结果}把“获取数据”独立成纯函数,让“设置状态”只处理最终结果
#⃣ 原始代码问题分析(Getting + Setting 混在一起)
你的原始版本:
if(handle.getBidPrice(&decimalPrice))bid_.setPrice(...);elseif(handle.getBidPrice(&doublePrice))bid_.setPrice(...);elseif(handle.getBidPrice(&floatPrice))bid_.setPrice(...);...类似结构forask问题:
(1)逻辑内嵌了读取数据的细节(decimal / double / float)
这意味着:
- 任何修改价格类型解析逻辑都必须进入这个函数
- 业务逻辑 tightly coupled with data format
(2)每个条件都立即 setPrice → 难以抽取、难以测试
你无法单独测试“价格解析逻辑”,因为它和写入状态耦合在一起。
(3)违反单一职责原则(SRP)
函数既负责:
- 获取价格(pure, stateless)
- 更新内部状态(impure, stateful)
这两者最好拆分。
#⃣ 改进版:将获取与设置拆开(Disentangle Getting from Setting)
重构后的代码:
constboost::optional<Tickers::PriceVariant>&bid_price=getBidPrice(handle);if(bid_price)bid_.setPrice(*bid_price);constboost::optional<Tickers::PriceVariant>&ask_price=getAskPrice(handle);if(ask_price)ask_.setPrice(*ask_price);现在逻辑变成:
getBidPrice / getAskPrice —— 纯函数(pure function)
职责:
- 输入:
handle - 解析出价格格式
- 输出:一个
boost::optional<PriceVariant>
数学上类似:
getBidPrice:Handle→Optional(PriceVariant) getBidPrice : Handle \rightarrow Optional(PriceVariant)getBidPrice:Handle→Optional(PriceVariant)
这是一个非常好的 DI 模式:
逻辑已被“纯化”,可以独立注入、独立测试。
bid_.setPrice / ask_.setPrice —— 状态更新(impure function)
职责:
- 将最终结果写入对象内部
数学上是:
setPrice:PriceVariant→State Update setPrice : PriceVariant \rightarrow State\ UpdatesetPrice:PriceVariant→StateUpdate
这部分保持简单即可,不需知道读取逻辑。
#⃣ 这样拆分后带来的好处
1. 更易依赖注入(DI Friendly)
现在你只注入:
- handle mock 或解析器 mock
- Bid/Ask mock
即可测试。
不再需要 mock 整条链,如:
tick → context → exchange → helper → price2. 更易单元测试
你可以独立测试纯函数:
autov=getBidPrice(mockHandle);ASSERT_EQ(v.value(),expected);也可以单独测试状态更新。
测试变得:
- 快速
- 可控
- 无副作用
3. 纯函数可复用、可组合
例如:
- 你可以增加价格过滤逻辑
- 或将价格归一化
- 或将结果送进另一个 pipeline
因为 pure function is easy to compose:
normalized=normalizePrice(getBidPrice(handle)) normalized = normalizePrice(getBidPrice(handle))normalized=normalizePrice(getBidPrice(handle))
4. 清晰的职责边界 = 更少的耦合 + 更高可维护性
逻辑线条变为:
- 从 handle 提取价格(纯函数)
- 更新报价对象(状态函数)
这完全符合现代架构(FP + OO 混合)的最佳实践。
#⃣ 总结(版)
你的示例展示了依赖注入的一个典型危险:
将“获取数据”与“更新状态”混在一起,使逻辑不可测试、不纯、难注入。
改进方式:
- 将获取逻辑提取为 pure function:
getBidPrice(handle)→Optional(PriceVariant) getBidPrice(handle) \rightarrow Optional(PriceVariant)getBidPrice(handle)→Optional(PriceVariant) - 将设置逻辑保持在最简单的状态更新中:
setPrice(PriceVariant) setPrice(PriceVariant)setPrice(PriceVariant)
这样可提升:
- 可替换性(Injection-friendly)
- 可测试性
- 可维护性
- 逻辑清晰度
#⃣Applied Dependency Injection — Roadblock: Too Many Dependencies
问题:一个类/函数块依赖的对象太多
这意味着:
- 类内部要调用许多不同的服务、工具、模块
- 每个依赖如果都要通过构造器或函数方法注入
- 则构造器参数会爆炸(Constructor Parameter Explosion)
例如一个类:
classProcessor{public:Processor(Database&db,Logger&logger,Cache&cache,Metrics&metrics,AuditTrail&audit,Config&config,Network&network,PermissionChecker&permissions,...)当依赖多了之后:
- 函数签名变得巨大
- 类难以理解、难维护
- 创建实例时需要提供一大堆 mock(测试变得沉重)
- 任何变更都会涉及到许多对象,增加耦合
数学上表现为:
Processor=f(D1,D2,...,Dn) Processor = f(D_1, D_2, ..., D_n)Processor=f(D1,D2,...,Dn)
当nnn很大时,这个函数(构造器)就变得不可用(impractical)。
#⃣为什么会出现 Too Many Dependencies?
原因通常包括:
(1) 单一类承担过多职责(违反 SRP)
一个类负责:
- 业务处理
- 数据加载
- 监控记录
- 日志输出
- 缓存查询
- 配置解析
- 状态更新
这自然会导致依赖膨胀。
(2) 类内部“深度 reach through”,从而产生链式依赖
例如:
ctx().engine().exchange().book().helper()一旦你用 DI 把这些对象逐级抽出,最终需要注入一大堆依赖。
(3) 逻辑没有被分解为“纯函数 + 状态更新”
当纯计算逻辑与状态耦合时,会引入更多辅助对象、工具类等依赖。
(4) 没有使用 Facade / Aggregator 模式
当多个依赖其实属于同一功能域,却被当成独立依赖分别传入。
例如:
ConfigLoader ConfigParser ConfigValidator ConfigWatcher其实应该由一个ConfigSystem聚合。
#⃣为什么这是 DI 的重大障碍?
** ① 构造器变得巨大(Constructor Bloat)**
依赖注入要求显式声明依赖,但显式依赖太多时:
- 签名长度太大
- 阅读困难
- 重构困难
** ② 单元测试会变得极其痛苦**
你需要构造大量 mock:
MockDB db;MockLogger logger;MockCache cache;MockMetrics metrics;MockAudit audit;MockConfig config;MockNetwork network;MockPermissions permissions;MockRateLimiter limiter;MockRetryPolicy retry;...Processorp(db,logger,cache,metrics,...);这就是所谓:
Mock Explosion Mock\ ExplosionMockExplosion
** ③ DI 容器(如 Boost.DI,或手写组装器)将变得复杂**
因为谁要构造所有依赖?
→ DI 容器 / builder。
一旦依赖量大,组装将非常困难。
** ④ 表示类职责过多(Coupling Smell)**
太多依赖意味着:
- 类知道太多事情(破坏封装)
- 类关联太多模块(耦合过高)
- 类承担过多任务(违反 SRP)
通常意味着架构有设计问题。
#⃣如何解决 Too Many Dependencies?
** 解决方案 1:分解类(Decompose Class by Responsibility)**
如果依赖D1,D2,…,DnD_1, D_2, …, D_nD1,D2,…,Dn来自不同职责域:
将类拆成多个独立类:
- 一个处理业务逻辑
- 一个处理 I/O
- 一个处理配置
- 一个处理监控/指标
- 一个处理缓存
减少依赖数量:
n→n1,n2,... n \rightarrow n_1, n_2, ...n→n1,n2,...
** 解决方案 2:使用 Facade / Aggregator(并不是 Service Locator)**
把同一功能域的依赖打包:
structMarketDataServices{QuoteProvider"es;PriceNormalizer&normalizer;MarketClock&clock;};从:
Processor(a, b, c, d, e)变为:
Processor(MarketDataServices)依赖变少,逻辑更清晰。
** 解决方案 3:挖掘纯函数**
DI 的建议:
先把“获取信息”变成纯函数,再把状态改动独立出来
例如:
PriceVariant price=extractPrice(handle);// purestorage_.setPrice(price);// impure如果纯函数能独立出来,就不需依赖对象的内部状态,从而减少需要注入的依赖。
** 解决方案 4:上下文对象(Context Object)**
当多个依赖总是成对出现:
- Exchange
- TickHelper
- PriceModel
- Clock
- Rules
就用一个 Context 类封装这些对象。
但注意:不能让 Context 变成“万能工具包”(Service Locator 的危险)。
上下文应只包含该功能域相关的依赖。
总结()
Having too many dependencies in a class
意味着:构造器和函数参数变得巨大,而这会让依赖注入变得不切实际。
主要问题:
- 构造器参数爆炸
- 单元测试需要大量 mock
- 类职责太多(违反 SRP)
- DI 容器逻辑变得复杂
- 读取/写入逻辑混在一起导致更多依赖
解决办法:
- 拆分类职责
- 用 Facade / Aggregator 聚合功能域依赖
- 挖掘纯函数减少依赖需求
- 使用上下文对象代替一堆散乱依赖
最终目标:
越少依赖⇒越容易注入⇒越容易测试 \text{越少依赖} \Rightarrow \text{越容易注入} \Rightarrow \text{越容易测试}越少依赖⇒越容易注入⇒越容易测试
#⃣ 背景:巨大函数签名的 DI 难题
你最初的函数签名是:
boolexecute(DB&,Com&,FileLdr&,Calc&,string,double,string,Cache&,constData&,...){// ...}这个函数依赖大量参数:
- 业务依赖对象(DB, Com, FileLdr, Calc, Cache)
- 配置型参数(string filename, double multiplier, string mode)
- 数据上下文(const Data&)
- 其它 …
大量依赖使得 DI 出现以下问题:
- 函数难以理解、难以维护
- 测试需要构造大量 mock → mock explosion
- 不能自然地进行 DI(参数太多)
- 表示函数职责过多(SRP 被破坏)
#⃣ 错误的“解决方式”:把所有参数塞进一个 Bucket
你展示的Bucket:
structBucket{DB&db_;Com&com_;FileLdr&ldr_;Calc calc_;string filename_;doublemultiplier_;string mode_;Cache&cache_;constData&data_;// ...};然后使用:
boolexecute(Bucket&bucket){// ...}为什么这是NOPE(错误做法)
你的备注里写了:
Unstructured bucket just moves the problem elsewhere
将问题塞进一个无结构的 bucket,只是把问题从 A 挪到 B
这是非常关键的理解点。
本质上:God Bucket = God Object
你只是把原本:
execute(D1,D2,…,Dn) execute(D_1, D_2, \dots, D_n)execute(D1,D2,…,Dn)
变成:
execute(Bucket) execute(Bucket)execute(Bucket)
但 Bucket 内部依然包含:
Bucket=D1,D2,…,Dn Bucket = { D_1, D_2, \dots, D_n }Bucket=D1,D2,…,Dn
问题零改进。
问题 1:Bucket 只是“捆绑参数”,没有结构/语义
Bucket 内的字段:
- 没有逻辑分组
- 没有语义分类
- 没有 domain boundaries
- 内容杂乱无章
它是一个procedural parameter dump(参数垃圾桶)。
它不能回答任何问题: - 哪些字段是强相关?
- 哪些字段一起被使用?
- 哪些字段属于哪一逻辑域?
- 哪些字段自然可以用类聚合?
问题 2:God Bucket 让函数依然知道太多(破坏 Law of Demeter)
一个函数如果接受 Bucket,仍然会写出:
bucket.db_.runQuery();bucket.com_.send();bucket.cache_.lookup();bucket.ldr_.loadFile(bucket.filename_);每一行都在 reach-through。
不符合迪米特法则(Law of Demeter):
Don’t talk to strangers. Only talk to your immediate friends.
“不要跨多层调用;只与直接依赖的对象交互。”
但 Bucket 让函数接触所有依赖对象,反而变得更杂乱。
问题 3:Mock Explosion 问题依旧
为了测试:
MockDB db;MockCom com;MockFileLdr ldr;MockCalc calc;string filename;doublemultiplier;MockCache cache;MockData data;Bucketb(db,com,ldr,calc,filename,multiplier,...);execute(b);Bucket 并没有减少你需要 mock 的数量:
Mocks(Bucket)=Mocks(OldSignature) Mocks(Bucket) = Mocks(OldSignature)Mocks(Bucket)=Mocks(OldSignature)
Bucket 只是把参数打包起来,不减少复杂度。
问题 4:阻断了依赖分层设计(Dependency Layering)
DI 强调关注职责界限:
- IO 层
- 业务逻辑层
- 规则运算层
- 数据转换层
Bucket 把不同层的依赖全捆在一起,让层次结构变得模糊。
问题 5:Bucket 会诱使函数使用不该使用的依赖
因为 Bucket 里“什么都有”,开发者常会不自觉写:
哦刚好有 cache_,那我就查一下; 哦刚好有 db_,那我就写一点 log; 哦刚好有 fileLoader,那我就读取个文件;导致:
- 职责扩散
- 函数肥大
- 越来越像“巨石函数”
问题 6:与 DI 原则相反 —— 依赖不再明确可见
DI 的核心原则是:
让依赖显式化,使其可看见、可测试、可替换 \text{让依赖显式化,使其可看见、可测试、可替换}让依赖显式化,使其可看见、可测试、可替换
Bucket 把所有依赖隐式化:
- 外部看函数签名无法知道依赖了哪些对象
- 违背 DI 的透明性
#⃣ 何时 Bucket 是可接受的?
你写的:
nb: Can work for coupled data of simple types
注:对简单类型的耦合数据(例如成对出现的简单属性)可以使用 bucket
例如:
structRectangleSize{intwidth;intheight;};这是有语义分组的(width/height 同属一类数据)。
但不能用来包“所有依赖”。
#⃣ 总结(核心思想)
你展示的例子说明:
把所有依赖打包成一个 Bucket(God Bucket),不是依赖注入,它只是把原本混乱的依赖移动到另一个地方。
主要问题:
- Bucket 无结构、无语义
- Bucket 不减少 mock
- Bucket 不减少依赖耦合
- Bucket 破坏 Law of Demeter
- Bucket 让签名隐藏依赖(反 DI)
- Bucket 鼓励滥用依赖,导致职责扩散
DI 的目标是减少依赖复杂度,而 Bucket 则把复杂度隐藏起来,反而更难管理。
依赖注入的常见阻碍:详细理解
依赖注入(Dependency Injection, DI)本质是:
把对象需要的依赖从外部传进来,而不是在内部创建。
这样可以提升可测试性、可替换性、可维护性。
但在实际大型 C++/系统开发中,有许多典型阻碍。下面逐条展开详细解释。
1.对象的完整构造隐藏在函数或类内部
问题
许多类或函数在内部直接写:
Processorp(a,b,c);或使用:
auto&proc=ProcessorSingleton::instance().getProcessor(...);这会导致:
- 外部无法替换 Processor(无法注入 mock)
- 测试困难
- 强耦合:函数“绑定”了某个具体实现
就像:
你把食材和做法都封死在锅里,别人无法替换食材,也无法测试过程。
2.穿越多个对象链条(Reaching Through Multiple Objects)
这违反Law of Demeter(迪米特法则):
只与你的直接朋友交互,不要跨越层级访问对象内部的内部。
例如:
context.exchanges().get(id).legacyTickHelper()这需要:
- 构造一整条 mock 链条:
mock exchanges → mock exchange → mock tick helper - 测试成本指数级增加:O(nk)O(n^k)O(nk)
改写成:
voidf(constLegacyTickHelper&hp)即可将依赖直接注入,完全符合 DI。
3.难以区分获取信息与设置状态(Get vs Set 混合)
问题代码:
if(handle.getBidPrice(p))bid_.setPrice(p);...这意味着:
- 代码同时在做“读”和“写”
- 测试 bid 逻辑必须模拟 handle 逻辑
- 强耦合,难测试
改写为:
autobid=getBidPrice(handle);if(bid)bid_.setPrice(*bid);把纯函数(Pure Function)提取出来,就能实现:
- 可测试性提升
- 更容易注入依赖
- 逻辑清晰
4.类或函数拥有过多依赖(God Constructor / God Function)
示例:
boolexecute(DB&,Com&,FileLdr&,Calc&,string,double,string,Cache&,constData&,...)过多参数意味着:
- 代码难读
- 难测试
- 难维护
- 很难全部注入 mock
尝试用 struct “合并”参数:
structBucket{...};但这其实只是把问题挪到另一个地方:
- bucket 内到底哪些成员是 execute 使用的?
- 有些无关成员仍然耦合进来
因此:
God Bucket 与 God Class 本质是同一个问题:
没有真正解耦,只是换了个皮肤。
5.类(尤其是继承层级)塞进太多功能(God Classes)
God Class(上帝类)就是:
- 负责太多事情(违反单一职责 SRP)
- 依赖数量爆炸(几十个成员)
- 无法测试 —— 需要 mock 太多依赖
- 无法替换 —— 逻辑耦合在一起
一个 God Class 如:
MarketProcessor ├── 网络数据解析 ├── Tick 计算 ├── 策略逻辑 ├── 配置管理 ├── 缓存 ├── 文件加载 ├── 日志 └── DB 写入这种类通常依赖数量N>10N > 10N>10,几乎无法注入所有依赖。
DI 在这种情况下难以实施,因为:
- 注入所有依赖参数将导致构造函数长得像:
Constructor(Dep1,Dep2,…,DepN) \text{Constructor}(\text{Dep}_1, \text{Dep}_2, …, \text{Dep}_N)Constructor(Dep1,Dep2,…,DepN) - 每次调用都需要准备一大堆虚假依赖
这就是 DI 的“天花板”。
解决方案通常是:架构拆分、抽象层重构、分离纯逻辑。
总结(从 DI 的角度)
| 阻碍 | 问题本质 | 解决方式 |
|---|---|---|
| 对象构造隐藏 | 无法替换依赖 | 将依赖作为参数传入 |
| 穿越多个对象 | Mock 链条过长,违反 Demeter | 将所需对象直接注入 |
| 获取与设置混杂 | 强耦合 | 抽取纯函数 |
| 依赖过多 | 参数膨胀 | 拆分功能、减少职责 |
| God Classes | 大量内部耦合 | 架构重组、分层 |
为什么要拆解 God-Like Class?
God-Like Class(上帝类)是指:
- 类里塞满数十个功能
- 负责读取数据、计算、验证、储存、发送、业务逻辑等
- 耦合程度极高
- 单元测试困难甚至无法测试
- 依赖数量巨大,难以注入
- 重构成本高,扩展困难
它通常让类的职责数量NNN达到一个不可控的规模:
N≫1 N \gg 1N≫1
依赖注入(DI)本质上要求结构是“可分离的”,而 God Class 内部各种功能“粘”在一起,因此:
不先对 God Class 进行重构,DI 根本无从谈起。
Step 1:识别 God Class 内的功能块
原始 God Class 内部包含:
- Pricing(定价)
- Sizing(规模、数量计算)
- Actions(操作动作)
- Saving(保存)
- Sending(发送)
- Business Logic(业务逻辑)
- verifying(校验)
- rules(规则判断)
这些逻辑全部揉在一起,使得类像下面这样:
GodClass ├── Pricing ├── Sizing ├── Actions ├── Business Rules ├── Verifying ├── Saving └── Sending这种结构导致:
- 修改一个功能会影响全部
- 每个功能都依赖其他功能
- 无法独立测试校验逻辑、规则逻辑等
Step 2:提取“胶水逻辑”(Glue Logic)与“功能逻辑”(Function Logic)
重构目标:
把 God Class 切成多个独立的、可单元测试的功能模块,外加一个 Glue(胶水)层负责协调。
重构后的结构:
God-Like Class └── Glue Logic(协调者) ├── Pricing ├── Actions ├── Sizing ├── Verifying ├── Rules ├── Business Logic ├── Saving └── Sending解释:
- Glue Logic
只做调度,不执行核心业务
不包含算法,不包含计算式,不保存状态
只是负责调用其他模块的 API:
Pricing→Sizing→Actions \text{Pricing} \rightarrow \text{Sizing} \rightarrow \text{Actions}Pricing→Sizing→Actions - 各子模块成为独立的类:
PricingEngine SizingEngine ActionEngine RuleEngine
每一个都可以在 DI 的世界里作为可注入依赖。
Step 3:按功能进行模块化拆分(重点)
现在开始指明 God Class 该如何拆解:
① Gather Information(信息收集)
一个负责“输入数据收集”的小模块,例如:
- 从 Tick 中取价格
- 从缓存中取状态
- 从市场信息查询参数
该模块成为“数据准备器”:
class InfoGatherer { ... };② Pricing(定价逻辑)
定价逻辑通常是纯函数性质,拆出来可以完全独立测试:
price=f(market data,config) \text{price} = f(\text{market data}, \text{config})price=f(market data,config)
可注入模块:
class PricingEngine { ... };③ Sizing(数量/规模计算)
这通常依赖价格,但仍可以作为独立模块:
class SizingEngine { ... };④ Business Logic / Verifying / Rules(业务逻辑 / 验证 / 规则判断)
这些通常耦合最严重,需要分拆如下:
- verifying(校验逻辑)
- rules(规则判断)
- business logic(业务逻辑组合)
拆成:
class RuleEngine {...}; class VerificationEngine {...}; class BusinessEngine {...};这样所有业务规则可以独立测试。
⑤ Distribution Logic(分发与结果处理)
包括:
- Saving(保存)
- Sending(发送)
这些也独立成模块:
class StorageEngine {...}; class SendEngine {...};为什么这样拆才利于 DI?
因为在最终 DI 场景里,我们构造一个“组合器”:
classProcessor{PricingEngine&pricing_;SizingEngine&sizing_;ActionEngine&actions_;RuleEngine&rules_;VerificationEngine&verify_;StorageEngine&storage_;SendEngine&send_;public:Processor(...all injected...){}};所有依赖已变成:
- 可替换
- 可 mock
- 可单元测试
- 可独立部署
- 可扩展
God Class 切割完成后,依赖注入就自然成立。
最终结构总结
拆解前:
God Class ├── Pricing ├── Sizing ├── Actions ├── Verifying ├── Saving ├── Sending └── Business Logic拆解后:
Glue Logic ├── InfoGatherer ├── PricingEngine ├── SizingEngine ├── ActionEngine ├── VerificationEngine ├── RuleEngine ├── BusinessEngine ├── StorageEngine └── SendEngine收益
- 封装功能
- 提高可测试性
- 便于 DI
- 便于重构
- 模块之间不再互相污染
- 单一职责原则 SRP 满足
- 可读性、可维护性提升
这是大型 C++ 系统从 God Class 转向可测试架构的关键步骤。
Refactoring for DI(为依赖注入而重构)
本节展示如何将一个 God-Like Class 拆解成结构清晰、可 DI(依赖注入)、可测试、可维护的组件。
核心思想:
先拆解职责,再让各模块之间通过 DI 协作。
Marshaling Class(编排类 / 调度类)
Marshaling Class { Gather Information Distribution Logic Business Logic }Marshaling Class 的角色:
- 不是做业务计算的
- 不保存复杂状态
- 不含算法
- 只负责流程调度(orchestration)
也就是:
Marshaler=Coordinator(Gather, Business, Distribute) \text{Marshaler} = \text{Coordinator}(\text{Gather},\ \text{Business},\ \text{Distribute})Marshaler=Coordinator(Gather,Business,Distribute)
它只负责决定顺序,比如:
- Gather Information
- Business Logic
- Distribution Logic
而不参与任何细节。
Gather Information(信息收集模块)
Gather Information{ Pricing Actions Sizing }Information Gatherer(信息收集器)负责从系统中提取运算需要的数据,通常是:
- 获取市场行情(Tick)
- 获取缓存状态
- 获取配置
- 获取消息数据
信息收集是一个“准备工作”,但内部也包含子流程: - Pricing(定价相关)
- Actions(产生可执行动作的信息)
- Sizing(数量规模信息)
也就是说 GatherInformation 的逻辑结构是:
Gather=Pricing Info, Action Info, Sizing Info \text{Gather} = { \text{Pricing Info},\ \text{Action Info},\ \text{Sizing Info} }Gather=Pricing Info,Action Info,Sizing Info
Gather Information 模块的职责:
将原始输入加工成业务逻辑可以直接使用的结构化数据。
Pricing Class(定价类)
Pricing Class { Raw Pricing Aggregate Prices Pricing Adjustments }Pricing 是典型的业务核心逻辑,因此它被单独抽出为一个可测试模块。
它内部有三个主要步骤:
① Raw Pricing(原始定价)
例如:
- 市场原始 bid/ask
- 原始 tick 数据
- 基础价格(例如指数、净值)
公式可以表示为:
Praw=fraw(market data) P_{\text{raw}} = f_{\text{raw}}(\text{market data})Praw=fraw(market data)
② Aggregate Prices(价格汇聚/聚合)
可能需要:
- 多市场合成价
- 多数据源取最优价
- 多层行情聚合
例如:
Pagg=fagg(Praw,1,Praw,2,...) P_{\text{agg}} = f_{\text{agg}}(P_{\text{raw},1}, P_{\text{raw},2}, ...)Pagg=fagg(Praw,1,Praw,2,...)
③ Pricing Adjustments(价格修正)
包括:
- risk adjustments(风险调整)
- spread adjustments(点差加减)
- rounding(四舍五入)
- model-based adjustment(模型修正)
例如:
Pfinal=Pagg+Δadjust P_{\text{final}} = P_{\text{agg}} + \Delta_{\text{adjust}}Pfinal=Pagg+Δadjust
所以整个 Pricing 流程实际上是:
Pfinal=fadjust(fagg(fraw(market))) P_{\text{final}} = f_{\text{adjust}}\big( f_{\text{agg}}( f_{\text{raw}}(\text{market}) ) \big)Pfinal=fadjust(fagg(fraw(market)))
具有明确的阶段与嵌套结构。
为什么这样拆解有利于依赖注入(DI)?
因为每个模块都可以独立:
- Mock
- 替换
- 测试
- 组合
- 重构
- 并行开发
Marshaling Class 调用顺序变成:
price_info = Pricing.RawPricing(...) price_agg = Pricing.AggregatePrices(price_info) price_adj = Pricing.PricingAdjustments(price_agg)而不是一个 God Class 内部塞满几十个相互耦合的函数。
DI 的原则告诉我们:
越细粒度的功能隔离,越容易通过构造注入、方法注入、接口注入等方式进行替换与测试。
最终重构关系总结
原始状态(God Class)
- 一堆不同类型的功能混在一起
- 每个函数互相调用
- 依赖很多
- 无法单测
- DI 难以落地
重构后结构
Marshaling Class:调度整体流程
Marshaling Class ├── Gather Information ├── Business Logic └── Distribution LogicGather Information:负责数据准备
Gather Information ├── Pricing Info ├── Actions Info └── Sizing InfoPricing Class:负责价格处理
Pricing Class ├── Raw Pricing ├── Aggregate Prices └── Pricing Adjustments通过这种分层:
- 每个模块变成可注入组件
- Marshaling Class 成为流程 orchestrator
- DI 可以自然应用
- 单元测试覆盖率与质量极大提升
- 多团队协作更容易(接口边界清晰)
Applied Dependency Injection
依赖注入在真实系统中的障碍(详细解析)
依赖注入(Dependency Injection, DI)在理论上很完美,但在真实大型 C++ 系统里,常常遇到各种阻碍。下面逐条解释这些 roadblocks 的含义、成因与后果。
1⃣ Objects full creation hidden inside functions/classes
对象在函数/类内部完全创建 → 无法注入外部依赖
voidprocess(){DB db;// 在函数里 new / stack 创建Cache cache;// 在函数内部隐藏Processorp(db,cache);}问题
- DI 必须能够从外部传入依赖
- 但如果依赖对象在内部创建,就无法替换
换句话说:
inside-created object⇒cannot inject \text{inside-created object} \Rightarrow \text{cannot inject}inside-created object⇒cannot inject
后果
- 无法使用 mock / fake 对象
- 无法单元测试
- 逻辑紧耦合
2⃣ Default class constructors initialized via Singletons/Globals
依赖来自单例 / 全局状态 → 隐式依赖,无法替换
许多旧代码这样写:
auto&db=DB::instance();auto&com=GlobalCom;问题
- 单例 = 隐式依赖
- DI 无法用 mock 替代
- 所有使用者都依赖同一个全局对象,耦合度极高
公式化表示:
Singleton=Global State=¬DI \text{Singleton} = \text{Global State} = \neg \text{DI}Singleton=Global State=¬DI
3⃣ Reaching through multiple objects
多层对象链式访问 → mock 链太长,破坏最少知识原则
示例:
a.b().c().d().getValue();需要 mock:
- mock A
- mock B
- mock C
- mock D
结果
- mock 链条越来越长
- 违背Law of Demeter(迪米特法则)
只和你的直接朋友说话
- 难以测试
Long chain mocks⇒High cost testing \text{Long chain mocks} \Rightarrow \text{High cost testing}Long chain mocks⇒High cost testing
4⃣ Disentangling getting information from setting state
获取数据 + 设置状态 混在一起 → 难以提取纯函数
示例:
price=handle.getBidPrice();bid_.setPrice(price);问题
- 获取信息(pure function)
- 设置状态(side effect)
混在一起,使得: - pure function 无法单测
- setter 逻辑太乱
- 测试只能从外部观察状态改变
拆分后应该是:
value=get_price(input) \text{value} = \text{get\_price(input)}value=get_price(input)
再:
set_price(value) \text{set\_price(value)}set_price(value)
5⃣ Having too many dependencies
一个类的依赖数量太多 → 构造函数 / 方法参数爆炸
例如:
Processor(DB&,Cache&,Logger&,Risk&,Market&,FileLdr&,Policy&,Config&,...);当依赖数量达到NNN:
N→10, 15, 20 N \rightarrow 10,\ 15,\ 20N→10,15,20
会出现:
- 构造函数冗长
- DI container 难以维护
- mock 数量庞大
- 类职责过重
这意味着:
Too many deps⇒Bad design⇒Cannot DI \text{Too many deps} \Rightarrow \text{Bad design} \Rightarrow \text{Cannot DI}Too many deps⇒Bad design⇒Cannot DI
6⃣ God Classes doing too many things
上帝类做太多事 → 依赖太多,无法注入
God Class 的特征:
- 内含大量不同类型的逻辑(pricing、saving、actions、validation 等)
- 依赖数量巨大
- 每个方法都依赖大量成员变量
- 功能耦合
DI 无法在这种类上工作,因为:
God Class⇒Too many deps⇒No DI \text{God Class} \Rightarrow \text{Too many deps} \Rightarrow \text{No DI}God Class⇒Too many deps⇒No DI
必须先拆解 God Class 才能注入。
7⃣ Functionality splintered and spread throughout codebase
功能碎片化、分散化 → 不可单测,不可注入
出现三种常见问题:
① Fragmented throughout the inheritance chain
功能分布在多层继承链:
BaseClass ├── does half of actionA DerivedClass ├── does other half of actionA问题
你必须读完整个继承树才能理解一个方法的全部行为。
② Duplicated throughout the codebase
相同逻辑复制粘贴到不同类:
Class A: same logic Class B: same logic Class C: same logic问题
- 逻辑一旦修改,需要更新所有地方
- DI 无法抽象成可注入的统一功能
③ Blended into general utility classes
一些“通用工具类”中加入业务逻辑:
Utils::PriceHelper Utils::MathHelper Utils::ActionUtils问题
- 工具类“变成垃圾桶”
- 无法独立 mock
- 强依赖静态函数
- DI 无法介入静态函数
Static Utils⇒¬DI \text{Static Utils} \Rightarrow \neg \text{DI}Static Utils⇒¬DI
综合总结
依赖注入失败的原因可以归纳为:
Hidden Creation+Global State+Long Chains+Mixed Concerns+Too Many Deps+God Classes+Fragmentation \text{Hidden Creation} + \text{Global State} + \text{Long Chains} + \text{Mixed Concerns} + \text{Too Many Deps} + \text{God Classes} + \text{Fragmentation}Hidden Creation+Global State+Long Chains+Mixed Concerns+Too Many Deps+God Classes+Fragmentation
这些因素使得 DI 难以应用,测试难以编写,代码难以维护。
DI 真正想达到的是:
Pure Functions+Small Modules+Explicit Dependencies+No Global State+Replaceable Components \text{Pure Functions} + \text{Small Modules} + \text{Explicit Dependencies} + \text{No Global State} + \text{Replaceable Components}Pure Functions+Small Modules+Explicit Dependencies+No Global State+Replaceable Components
只有架构健康了,DI 才能顺利实施。
Splintered Functionality(碎片化功能)是什么?
在大型代码库里,某些业务逻辑(如 verifying 校验功能)经常不是集中在一个地方,而是:
- 到处都是
- 分布在多个 Utility 文件里
- 散落在组件(Component)内部
- 被多次复制(copy-paste)
- 混杂在不相关的类中
这种状态称为功能碎片化(Splintered Functionality)。
原始结构(糟糕案例)
Application Class { verifying // 应用中需要做验证 } verifying -> Utility 1 { V1 } verifying -> Utility 2 { F2 } verifying -> Utility 3 { F3 } verifying -> Utility 4 { F4 } verifying -> Component // 甚至散落在组件类里存在的问题:
1.验证逻辑散落到处,难以维护
- 功能分布在 Utility1、Utility2、Component、多个 class
- 修改一个验证需要搜遍整个 code base
2.功能无法独立抽取,阻碍 DI
依赖注入需要把功能封装为可替换的组件(可 mock,可注入)。
但碎片化的功能具有以下特征:
- 不集中
- 无法抽象出一个接口
- 需要穿越很多层级才能调用(违反 “最少知识原则”)
3.隐藏依赖太多,难以 mock
要测试 verifying 功能,你必须 mock:
- Utility1
- Utility2
- Utility3
- Component
而这些类都不是为 DI 设计的,因此没有接口,也不支持替代。
4.强耦合(高依赖度)
- Application → Utility1
- Application → Utility2
- Application → Utility3
- Application → Component
每个验证步骤都绑定具体实现。
如何进行 Refactor(重构)
目标是变成:
- 单一入口
- 单一职责
- 可组合
- 可 mock
- 可 DI 注入
例如:
class Verifier { public: bool verify(const Data& d); };Verifier 内部组合原来散落的多个功能:
class Verifier { Utility1& u1; Utility2& u2; Utility3& u3; ComponentVerifier& comp; public: bool verify(const Data& d) { return u1.V1(d) && u2.F2(d) && u3.F3(d) && comp.check(d); } };Application 原来直接调用一堆 Utility:
Application { verifying(); // 内部多个模块 }现在变成:
Application { Verifier& verifier_; verifier_.verify(data); }重构后优势
✓Unit testing(可单测)
你可以 mock 一个 Verifier:
MockVerifier mock; EXPECT_CALL(mock, verify(_)).WillReturn(true);测试 Application 无需理会 Utility1〜4。
✓Dependency Injection(可依赖注入)
Verifier 不再依赖具体类,而是依赖接口:
class IUtility1 {}; class IUtility2 {}; class IUtility3 {}; class IComponentVerifier {}; class Verifier { IUtility1& u1; IUtility2& u2; IUtility3& u3; IComponentVerifier& comp; };这样:
- Application 可以注入一个真实 Verifier
- 测试可以注入 MockVerifier
✓Refactor(可重构)
功能不再散落,可以轻松:
- 合并逻辑
- 优化结构
- 替换具体实现
- 抽象新 API
总结()
碎片化功能会导致功能到处散落,没有集中封装,不利于单测,不利于 DI,难以维护。
将碎片化功能集中抽象到单一类(如 Verifier)能带来:
- 结构清晰
- 容易 mock
- 容易注入 DI
- 单元测试容易
- 代码可维护性大幅提高
- 为下一步架构升级(如分层、抽象接口)打下基础
背景问题:为什么需要 “参数整合(Parameter Consolidation)” 来支持 DI?
在依赖注入(Dependency Injection)中,一个常见麻烦是:
函数或类拥有大量零散的参数,越来越多,越来越复杂,难以重构、难以注入、难以 mock。
你提供的例子正是这一类问题:
示例代码的问题结构
基类 Builder:
classBuilder{public:virtualvoidbuild(constTick&tick)const{Data info=collector_.getData(tick,bid_,ask_,localAsk_,localBid_);//...}protected:// Sides infostd::optional<Side>bid_;std::optional<Side>ask_;std::optional<Side>localBid_;std::optional<Side>localAsk_;};这里只需要 Side 信息(bid/ask)。
参数数量尚可控(4 个)。
派生类 DBuilder:
classDBuilder:publicBuilder{public:virtualvoidbuild(constTick&tick)constoverride{Data info=collector_.getData(tick,bid_,ask_,localAsk_,localBid_,bidBroker_,askBroker_,bidYield_,askYield_);//...}protected:// ExtraFieldsstd::optional<Broker>bidBroker_;std::optional<Broker>askBroker_;std::optional<Yield>bidYield_;std::optional<Yield>askYield_;};DBuilder 增加了:
- 2 个 Broker 信息
- 2 个 Yield 信息
→ 参数变成8 个。
随着需求增加,会继续增加更多字段,导致: - getData(…) 参数爆炸
- Builder/DBuilder 之间接口不一致
- 难以 mock collector_
- 难以把逻辑转成可 DI 的结构
- 继承结构脆弱(每增加一个字段所有派生类都要调整)
关键问题总结
1. 参数数量膨胀(Parameter Explosion)
导致函数不可维护。
2. 多个可选字段(optional)散乱存放
逻辑上属于“数据的一部分”,却分散在类中。
3. Builder 与 DBuilder 的接口不一致
违反 Liskov 替换原则(LSP)。
4. collector_.getData(…) 变得无法重构
因为参数数量太多,不易抽象成一个结构体。
5. DI 难以进行
因为要传递 N 多字段,mock 需要知道所有参数,非常痛苦。
解决方案:数据结构整合(Parameter Consolidation)
思想很简单:
将多个散乱的相关字段聚合成一个结构体(Value Object)。
例如可以定义:
structSideInfo{std::optional<Side>bid;std::optional<Side>ask;std::optional<Side>localBid;std::optional<Side>localAsk;};对 DBuilder 再扩充:
structExtendedInfo:SideInfo{std::optional<Broker>bidBroker;std::optional<Broker>askBroker;std::optional<Yield>bidYield;std::optional<Yield>askYield;};Collector 的接口会从:
getData(tick, a, b, c, d, e, f, g, h)变成:
getData(tick, SideInfo) getData(tick, ExtendedInfo)更进一步:完全移除继承结构
原始结构:
Builder ↑ DBuilder重构结构:
Builder { InfoType info_; // SideInfo 或 ExtendedInfo }你可以做到:
- Builder 不需要继承。
- 不同类型的 builder 只需要不同的信息结构。
- 信息结构天然可 DI。
重构后的好处(DI 视角)
✓ 参数不再爆炸
参数数量被压缩到1 个结构体。
✓ Collector 接口更加清晰
从难懂的 8 个 optional:
getData(tick, bid, ask, localAsk, localBid, bidBroker, ...)变成:
getData(tick, info)✓ 结构体可被 mock → DI 自动变简单
mock 的 collector 不用关心 8 个字段,只关心 InfoType。
✓ 更符合 SRP(单一职责)
Builder 负责处理逻辑;
InfoType 负责存放数据;
Collector 负责提取数据。
✓ 扩展容易
如果以后加一个字段:
info.newField = ...;Collector 也只需要更新结构体读取逻辑。
不会影响 build() 的签名。
总结
你展示的代码揭示一个常见 DI 难题:
“类中分散着大量 optional 字段,这些字段被不断传给外部函数,造成参数膨胀,使 DI 和测试都变得痛苦。”
解决方法:
- 将这些字段整合成结构体(SideInfo,ExtendedInfo)。
- 替代冗长的 getData(…) 参数列表。
- 取代继承体系,减少 override build 的复杂度。
- 让 Builder 只关注逻辑,而不是管理几十个 optional 字段。
最终结果:
- build() 签名变简单
- collector_.getData() 更易理解
- DI 容易注入不同信息结构
- 测试更容易 mock 信息结构
- 维护成本大幅降低
背景:为什么要做 OptionalPairT 重构?
在原始代码中,Collector 的接口如下:
DatagetData(constTick&,conststd::optional<Side>&bid,conststd::optional<Side>&ask,conststd::optional<Side>&localBid,conststd::optional<Side>&LocalAsk)const;以及增强版:
DatagetData(constTick&,conststd::optional<Side>&bid,conststd::optional<Side>&ask,conststd::optional<Side>&localBid,conststd::optional<Side>&LocalAsk,conststd::optional<Broker>&bidBroker,conststd::optional<Broker>&askBroker,conststd::optional<Yield>&bidYield,conststd::optional<Yield>&askYield)const;问题非常典型:
原始接口的问题(Collector Interface Hazards)
1.Hard to use(难以使用)
函数签名长得像蛇:
- 多个 std::optional
- 参数顺序容易弄错
- 使用非常痛苦、阅读非常困难
2.Weakly typed(弱类型)
例如:
bid, ask, localBid, LocalAsk从类型上看:
- 全部都是
std::optional<Side> - 完全无法体现哪些是 “global”,哪些是 “local”
- 调用者很容易传错参数
这就违反了Strong Typing(强类型)原则,也违反了 DI 设计中的明确性(Explicitness)
重构:引入 OptionalPairT
你提供的模板:
template<typenameT>structOptionalPairT{std::optional<T>bid_;std::optional<T>ask_;};于是可以定义:
usingSidePair=OptionalPairT<Side>;usingBrokerPair=OptionalPairT<Broker>;usingYieldPair=OptionalPairT<Yield>;从而把成对出现的 bid/ask 参数合并为一个结构体。
重构后的 Collector 接口
基础版:
DatagetData(constTick&,constSidePair&sides,constSidePair&localSides)const;增强版:
DatagetData(constTick&,constSidePair&sides,constSidePair&localSides,conststd::optional<Broker>&bidBroker,conststd::optional<Broker>&askBroker,conststd::optional<Yield>&bidYield,conststd::optional<Yield>&askYield)const;详细理解(逐条解析)
① OptionalPairT:抽象出“成对信息”的通用结构
关键思想
bid 与 ask 总是成对存在
→ 让它们合并成一个 Pair,而不是两个零散字段。
比如:
SidePair sides;sides.bid_=...sides.ask_=...而不是:
bid, ask 两个独立变量这样更具有逻辑完整性。
② 强类型化:让代码具有语义
原来:
getData(tick, bid, ask, localBid, localAsk)五个 optional,看不到语义。
现在:
getData(tick, sides, localSides)你可以立即知道:
- sides → 全局报价
- localSides → 本地报价
结构化信息让代码可阅读性大幅提升。
③ 可扩展性更强(Open/Closed Principle)
使用 OptionalPairT 之后:
如果未来 Side 类型扩展,或者要加更多 pair:
VolumePair LiquidityPair FlagPair只需:
using VolumePair = OptionalPairT<Volume>;Collector 接口仍然保持清晰结构,不会出现参数爆炸。
④ 升级 DI:更容易 mock 与注入
在 DI(Dependency Injection)中,一个关键点是:
结构化数据比零散参数更容易注入(inject)。
例如:
你现在可以注入:
SidePair injectedSides;SidePair injectedLocalSides;mock 也只需要构造 SidePair,而不是构造 8 个 optional。
这就是 DI 最关心的封装依赖与最小参数表。
⑤ 分类整理:减少 Collector 重载
原始代码的增强版 getData:
bidBroker, askBroker bidYield, askYield其实也可以进一步重构(建议):
usingBrokerPair=OptionalPairT<Broker>;usingYieldPair=OptionalPairT<Yield>;那么 enhanced Data 就变成:
DatagetData(constTick&,constSidePair&sides,constSidePair&localSides,constBrokerPair&brokerPair,constYieldPair&yieldPair)const;这样 Collector 函数不需要 8 个 optional,只需 4 个结构体。
→ 数据结构更加清晰
→ DI 更容易
→ 调用不容易出错
最终效果总结()
你展示的重构过程达成了以下目标:
把散乱的 optional 参数整合为结构体(OptionalPairT)
减少参数数量,提高语义表达力。
Collector 接口更强类型化,难以调用错误
参数变得更清楚,避免 bid/ask/localBid/localAsk 混淆。
非常有利于依赖注入(DI)
因为结构体是天然可注入、可 mock 的数据单元。
DI 中常见的“参数爆炸”问题得以缓解
不再传 8 个 optional,而是传 2~4 个结构化对象。
代码变得易读、易写、易维护
工程师只需处理 Pair 结构,而不是几十个散乱字段。
如何重构大量散乱的 optional 参数,使函数接口更加清晰、强类型、可扩展,并更符合 DI(依赖注入)。
Bloomberg 的做法是:
- 第一步:把 bid/ask 这一对数据合并成
OptionalPairT<T> - 第二步:把组合出现的两组 pair(global + local)合并为
SideInfo - 第三步:把扩展字段(broker、yield)合并为
ExtraFields - 第四步:把 Collector 的 getData 接口简化为:
- 基础版:
getData(tick, sideInfo) - 扩展版:
getData(tick, sideInfo, extraFields)
- 基础版:
- 最后:Builder / DBuilder 调用也变得更清晰、更强类型。
参数爆炸的旧版本接口
旧的 Builder:
Data info=collector_.getData(tick,bid_,ask_,localAsk_,localBid_);DBuilder 更糟糕:
Data info=collector_.getData(tick,bid_,ask_,localAsk_,localBid_,bidBroker_,askBroker_,bidYield_,askYield_);问题:
- 参数太多(8 个 optional)
- 全是
std::optional<X>→ 弱类型(weakly typed) - 容易传错顺序
- 不利于 mock / 不利 DI(依赖注入)
- Collector 接口难维护
引入 OptionalPairT,减少参数数量
设计:
template<typenameT>structOptionalPairT{std::optional<T>bid_;std::optional<T>ask_;};usingSidePair=OptionalPairT<Side>;usingBrokerPair=OptionalPairT<Broker>;usingYieldPair=OptionalPairT<Yield>;Collector 接口变成:
DatagetData(constTick&,constSidePair&sides,constSidePair&localSides)const;DatagetData(constTick&,constSidePair&sides,constSidePair&localSides,constBrokerPair&,constYieldPair&)const;Builder 改成:
SidePair sides_;SidePair localSides_;DBuilder:
BrokerPair brokers_;YieldPair yields_;优点
- 参数数量减少
- 语义更清晰:bid/ask → 一个 Pair
- 不容易传错顺序
- 更适合 DI 和 mock(注入一个 SidePair,而不是 2~8 个 optional)
为组合字段再次分组 → SideInfo & ExtraFields
进一步封装数据:
structSideInfo{SidePair sides;SidePair localSides;};structExtraFields{BrokerPair brokers_;YieldPair yields_;};Collector:
DatagetData(constTick&,constSidePair&,constSidePair&)const;DatagetData(constTick&,constSidePair&,constSidePair&,constBrokerPair&,constYieldPair&)const;Collector 完整接收 SideInfo / ExtraFields
改成,更强类型接口:
DatagetData(constTick&,constSideInfo&)const;DatagetData(constTick&,constSideInfo&,constExtraFields&)const;这是结构化的最终版本。
优点:
- 结构化强类型(Strongly Typed)
- 不再散乱传 2~4 个 SidePair
- 函数参数最小化(符合最小知识原则)
- 可维护性极大提升
Collector 强类型接口完成
最终 Collector:
DatagetData(constTick&tick,constSideInfo&)const;DatagetData(constTick&,constSideInfo&,constExtraFields&)const;文档总结:
Collector Interface: • Easy to use • Strongly typedBuilder / DBuilder 在强类型接口下变得更清晰
Builder:
SideInfo sidesInfo_;Data info=collector_.getData(tick,sidesInfo_);DBuilder:
ExtraFields extraFields_;Data info=collector_.getData(tick,sidesInfo_,extraFields_);改进效果
- Builder 与 DBuilder 的 build() 函数非常干净
- 参数结构清晰:sideInfo + extraFields
- 更容易测试、mock
- 更符合 DI
最终意义总结(重点)
Bloomberg 做的这套优化是典型的参数整合 + 强类型化 + 结构化 DI。
你应该学到:
1. 不要让函数暴露太多零散参数
- 多个 optional → 打包成 struct
- Pair → 表达业务含义
- 结构体越接近业务约束越好
2. 强类型接口比弱类型 optional 更安全
从:
(optional<Side>, optional<Side>, optional<Side>, optional<Side>)变成:
SideInfo { sides, localSides }语义更清晰,也不容易传错。
3. 对 DI(依赖注入)非常友好
DI 的原则:
单一依赖单元应该尽可能结构化,而不是大量散乱字段。
SidePair、SideInfo、ExtraFields 都是完美的 DI 注入单元。
4. 大量降低重复代码、错误率、维护成本
Builder / DBuilder 最终变成干净的:
collector_.getData(tick,sidesInfo_);collector_.getData(tick,sidesInfo_,extraFields_);相比初始的 8 个 optional,简直天差地别。
Applied Dependency Injection —— 详细理解
总结了在大型系统中真正落地依赖注入(Dependency Injection, DI)时应该遵循的一套“高速公路式”的最佳实践。其核心思想是:
让对象之间的依赖关系透明、可控、可替换、可测试,避免隐藏副作用和隐含依赖,使代码更易维护、更易测试。
下面逐条解释:
1. 对象创建在函数外部完成(Object creation done outside the logic of functions)
意义:
不要在函数内部new出依赖,也不要在内部自己构建需要的数据或对象。应该让外部(构造函数、工厂、容器等)注入。
两种方式:
① 直接传入依赖(Pass in Dependencies directly)
例如:
classFoo{public:Foo(Database&db):db_(db){}};这属于构造注入。
② 传入依赖的“提供者”(Pass in Dependency suppliers)
例如传入一个 Callable:
Foo([&](){returndbPool.get();});这属于工厂注入。
好处:
对象不再负责创建依赖,从而满足单一职责原则,并能在测试时方便地 mock。
2. 在立即可用的对象上调用方法(Invoke methods on immediate objects)
含义:
避免“火车链式调用”(train wreck),避免从一个对象拿到另一个对象再继续调用它的方法。
例如:
config.getEnv().getPath().getParent().getValue();这很糟糕,因为:
- 很难 mock
- 隐含多层依赖
- 不清楚谁依赖谁
- 类变得脆弱
应该: - 让所需的对象直接作为依赖注入
- 或让上级负责拆解对象并注入你真正需要的部分
3. 将信息获取/计算,与状态改变分离(Disentangle retrieval/calculation from state changing)
思想源自纯函数(pure function)和函数式设计。
拆分成两类函数:
① const / pure function
- 输入 → 输出
- 不修改对象状态
- 容易推理
- 容易测试(表征测试 / characterization test)
即:
f(x)=y f(x) = yf(x)=y
② 状态修改函数
- 明确表达要更改对象内部状态
- 逻辑简单
- 更可控
好处:
降低副作用,让依赖注入更加简单,因为纯函数不需要外部状态。
4. 重构 God classes(上帝类)
God class = 功能太多、依赖太多、责任不清、方法上百行的类。
解决方案:
① 功能聚类并下沉到分层抽象(clustered and pushed into tiered abstraction layers)
即把这个类拆成多个组件,每个组件负责一部分逻辑:
- Parser
- Validator
- Calculator
- DataRetriever
- Serializer
等等。
② 减少不必要的依赖(lessen unnecessary dependencies)
例如:
原来类 A 依赖类 B 只为了使用 B 的某一个方法,
可以把那个方法抽取出来注入,而不是注入整个 B。
5. 重构碎片化的功能(Refactor fragmented functionality)
有些逻辑散落在多个小类、多文件中,让依赖复杂化。
目标:
① 将零碎逻辑聚合(Cluster splintered functionality together)
例如:一个功能相关的方法分散在 Utility1、Utility2、Utility3,测试困难。
把它们整理成一个 coherent 的组件,例如:
PriceNormalizer DateConverter RiskCalculator② 减少依赖数量(lessen dependencies)
聚合后的类通常可以减少依赖注入的数量。
6. 重构数据/状态(Refactor data/state)
如果一个接口需要传入许多参数,例如:
getData(tick,bid,ask,localBid,localAsk,brokerBid,brokerAsk,...)这是 DI 的反模式:
- 依赖过多
- 参数过碎
- 接口不清晰
- 易混淆(位置参数错误风险极大)
解决方式:
① 将相关字段收集成一致的数据结构
如:
structSideInfo{SidePair sides;SidePair localSides;};structExtraFields{BrokerPair brokers;YieldPair yields;};接口变成:
getData(tick,sideInfo,extraFields);更易使用、强类型、安全、可扩展。
总结(口语化理解)
这页内容告诉你:
依赖注入不是加几个模板、加几个构造函数那么简单。
是一种系统化的代码健康改革。
六大方向:
- 别在里面 new —— 依赖从外面来
- 别链式调用 —— 直接注入需要的东西
- 纯逻辑与状态修改分开 —— 可测试性大幅提升
- 拆 God class —— 抽象分层,让依赖清晰
- 把碎功能聚合 —— 避免依赖乱飞
- 把零散参数收拢成结构 —— 强类型、可扩展
这就是 DI “高速公路”全套最佳实践。
Legacy Code DI —— 遗留代码中的依赖注入
什么是遗留代码(Legacy Code)?
Legacy Code = 已经在生产环境运行、为真实用户服务、很难随便改动的代码。
这类代码具有典型特点:
- 不能轻易改,因为稳定运行非常重要
- API 被大量使用,牵一发而动全身
- 外部依赖多,改动风险高
- 重构可能需要几周甚至几个月
- 因此:不能一次性大改,需要分阶段、小范围地逐步调整
这意味着:
在遗留系统里应用依赖注入(DI)非常困难。
所以我们需要技巧和工具来做到“悄无声息地引入 DI”。
DI for immutable APIs —— 针对不可更改 API 的依赖注入
问题:API 已经广泛使用,接口不可随意修改
例如你的函数:
boolprocess(intkey,conststd::string&index);也许已经被数千个地方调用。
如果你强行加入一个注入项,比如:
boolprocess(intkey,conststd::string&index,CalcDep&calc);会造成:
- 所有调用点全部失效
- 重编、重测风险巨大
- 公司同事会把你打死(真的)
因此:
API 不能直接改,但你仍然需要 DI —— 怎么办?
解决方案:透明(Transparent)依赖注入
关键是:
在不破坏旧 API 的前提下,让新的依赖可以被注入。
工具包含:
- 默认参数(default arguments)
- 转发函数(forwarding functions)
- 委托构造函数(delegating constructors)
示例:函数级透明 DI
1. 引入默认参数 + 注入参数(新的 API)
boolprocess(intkey,conststd::string&index,CalcDep&calc=defaultCalc)// Injection point{// ...calc.estimate(...);// ...}- 新 API 自带 DI(可注入可替换)
- 旧调用者不需要改动
为什么成功?
因为旧代码仍然可以这样调用:
process(k,idx);// 使用 defaultCalc新的测试可以这样用:
FakeCalc fake;process(k,idx,fake);// 注入 mock 实现2. 保留旧 API,但让它转发到新 API(Forwarding function)
boolprocess(intkey,conststd::string&index){// deprecatedreturnprocess(key,index,defaultCalc);}这保留了旧接口,但让新的接口成为真正的逻辑入口。
示例:构造函数级(Constructor)透明 DI
旧 API:
classDataProcessor{public:DataProcessor(intkey,conststd::string&index);};你需要注入:
classDataProcessor{public:DataProcessor(intkey,conststd::string&index,CalcDep&calc=defaultCalc);};正确方式:委托构造函数(Delegating constructor)
classDataProcessor{public:DataProcessor(intkey,conststd::string&index,CalcDep&calc):calc_(calc),key_(key),index_(index){}// 旧 API 改为委托构造函数 —— deprecatedDataProcessor(intkey,conststd::string&index):DataProcessor(key,index,defaultCalc){}};这样就实现了:
- 外部 API 不变
- 可以注入新的依赖
- 不会破坏现有调用方
总结(通俗版)
遗留系统中的 API不能轻易改,但你仍然需要 DI。
于是我们用到:
透明 DI 技巧:
- 默认参数
- $ \text{CalcDep& calc = defaultCalc} $
- 转发函数
- 保留旧 API → 内部转发到新 API
- 委托构造函数
- 旧构造函数调用新构造函数
这些技巧让你:
- 旧构造函数调用新构造函数
- 不破坏现有系统
- 逐步引入可测试、可替换的依赖
- 不需要一次性重写整个模块
- 风险低、可渐进式重构
DI for lazy object construction —— 惰性构造中的依赖注入
问题:Lazy Initialization(惰性初始化)让 DI 很难做
Lazy initialization 的核心是:
当对象真正被使用时才创建内部依赖。
但 DI 的原则是:
依赖不应该在对象内部创建,应该从外部注入。
于是两者产生冲突:
- 惰性初始化需要 “延后创建对象”
- DI 需要 “外部提供对象,不允许自己创建”
特别是:
惰性初始化时,你无法提前构造出依赖对象,因此无法通过构造函数给依赖注入。
这就是 DI for lazy construction 的核心矛盾。
示例分析:原始写法
classLazyObj{public:LazyObj(...,conststd::string&index_name):index_name_(index_name){}voidapply(constActionX&action){ensureLoaded();// Lazy Injection...}private:voidensureLoaded(){if(!db_helper_)// Lazy injectiondb_helper_=createDbHelper(index_name_);}conststd::string index_name_;std::unique_ptr<DBHelper>db_helper_;// Dependency};问题:
createDbHelper在类内部被调用LazyObj无法接受外部注入的依赖- 测试无法替换 DBHelper(不能 mock)
- 完全违反 DI 在 “依赖由外部提供” 的原则
错误尝试 1:构造函数直接创建依赖(NOT LAZY)
LazyObj(...,index_name):index_name_(index_name),db_helper_(createDbHelper(index_name_)){}为什么“不应该这样做”?
因为:
你破坏了 Lazy Initialization —— 对象提前构造而不是按需构造。
LazyObj 可能设计是因为 DBHelper 很重(DB、网络、文件 IO),但你提前创建使性能下降。
结论:此方式失去 lazy 的意义,不可取。
错误尝试 2:Setter 注入
boolsetDBHelper(std::unique_ptr<DBHelper>dbh){if(!db_helper_)db_helper_=dbh;}为什么这样也不应该?
原因:
- API 暴露 Setter 改变对象内部状态
- 调用者必须知道何时调用 Setter
- 对象生命周期不安全
- Setter 可以被调用多次(除非你强加逻辑)
- 很难保证与 lazy 初始化一致
- 存在竞态风险
Setter injection 在复杂对象中是一种糟糕的模式,在大型代码库中更是灾难。
正确做法:Provider Injection(依赖提供者注入)
核心思想:
不是注入对象,而是注入一个“对象生成器函数”。
也就是说:
- 类内部仍然决定何时创建依赖(保持 lazy)
- 但“如何创建”从外部注入
这个“提供依赖的函数”叫做Provider。
你提供一个函数:
ProvideDBHelper:string→unique_ptr<DBHelper> \text{ProvideDBHelper} : \text{string} \rightarrow \text{unique\_ptr<DBHelper>}ProvideDBHelper:string→unique_ptr<DBHelper>
即:
“给我 index_name,我返回一个 DBHelper。”
Provider 类型定义
usingProvideDBHelper=std::function<std::unique_ptr<DBHelper>(conststd::string&)>;注入 Provider 的版本(正确实现)
classLazyObj{public:LazyObj(...,conststd::string&index_name,ProvideDBHelper provide_dbhelper=createDbHelper)// Provider Injection:index_name_(index_name),provide_dbhelper_(provide_dbhelper){}voidapply(constActionX&action){ensureLoaded();...}private:voidensureLoaded(){if(!db_helper_){db_helper_=provide_dbhelper_(index_name_);// Injection!}}conststd::string index_name_;ProvideDBHelper provide_dbhelper_;// injected providerstd::unique_ptr<DBHelper>db_helper_;};为什么这是真正正确的 DI 方式?
1. 保持 Lazy 行为(重要)
对象内部依旧可以按需构造:
if(!db_helper_)db_helper_=provide_dbhelper_(index_name_);2. 遵循 DI 原则
依赖的创建逻辑从外部传入,而非写死在类内部。
3. 测试替换极其容易
在测试中:
autofakeProvider=[](conststd::string&){returnstd::make_unique<FakeDBHelper>();};LazyObjobj(...,"index",fakeProvider);不需要改 LazyObj 的代码。
4. 兼容遗留代码(非常关键)
旧代码仍然可以:
LazyObjobj(...,"index");因为有默认参数:
ProvideDBHelper provide_dbhelper=createDbHelper5. 渐进式(phased)引入 DI
这是最重要的一点:
不破坏现有代码,又能一步步引入 DI。
特别适合大型公司(如 Bloomberg)的大型遗留系统。
总结(最简版)
问题
Lazy initialization 无法在构造时注入依赖对象。
错误方式
- 在构造函数直接创建依赖(不再 lazy)
- Setter 注入(暴露内部状态,不安全)
正确方式
使用依赖提供者(Provider Injection)\text{使用依赖提供者(Provider Injection)}使用依赖提供者(Provider Injection)
即:注入一个用于构造依赖的函数:
ProvideDBHelper=std::function<std::unique_ptr<DBHelper>(string)>LazyObj 内部按需调用 provider,从而:
- 保留 lazy 行为
- 外部可注入 mock 或自定义依赖
- 不改变原有 API
- 适合逐步、低风险地改造遗留代码
问题核心(一句话)
C++ 中模板成员函数不能被声明为
virtual。
因此你无法像普通虚函数那样去 Mock/覆盖一个模板成员函数(例如template<typename T> TypeNum isType(const T&) const;)。
换句话说:
template member function∉virtual functions\text{template member function} \not\in {\text{virtual functions}}template member function∈virtual functions
为什么这是个 DI / Mocking 的麻烦点?
示例类:
classHeader{public:template<typenameT>TypeNumisType(constT&)const;virtualTypeNumisType(conststd::string&)const;// ...};isType<T>是模板实现(按需实例化),编译器在编译期生成实例化代码;virtual机制是运行时的 vtable/动态派发,两者语义不同,所以语言不允许把模板成员直接标记为virtual。
因此当你想在MockHeader里MOCK_METHOD一个模板版本时会失败 —— gmock 只能 mock 非模板的虚方法。
你做到的事情(但没真正解决问题)
你写了一个可以注入的std::function:
usingis_type_fn=std::function<TypeNum(constHeader&,conststd::string&)>;autoreal_typenum=[](constHeader&hdr,conststd::string&val){returnhdr.isType(val);};Processor(is_type_fn istype=real_typenum):istype_(istype){...}但关键点是:你必须把所有对hdr.isType(val)的调用改为istype_(hdr, val)才能真正“拦截”行为。
否则注入的函数根本不会被调用 —— 你只是往Processor放了个可替换的函数对象,但Processor::apply()仍然直接调用hdr.isType(val)。
简而言之:
- 注入点存在 ≠ 注入点被使用。
- 必须修改调用点或类的接口,才能让 DI 生效。
可行方案一览(5 种)
下面每种方法都有示例、优缺点与适用场景。按从低侵入到高侵入排序与推荐优先级。
方案 A — 为常见具体类型增加非模板虚方法(推荐,低侵入)
要点:在Header中为需要 mock 的具体类型(如std::string)增加一个virtual非模板重载,模板实现委托给这些虚函数。
structHeader{virtual~Header()=default;// 把 string 这种常用类型做成虚函数virtualTypeNumisTypeString(conststd::string&s)const{// 默认实现return/*...*/;}template<typenameT>TypeNumisType(constT&t)const{ifconstexpr(std::is_same_v<T,std::string>){returnisTypeString(t);}else{returngenericIsType(t);}}private:template<typenameT>TypeNumgenericIsType(constT&t)const{/* ... */}};测试/mock:
structMockHeader:Header{MOCK_METHOD(TypeNum,isTypeString,(conststd::string&),(const,override));};优点:
- 能用 gmock 直接 mock
- 改动小(只需在
Header增加少量虚方法)
缺点: - 需要为每个要 mock 的具体类型写虚函数(类型集若很大则不适用)
适用场景:你只需 mock 少数几种类型(如string、int),且能修改Header。
方案 B — 注入函数对象 / 策略(Function Injection,低到中侵入)
要点:把isType的调用改为使用注入的std::function,并在Processor中使用它。
usingis_type_fn=std::function<TypeNum(constHeader&,conststd::string&)>;classProcessor{is_type_fn istype_;public:Processor(is_type_fn f=[](constHeader&h,conststd::string&s){returnh.isType(s);}):istype_(std::move(f)){}voidapply(...){TypeNum tn=istype_(hdr,val);// <-- 注意:必须用 istype_,而不是 hdr.isType()...}};测试时传入:
Processorp([](constHeader&,conststd::string&){returnTypeNum::A;});优点:
- 不必改
Header,改动只在调用者(Processor)侧 - 渐进式替换,适合遗留代码迁移
缺点: - 必须把所有原有
hdr.isType(...)调用点替换成istype_(hdr, ...)(可能很多) - 调用点变得稍多一层间接(
std::function的开销)
适用场景:无法改Header,但能修改使用Header的模块;适合逐步改造。
方案 C — 适配器 / 接口包装(Adapter / Interface, 中侵入)
要点:新增IHeader接口或HeaderAdapter,让客户端依赖接口而非具体Header,并为测试提供 mock。
structIHeader{virtual~IHeader()=default;virtualTypeNumisType(conststd::string&s)const=0;};classHeaderAdapter:publicIHeader{constHeader&real_;public:HeaderAdapter(constHeader&r):real_(r){}TypeNumisType(conststd::string&s)constoverride{returnreal_.isType(s);}};客户端(如Processor)改为接受IHeader&。
优点:
- 清晰的接口抽象,符合 DI 原则
- Mock 很直接:
MockIHeader可被注入
缺点: - 需要改调用方(让其依赖
IHeader) - 若
Header被广泛直接使用,修改面可能较大
适用场景:想把系统改成接口驱动风格,计划较大规模重构
方案 D — 类型擦除(Type Erasure:isTypeAny)(中到高侵入,强力)
要点:在Header中实现一个单一的运行时虚方法isTypeAny(type_info, void*),模板isType<T>转发到它。Mock 时只 mock 这个运行时方法。
structHeader{virtual~Header()=default;virtualTypeNumisTypeAny(conststd::type_info&ti,constvoid*data)const{// 默认实现根据 ti 做分支}template<typenameT>TypeNumisType(constT&t)const{returnisTypeAny(typeid(T),static_cast<constvoid*>(&t));}};Mock:
structMockHeader:Header{MOCK_METHOD(TypeNum,isTypeAny,(conststd::type_info&,constvoid*),(const,override));};优点:
- 一次性覆盖任意类型(只需一个虚方法)
- Mock 非常简单(mock
isTypeAny)
缺点: - 实现复杂,需要进行类型检查/转换,易错且性能差
- 可读性差,需小心管理
void*和type_info - 增加运行时开销
适用场景:类型集合很大或不可预知,且你能接受实现复杂度与性能成本
方案 E — 静态多态 / CRTP(模板化 Processor,编译期替换,高侵入)
要点:使用模板让Processor在编译时接受一个 “Header-like” 类型或策略,从而在测试时用 mock 类型替换。
template<typenameHeaderT>classProcessorT{public:voidapply(constHeaderT&hdr,conststd::string&val){autotn=hdr.isType(val);// compile-time dispatch}};优点:
- 零运行时开销(无虚调用)
- 完全类型安全
缺点: - 模板会传播到调用点,导致代码膨胀
- 无法在运行时替换实现
- 对大型遗留系统改造侵入性大
适用场景:新代码或能接受模板化架构的模块;对性能非常敏感的场景
何时用哪种方案(简明决策树)
- 你能修改
Header且类型数量少→ 方案 A(非模板虚 overload)。 - 你不能修改 Header,但能改调用方→ 方案 B(函数注入)。
- 你想把代码改成接口驱动、并可一次性替换多处→ 方案 C(Adapter/IHeader)。
- 类型非常多且动态,愿意承担复杂性 → 方案 D(类型擦除)。
- 是新模块,需高性能且可编译期替换 → 方案 E(CRTP/模板化)。
小结(关键提醒)
- 模板成员不能是虚函数,因此你不能直接
MOCK_METHOD一个模板方法。 - 注入必须被使用—— 把
std::function放到类里无效,除非你把调用点改为调用该函数对象。 - 在遗留系统中,优先选择最小侵入且可测试的方案(通常是 A 或 B 或 C 的某种折衷),再视情况推进更强拆解。
- 每种方案都有权衡:易改 vs 易测试 vs 性能开销 vs 侵入面,根据实际代码库规模与约束选型。
问题回顾(要点)
C++ 不允许把成员模板声明为virtual。因此像下面的设计:
classHeader{public:template<typenameT>TypeNumisType(constT&)const;// 不能 virtual};无法通过继承/虚方法去覆盖/MockisType<T>。这是 DI(基于继承/虚函数)的一个根本性限制。
你必须用另一种方法让行为在测试时可替换。
数学上可以把问题表述为:你想让一个映射
T↦行为T T \mapsto \text{行为}_TT↦行为T
在运行时可替换,但模板实现在编译期确定,所以两者语义冲突。
解决思路总览(三类主流方案)
- 把需要覆盖的具体类型变成普通的非模板虚方法(把部分模板“具体化”成虚方法)。
- 在调用点做类型擦除 / 注入函数对象(type-erasure 或 function-injection),让调用者依赖注入点而不是类模板。
- 把继承 DI 换成模板 DI(静态多态 / CRTP / 模板化 Processor),在编译期决定类型,实现零虚函数替换。
下面逐项展开,给出代码 + 优缺点 + 何时用。
方案 A — 为常用具体类型提供非模板虚方法(Concrete virtual overloads)
思想:如果你只关心少数具体类型(例如std::string),把这些类型做成virtual非模板方法;模板isType<T>委托给这些虚函数。
代码示例
enumclassTypeNum{A,B,C};classHeader{public:virtual~Header()=default;// 为 string 提供虚方法(可被 mock)virtualTypeNumisTypeString(conststd::string&s)const{// 默认实现(或调用通用实现)returnTypeNum::A;}// 泛化模板委托给具体 overloadtemplate<typenameT>TypeNumisType(constT&t)const{ifconstexpr(std::is_same_v<T,std::string>){returnisTypeString(t);}else{returngenericIsType(t);}}private:template<typenameT>TypeNumgenericIsType(constT&/*t*/)const{// fallback implementationreturnTypeNum::B;}};// gmock 可以 mock isTypeStringstructMockHeader:Header{MOCK_METHOD(TypeNum,isTypeString,(conststd::string&),(const,override));};优点
- 对测试友好:可直接 mock 具体虚函数。
- 侵入小:只需为少数类型加虚方法。
- 保留模板 API(调用者不必修改模板调用)。
缺点
- 若需要覆盖的类型集合很大,不可扩展(必须为每个类型写虚方法)。
- 额外维护工作:为每种需要 mock 的类型增加代码路径。
适用场景
- 你只需 mock/替换少数常见类型(例如
string、int),并且可以改Header。
方案 B — 在调用点做类型擦除 / 注入函数对象(Function / Provider Injection)
思想:不要试图在Header上做虚化;而是把isType的行为作为依赖注入到使用Header的地方(例如Processor)。调用点使用注入的std::function/ 策略函数,而不是直接调用hdr.isType(...)。
代码示例
usingis_type_fn=std::function<TypeNum(constHeader&,conststd::string&)>;// 默认实现把调用委托回 Header(兼容旧实现)staticis_type_fn real_typenum=[](constHeader&h,conststd::string&s){returnh.isType(s);};classProcessor{public:Processor(is_type_fn fn=real_typenum):istype_(std::move(fn)){}voidapply(constHeader&hdr,conststd::string&val){// **关键**:调用注入的函数,而不是 hdr.isType(val)TypeNum tn=istype_(hdr,val);// ...}private:is_type_fn istype_;};测试时注入:
Processorp([](constHeader&,conststd::string&){returnTypeNum::C;});优点
- 不改
Header的定义(适合无法修改的遗留类)。 - 测试时可灵活注入任意行为(lambda / mock 函数)。
- 逐步迁移:可在少数调用点先替换,再推广。
缺点
- 必须把所有调用点改为使用注入函数,否则注入无效(你原来碰到的问题)。
std::function有运行时开销(可通过模板函数或 inline 函数优化)。- 侵入调用点(需要代码改动)。
适用场景
- 不能改被测类(
Header),但能改使用它的模块;适合渐进式重构。
方案 C — 适配器 / 接口(Adapter / Interface)将具体类包一层
思想:引入抽象接口IHeader,让代码依赖该接口;用适配器把现有Header包装成IHeader。Mock 时直接替换IHeader。
代码示例
structIHeader{virtual~IHeader()=default;virtualTypeNumisType(conststd::string&s)const=0;};classHeaderAdapter:publicIHeader{public:explicitHeaderAdapter(constHeader&real):real_(real){}TypeNumisType(conststd::string&s)constoverride{returnreal_.isType(s);}private:constHeader&real_;};// Processor 使用 IHeader&classProcessor{public:Processor(IHeader&hdr):hdr_(hdr){}voidapply(conststd::string&val){TypeNum tn=hdr_.isType(val);// ...}private:IHeader&hdr_;};测试时传入MockIHeader。
优点
- 统一接口,清晰的依赖注入点。
- mock 非常直接。
- 允许在多个位置统一替换实现。
缺点
- 需要改使用点(将
Header改为IHeader&)或在很多地方注入HeaderAdapter。 - 若
Header被广泛使用,改动面大。
适用场景
- 想把系统改为接口驱动或准备做较大重构时。
方案 D — 类型擦除到单一虚函数(isTypeAny)
思想:在Header内实现一个接受任意类型的运行时分发虚函数(例如isTypeAny(type_info, void*));模板isType<T>转发到这个统一的虚函数。测试时 mock 这一个虚函数。
代码示例(要小心管理)
classHeader{public:virtual~Header()=default;virtualTypeNumisTypeAny(conststd::type_info&ti,constvoid*data)const{// 在基类实现中根据 ti 做静态_cast 或其它处理if(ti==typeid(std::string)){autop=static_cast<conststd::string*>(data);returnisTypeString(*p);}returnTypeNum::B;}template<typenameT>TypeNumisType(constT&t)const{returnisTypeAny(typeid(T),static_cast<constvoid*>(&t));}protected:virtualTypeNumisTypeString(conststd::string&s)const{// defaultreturnTypeNum::A;}};// mock isTypeAnystructMockHeader:Header{MOCK_METHOD(TypeNum,isTypeAny,(conststd::type_info&,constvoid*),(const,override));};优点
- 单一虚函数覆盖任意类型(方便 Mock)。
- 不需要为每个类型写虚方法。
缺点
- 复杂且容易出错(类型转换 / 指针安全需谨慎)
- 性能与可读性都有损失。
- 不推荐除非确实必须支持大量动态类型。
方案 E — 模板 DI(将继承 DI 改为模板静态多态)
思想:把Processor改成模板类/函数,接受一个Header-like 类型参数。测试时用 Mock 类型替代真实类型(编译期替换)。
代码示例
template<typenameHeaderT>classProcessorT{public:voidapply(constHeaderT&hdr,conststd::string&val){TypeNum tn=hdr.isType(val);// compile-time dispatch// ...}};测试时传入MockHeaderType。
优点
- 零运行时虚调用开销(高性能)。
- 完全类型安全。
缺点
- 模板会传播到调用方(API 通用性下降)。
- 动态替换困难(不能在运行时切换实现)。
- 对大规模遗留系统侵入性强。
小结 / 决策建议(实务)
- 如果要快速可测、改动小:先用方案 A(为常用类型提供虚方法)。
- 如果不能改 Header,但能改调用方:用方案 B(函数注入),并务必把所有调用点改成使用注入点。
- 如果要系统性改造并统一接口:考虑方案 C(Adapter / IHeader)。
- 如果类型很多且动态:才考虑方案 D(类型擦除),但要慎重。
- 如果是新代码或性能关键:使用方案 E(模板 DI / 静态多态)。
最后提醒(务必注意)
你在最初尝试做的std::function注入并没有“解决问题”的唯一原因通常是调用点没有改用注入的函数。注入只是把可替换性放在了某个槽位,必须把调用改为使用那个槽位才有效。
Dependency Injection Myths — 神话解析(详细理解)
Myth 1:DI 很简单
很多初学者看到几个简单例子,以为 DI 就是 “加个构造器参数”、“加个 interface”,但真正的生产系统(规模大、深度复杂、有历史包袱)完全不是这样。
现实里复杂的是:
- 依赖链很深(例如几十层调用)
- 依赖创建逻辑隐藏在内部(private/new/singleton)
- API 接口改不了(大量 legacy / 生产依赖)
- 有懒加载(lazy init)、模板函数、虚函数、不可 mock 的类型
所以 DI 本质不简单。
Myth 2:DI 只适合简单系统
实际上,DI 在大型系统中价值最大。
大型系统最痛的就是耦合深、难测试、难演进,而 DI 正是解决这些问题的手段。
Myth 3:DI 对小项目是过度设计
错误。
即便是小项目:
- 若未来要扩展
- 若未来需要 mock
- 若要单测
- 若要避免巨型函数、强耦合
DI 的收益仍然非常大。
Myth 4:DI 是为测试服务的
不完整。
测试是 DI 的结果,但不是目的。
DI 的核心是:
减少耦合 + 提升模块独立性 → 提升可维护性与扩展性
测试只是 DI 之后自然获得的额外好处。
Myth 5:DI 可以以后再加
这是最危险的误区。
原因:
- 一旦耦合形成“技术债”,代价O(系统规模)O(\text{系统规模})O(系统规模)
- 随着时间推移,修改风险越来越大
- legacy code 会越来越难拆分
- 越早重构→越便宜;越晚重构→越痛苦
这是 Bloomberg 演讲反复强调的观点。
Dependency Injection Truths — 真相解析
Truth 1:DI 在真实生产系统里是难的
因为真实系统中充满:
- 单例(Singleton)
- 全局变量(global states)
- 隐藏在内部的对象创建
- 模板函数 + 虚函数混用
- 懒加载
- API 不允许改动(breaking change)
- 多层架构没有合理分层
这些都让 DI 变得困难。
Truth 2:“正确的代码结构”是 DI 的关键
Dependency Injection 本质上不是一个技巧,而是一种代码结构哲学。
核心思想:
让对象不负责创建依赖,只使用依赖
但要做到这一点,你需要:
- 把业务逻辑拆成可组合的小模块(函数、类、provider)
- 把创建逻辑移出去(factory/provider/构造注入)
- 引入合理的抽象边界
越干净、越解耦的代码 → DI 越容易。
Truth 3:在引入 DI 之前需要本地 refactoring
Bloomberg 特别强调:
- DI 不是随便“加一层 interface”
- 必须先局部重构(local refactoring)
- 把 responsibilities 拆清楚
否则 DI 会让代码更糟。
Truth 4:不良代码需要大量 DI trick
例如:
- 模板函数不能 virtual → 需要 type erasure
- 懒加载不能注入已有对象 → 需要 provider injection
- API 无法修改 → 需要 default argument + delegating constructor
- 深层依赖隐藏 → 需要工厂替换
- 单例 → 需要 registry / injector
越多 legacy → 越多 trick。
Truth 5:DI 改善系统可维护性与灵活性
核心效果:
- 更容易替换依赖
- 更容易 mock
- 更容易测试
- 更容易扩展
- 支持多实现并存(A/B 实现)
- 更容易做模拟环境、sandbox、回测系统等
长期维护成本显著下降。
Dependency Injection Revelation — 终极启示(非常重要)
DI 的最终本质不是“注入”本身,而是——
减少依赖数量,拉开抽象层次
分两类:
1⃣ Horizontal Abstraction — 水平抽象
意思是:
把一个大模块拆成独立的功能部件(function units)
例如:
- 把一个 2000 行类拆成 10–20 个功能 class
- 每个 class 单一职责(Single Responsibility)
- 减少耦合
- 每个部分可以被单独测试
这会让 DI 自然变得可能。
2⃣ Vertical Abstraction — 垂直抽象
意思是:
把代码分成层次,例如:
UI → 服务层 → 业务层 → 数据层 → 系统层
类似:
- Controller
- Application Service
- Domain Service
- Repository
- Infrastructure
每一层只依赖底层接口,不依赖具体实现。
也就是说:
让依赖从“实物对象”变成“接口/抽象”
DI 自然就变成:
Higher layers depend on abstractions, not implementations. \text{Higher layers depend on abstractions, not implementations.}Higher layers depend on abstractions, not implementations.
即:
高层依赖抽象,而不是具体实现
(典型的 Dependency Inversion Principle)
总结一句话(非常本质)
**Dependency Injection 的难点不是“注入”,
而是“让代码变得可注入”。**
做到这一点需要:
- 较少的依赖
- 较小的模块
- 清晰的边界
- 分层架构
- 解耦与抽象
- 以及大量的结构化 refactoring