news 2026/4/23 21:03:32

CppCon 2024 学习: Dependency Injection in C++ A Practical Guide

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CppCon 2024 学习: Dependency Injection in C++ A Practical Guide

1⃣ 减少功能模块之间的耦合度(Decreases coupling between functionality blocks)

理解:
在传统设计中,一个类或函数通常会直接创建或依赖另一个类,这会造成强耦合(tight coupling)。例如:

classService{Repository repo;// 直接依赖具体实现public:voiddoWork(){repo.save();}};

这种设计中,ServiceRepository紧密绑定在一起,如果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由外部注入
此时FFFDDD的耦合度降低,因为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,,CnImplementations 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}DoldDnew
    原设计中:
    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 EESMiE
    通常EEE很大,即使MiM_iMi只是其中一个模块。

2⃣ 测试性质

  • 这种方法不是单元测试(Unit Testing),而是集成测试(Integration Testing)
  • 测试过程会同时触发系统中多个模块的行为。
  • 测试目标可能很模糊,因为错误可能源于任何依赖模块,而不仅仅是被测模块。
    数学化表达:
    假设被测模块为MiM_iMi,其他模块为MjM_jMjj≠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}ErrorSourceM1,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}TfeedbackTunit test
④ 可能完全依赖回归 A/B 测试(Can lean entirely on regression A/B testing)

理解:

  • 由于难以单独测试某模块,测试往往依赖对比系统整体表现的回归测试。
  • 这类似于“黑盒测试”,只关注系统输出变化,而非内部逻辑。
    数学上可以理解为:
    ΔO=Onew−Oold \Delta O = O_\text{new} - O_\text{old}ΔO=OnewOold
    只检测输出变化,而不分析模块内部的错误。

总结

Pocket Universe Testing的特点是:

  1. 必须搭建几乎完整的环境才能测试一个小模块。
  2. 测试覆盖系统多个模块,属于集成测试而非单元测试。
  3. 缺点明显:
    • 难以定位错误
    • 设置复杂
    • 反馈慢
    • 依赖整体回归测试
      数学化理解总结:
  • 系统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:AB
    于是业务函数变成组合(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=f1f2f3
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=CnCn1C1

二、关键实体角色(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:interfaceidiimpl
运行时,组件接收到的是实现后的函数(算子),于是系统变为纯函数组合的实际执行图。

四、Component Injection 层(业务组件注入与生命周期)

在 Component Injection 阶段,DI 把具体实现注入到业务组件中(例如 Marshalling、CancelTrade、RepriceTrade、ExecuteTrade):

  • Marshaller:负责把 Trade 转为消息格式(binary/json/protobuf),可依赖SchemaServiceConfig
    Marshall(T;schema)↦messageMarshall(T; schema) \mapsto messageMarshall(T;schema)message
  • CancelTrade:需要BrokerAuthRepo(查找原始 Trade)等依赖。
  • RepriceTrade:需要PricingMarketDataRiskService
  • ExecuteTrade:如上所述组合 Pricing/Sizing/Sending。
    注入方式:常见三类
  1. 构造注入(Constructor):组件在构造时获得所有依赖(推荐用于不可变依赖)。
    C=new Component(d1impl,d2impl)C = \text{new } Component(d_1^{impl}, d_2^{impl})C=newComponent(d1impl,d2impl)
  2. 设值注入(Setter):运行后可替换依赖(适合可热切换的策略)。
  3. 接口注入 / 运行时解析(Service Locator):组件在运行时向容器索取依赖(一般不推荐,隐藏依赖)。

五、数据流示意(从 Data In 到 Execute)

把你的箭头展开为数据流步骤,既有数学表示也有文字说明:

  1. 输入TTT(Trade),HHH(Data Holders),SSS(Security),BBB(Broker)
  2. 组件注入DIDIDI注入dsize,dprice,dsendd_{size}, d_{price}, d_{send}dsize,dprice,dsendExecuteTradeExecuteTradeExecuteTrade
    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)
  3. 处理流水线(函数组合):
    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, 数据库写入等处)。
  4. 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_sendRepo),便于 mock。

3) 并发与线程安全

  • 单例依赖需保证线程安全(例如定价缓存的读写锁或无锁结构)。
  • 若依赖维护会话状态,应使用 Scoped 生命周期或显式 session 对象。

4) 事务与补偿(Transactional / Idempotency)

  • ExecuteTrade、CancelTrade、RepriceTrade 可能跨外部系统:要设计幂等与补偿逻辑(saga pattern)。
  • 标记已提交/回滚的状态保存在 Repo(Data Holder),并由依赖实现保证一致性。

5) 安全(Security)

  • AuthACL作为注入依赖,业务代码只调用接口验证:
    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 能让你在替换实现时把行为差异降到最小,从而更容易做表征测试。
  • 单元测试:Mockd_price,d_send,Broker,测试 ExecuteTrade 的纯逻辑。
  • 集成测试:用真实或近似实现(例如 test Broker)验证跨组件流程(Marshalling → Send → Repo 写入)。

十、设计建议(工程实践小结)

  1. 明确接口(interface-first):优先设计行为契约,再实现注入。
  2. 把副作用封装进依赖,业务逻辑保持纯净。
  3. 优先构造注入,辅助用 setter 进行可选/热替换。
  4. 使用作用域管理(singleton/scoped/transient)明确资源边界。
  5. 实现异步/非阻塞接口应对定价和发送的延迟特性。
  6. 为安全、审计、幂等、回滚设计可注入策略(比如AuditService,IdempotencyKeyService)。
  7. 保持 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(链接时依赖注入)

链接时依赖注入是一种在编译期/链接期决定系统依赖实现的方式。
特点是:

  • 无运行时开销
  • 无框架、无容器
  • 通过链接器、#ifdefLIBPATH、目标文件选择实现
  • 依赖选择完全在“外部”进行,而不是代码内部决定

一、什么是 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/*.o

2)通过编译开关#ifdef注入

#ifdefTESTING#include"TradeRepository_test.h"#else#include"TradeRepository_prod.h"#endif

3)通过替换 .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=ftestf=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}fidiprod
测试版链接器绑定:
fi↦ditest f_i \mapsto d_i^{test}fiditest
构建得到两个不同二进制:
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 test

main.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}sendsendprod
    或:
    send≡sendtest send \equiv send_{test}sendsendtest
    链接器通过目标文件(.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 TEST
  • MockComs
  • FakeSender
  • DIContainer
    非常干净。

测试替换实现就是“扔进另一个 .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}sendsendtest

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. 特点(优点与限制)

优点(非常有限)

  1. 生产代码无需修改
    不需要引入接口,不用依赖注入框架,也不需要条件编译。
  2. 简单粗暴
    通过不同链接脚本、路径、构建配置,就能切换功能。
  3. 可用于简单场景测试
    例如在某些古老代码库、缺乏接口设计的系统中。

3. 缺点(这是为什么它被认为不推荐)

(1) 难以管理(Logistics Nightmare)

如果你有很多模块,每个模块有不同版本:
实现数=∏i=1nki \text{实现数} = \prod_{i=1}^{n} k_i实现数=i=1nki
其中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{业务逻辑}ActionHandlerICom=业务逻辑
    而你替换不同的实现:
    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. 特点总结

继承注入的优势

  1. 可以处理多方法、复杂接口
    接口类可以拥有几十个函数,没有问题。
  2. 利用 C++ 已经成熟的虚函数机制
    无需外部框架,不依赖元编程、不依赖编译技巧。
  3. 对旧系统非常友好
    在已有类前加一个面向接口的抽象层即可。
  4. 扩展简单
    新功能 = 继承接口并实现新的类。
  5. 运行时可灵活选择实现

5. 继承 DI 的缺点

虽然继承是经典方法,但它并非完美:

✘(1)虚函数有运行时开销

调用成本为:
O(1)但包含一次虚表指针间接跳转 \text{O(1)} \quad\text{但包含一次虚表指针间接跳转}O(1)但包含一次虚表指针间接跳转
在高性能场景(如 SIMD 或 tight loop)可能不可接受。

✘(2)接口难以向下兼容

一旦接口新增方法:

  • 所有实现必须更新,否则无法编译
  • 增加维护成本

✘(3)不适用于模板或纯函数式代码路径

泛型代码更适合:

  • 模板注入
  • 函数对象注入(callables)

6. 示例:继承 DI vs Linking DI 的对比

特性Linking DIInheritance 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}测试最少必须提供noverride
这在大型类中很痛苦。
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() } }EngineTThas 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)
  • 类型擦除 viastd::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::functionstd::move_only_function)把“任何能被调用的对象”统一成同一类可存储/调用的对象,从而可以把实现(依赖)作为参数注入进来:这就是“依赖注入 via type erasure”。

std::function是一个通用的多态函数包装器,能够存储、复制并调用任何CopyConstructible的 callable。
std::move_only_function是一个“只能移动”的可调用包装器(C++23 起有),在无法或不希望拷贝目标时很有用。
std::invoke则提供了统一的“如何调用任意 callable(包括成员指针 / 函数对象 / 函数指针)”的语义,方便实现通用调用。

优点(详细)

  1. 能调用满足签名的任意可调用目标
    • 不论是普通函数、函数指针、lambda、bind 表达式、仿函数、成员指针,只要签名匹配(或满足可调用要求),都能被封装并注入。
  2. 灵活、易于测试(mock)
    • 测试时把std::function/std::move_only_function参数替换成简短 lambda,就能隔离被测模块,避免引入复杂依赖。
  3. 统一了调用接口
    • 调用端只需写callable(args...)或通过std::invoke,不需要关心底层目标是什么,代码更通用。
  4. 不同语义的包装器可选
    • 如果你需要不可复制、仅移动的语义,可以选std::move_only_function(C++23);如果需要拷贝语义就用std::function

缺点 / 限制(详细)

  1. “只能替代一个方法签名” 的观察
    • 使用std::function<R(Args...)>实际上只能替换单一签名的 callable(或者一组同签名的不同实现)。如果对象有多个方法需要替换,std::function只能覆盖单个入口点;为多个方法你需要多个std::function成员或更复杂的包装(见替代方案一节)。
  2. 运行时开销(类型擦除的代价)
    • std::function的调用不是编译时内联的:实现通常为对具体callable的间接调用(通过内部虚表样式的机制),因此其开销常常和运行时虚函数调用相当(都是间接调用、可能有一次指针跳转以及可能的堆分配)。精确成本依赖实现与具体平台,但原则上不可与模板内联(zero-overhead)相提并论。
  3. 可能的堆分配(取决于实现与目标大小)
    • 许多std::function实现有 Small Buffer Optimization(SBO):若目标对象足够小则内嵌存储,否则会分配堆内存。但这不是语言强制的行为、不同实现(libstdc++, libc++, MSVC)细节不同,且可观测(即会影响性能)。因此不能单纯假设“用小 lambda 就一定不分配”。
  4. 类型信息/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更快——只是提供不同的所有权语义。
  • 性能敏感路径的建议
    1. 优先考虑模板 DI(编译期注入)template<typename F> struct Processor { F yieldCalc; ... }—— 零间接调用、可内联,但会导致二进制代码膨胀(每个F一份实现)。
    2. 如果需要运行时多态且低开销,可自实现小型 vtable 或函数表:把要替换的方法收集成一组函数指针,减少分配并更明确控制 ABI。
    3. std::function/std::move_only_function在测试和不太敏感的路径:它们极大提高灵活性与可维护性。

当需要“替换多个方法”时怎么办?

std::function只能表示一个函数签名;如果你有一个对象的多个方法需要被替换(例如一个策略对象有 N 个方法),常见做法有:

  1. 传入一个“接口结构/策略对象”(类/结构体,包含多个std::function成员),每个成员表示一个可替换方法。优点:直观;缺点:每个std::function都有自己的开销。
  2. 使用模板策略(编译期注入整个策略类型):把整个策略类型作为模板参数,能最大化性能,但增加模板复杂度。
  3. 抽象基类 + 虚函数(传统的运行时多态):简单明确,语义一致;代价是虚调用开销和继承复杂度。
  4. 自行实现小型 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(空值对象)是一种设计模式:
给某个接口提供一个“什么都不做”的实现,用于在测试或某些运行场景下
禁用依赖的真实行为

特点:

  • 满足类型要求(能编译、能通过接口约束)
  • 无功能(不执行真实逻辑)
  • 丢弃所有输入参数
  • 返回固定结果(如true0、空对象等)
  • 让系统的一部分被“无害化”(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)

这张架构图描述的是一个系统中典型的“动作执行流程”:

  1. 外部输入一个Action
  2. 系统调用对应的execute(…)方法
  3. execute 内部依赖一个组件Com
  4. Com 通过调用send(...)发出请求到Req
  5. Req 返回结果
  6. 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”

Actionexecute(...)ComsendReqresult

这张图用于说明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=不可用

ActionComexecute(..., &)ComsendReqresult

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 ReqexecuteCom.send(...)Req

execute(…) 返回 result

结果同样从 execute 输出:
Response=execute(Action,Com) \text{Response} = execute(\text{Action}, Com)Response=execute(Action,Com)

Method Injection 的优点(图中隐含)

  1. 不污染类的内部结构
    — execute 不持有依赖,不需要成员变量。
  2. 没有未初始化风险
    Setter Injection 会产生未初始化状态:
    sender=∅ sender = \varnothingsender=
    而 Method Injection 强制依赖在函数调用时存在。
  3. 测试非常容易
    你只需:
MockCom mock;execute(act,mock);
  1. 可以按需切换依赖
    不同场景传入不同实现。

图中最后一行:

Response Execute(…, Com&);
就是标准 Method Dependency Injection 的函数签名:

ResponseExecute(constAction&,Com&);

表达:
依赖对象;Com;通过函数参数注入 依赖对象 ; Com ; 通过函数参数注入依赖对象;Com;通过函数参数注入

最终总结(超清晰对比)


DI 类型注入位置类是否持有依赖?是否可能未初始化?测试友好度
Constructor Injection构造函数持有不可能
Setter Injectionsetter 函数持有可能未初始化
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)
    因此外部所有调用点都需要修改(缺点之一)。

优点

  1. 非常灵活:方法调用者可以自由决定传入什么依赖对象。
  2. 便于测试
    在测试时可以传入 mock/stub 的通信组件:
    MockCom→execute \text{MockCom} \rightarrow \text{execute}MockComexecute

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}}Enginereal=Enginetest
无法改变其行为。

3.Default class constructors initialized via Singletons/Globals

(默认构造函数使用单例/全局变量初始化)
这是大型老系统中最常见、也是最严重的障碍。
例如:

classProcessor{public:Processor():db_(GlobalDB::Get()),// 依赖全局单例logger_(SingletonLogger::GetInstance()){}};

详细解释:

当默认构造函数直接使用单例或全局变量:

  1. 依赖被强绑定到单例
  2. 无法替换为 mock/fake
  3. 单例有可能是“全局状态污染”源头
  4. 单元测试会变得不可控
  5. 多线程中会产生隐性共享状态
    数学化:
    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 Processorprocess=f(D,Processor(real)) \text{process} = f(D, \text{Processor(real)})process=f(D,Processor(real))无法替换、无法注入
使用 Singletonprocess=f(D,G) \text{process} = f(D, G)process=f(D,G)全局状态污染、不可测试
使用全局 Factoryprocess=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 在真实世界中经常遇到的两个大问题:

  1. 隐式对象构造(上一节已经解释)
  2. 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 fABCDEf
    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()
    这样做的问题:
  1. 高度耦合Processor了解太多上下文结构
  2. 可测试性差:需要一长串 mock 对象链
  3. 难以维护:改变任何中间对象的 API 都会导致这里的代码被迫修改
  4. 破坏封装性:内部结构暴露太多

更好的方式:将所需对象直接注入

改写后的版本:

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");// ...}

这样会导致:

  • 外部无法替换LegacyTickHelperDatabase
  • 也无法注入 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
  • 无隐藏副作用
    这样才易于注入、替换、测试。

总结()

依赖注入遇到的典型障碍包括:

  1. 对象在内部创建,无法替换
  2. 没有“注入点”,无法传入 mock
  3. 依赖单例/全局变量,造成隐式耦合
  4. 跨越多个对象访问(违反迪米特法则)
  5. mock 链太长,测试负担过大
  6. 违反最少知识原则,使对象知道太多不该知道的东西
  7. 获取信息和设置状态混在一起,难以做 DI 也难以纯化逻辑
    本质目标是:
    让依赖变得显式、简单、可替换、可测试 \text{让依赖变得显式、简单、可替换、可测试}让依赖变得显式、简单、可替换、可测试

#⃣ 背景:为什么“获取”和“设置”混在一起是 DI 的障碍?

依赖注入要求:逻辑尽量纯(pure)、输入与输出分离、可复用、可测试。
但很多实际代码把:

  • 读取数据(get)
  • 写入状态(set)
    混在一段流程中,如你给的第一段示例:
if(handle.getBidPrice(&decimalPrice))bid_.setPrice(...);elseif(handle.getBidPrice(&doublePrice))bid_.setPrice(...);...

这种代码带来严重问题:

  1. 业务逻辑散落在条件中,不可复用
  2. 难以注入 mock,因为读取与写入耦合
  3. 难测试:每次测试都需要构造完整的状态和依赖对象
  4. 逻辑不纯,不可进行组合/重写
    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:HandleOptional(PriceVariant)
    这是一个非常好的 DI 模式:
    逻辑已被“纯化”,可以独立注入、独立测试。

bid_.setPrice / ask_.setPrice —— 状态更新(impure function)

职责:

  • 将最终结果写入对象内部
    数学上是:
    setPrice:PriceVariant→State Update setPrice : PriceVariant \rightarrow State\ UpdatesetPrice:PriceVariantStateUpdate
    这部分保持简单即可,不需知道读取逻辑。

#⃣ 这样拆分后带来的好处

1. 更易依赖注入(DI Friendly)

现在你只注入:

  • handle mock 或解析器 mock
  • Bid/Ask mock
    即可测试。
    不再需要 mock 整条链,如:
tick → context → exchange → helper → price

2. 更易单元测试

你可以独立测试纯函数:

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. 清晰的职责边界 = 更少的耦合 + 更高可维护性

逻辑线条变为:

  1. 从 handle 提取价格(纯函数)
  2. 更新报价对象(状态函数)
    这完全符合现代架构(FP + OO 混合)的最佳实践。

#⃣ 总结(版)

你的示例展示了依赖注入的一个典型危险:

将“获取数据”与“更新状态”混在一起,使逻辑不可测试、不纯、难注入。
改进方式:

  1. 将获取逻辑提取为 pure function:
    getBidPrice(handle)→Optional(PriceVariant) getBidPrice(handle) \rightarrow Optional(PriceVariant)getBidPrice(handle)Optional(PriceVariant)
  2. 将设置逻辑保持在最简单的状态更新中:
    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, ...nn1,n2,...

** 解决方案 2:使用 Facade / Aggregator(并不是 Service Locator)**

把同一功能域的依赖打包:

structMarketDataServices{QuoteProvider&quotes;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 容器逻辑变得复杂
  • 读取/写入逻辑混在一起导致更多依赖
    解决办法:
  1. 拆分类职责
  2. 用 Facade / Aggregator 聚合功能域依赖
  3. 挖掘纯函数减少依赖需求
  4. 使用上下文对象代替一堆散乱依赖
    最终目标:
    越少依赖⇒越容易注入⇒越容易测试 \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 出现以下问题:
  1. 函数难以理解、难以维护
  2. 测试需要构造大量 mock → mock explosion
  3. 不能自然地进行 DI(参数太多)
  4. 表示函数职责过多(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),不是依赖注入,它只是把原本混乱的依赖移动到另一个地方。
主要问题:

  1. Bucket 无结构、无语义
  2. Bucket 不减少 mock
  3. Bucket 不减少依赖耦合
  4. Bucket 破坏 Law of Demeter
  5. Bucket 让签名隐藏依赖(反 DI)
  6. 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 1N1
    依赖注入(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}PricingSizingActions
  • 各子模块成为独立的类:
    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)
    它只负责决定顺序,比如:
  1. Gather Information
  2. Business Logic
  3. 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 Logic

Gather Information:负责数据准备

Gather Information ├── Pricing Info ├── Actions Info └── Sizing Info

Pricing 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 objectcannot 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 mocksHigh 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,\ 20N10,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 depsBad designCannot 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 ClassToo many depsNo 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 和测试都变得痛苦。”
解决方法:

  1. 将这些字段整合成结构体(SideInfo,ExtendedInfo)。
  2. 替代冗长的 getData(…) 参数列表。
  3. 取代继承体系,减少 override build 的复杂度。
  4. 让 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 的做法是:

  1. 第一步:把 bid/ask 这一对数据合并成OptionalPairT<T>
  2. 第二步:把组合出现的两组 pair(global + local)合并为SideInfo
  3. 第三步:把扩展字段(broker、yield)合并为ExtraFields
  4. 第四步:把 Collector 的 getData 接口简化为:
    • 基础版:getData(tick, sideInfo)
    • 扩展版:getData(tick, sideInfo, extraFields)
  5. 最后: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 typed

Builder / 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);

更易使用、强类型、安全、可扩展。

总结(口语化理解)

这页内容告诉你:

依赖注入不是加几个模板、加几个构造函数那么简单。
是一种系统化的代码健康改革。

六大方向:

  1. 别在里面 new —— 依赖从外面来
  2. 别链式调用 —— 直接注入需要的东西
  3. 纯逻辑与状态修改分开 —— 可测试性大幅提升
  4. 拆 God class —— 抽象分层,让依赖清晰
  5. 把碎功能聚合 —— 避免依赖乱飞
  6. 把零散参数收拢成结构 —— 强类型、可扩展
    这就是 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 技巧:

  1. 默认参数
    • $ \text{CalcDep& calc = defaultCalc} $
  2. 转发函数
    • 保留旧 API → 内部转发到新 API
  3. 委托构造函数
    • 旧构造函数调用新构造函数
      这些技巧让你:
  • 不破坏现有系统
  • 逐步引入可测试、可替换的依赖
  • 不需要一次性重写整个模块
  • 风险低、可渐进式重构

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:stringunique_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=createDbHelper

5. 渐进式(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 functionvirtual functions

为什么这是个 DI / Mocking 的麻烦点?

示例类:

classHeader{public:template<typenameT>TypeNumisType(constT&)const;virtualTypeNumisType(conststd::string&)const;// ...};
  • isType<T>是模板实现(按需实例化),编译器在编译期生成实例化代码;
  • virtual机制是运行时的 vtable/动态派发,两者语义不同,所以语言不允许把模板成员直接标记为virtual
    因此当你想在MockHeaderMOCK_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 少数几种类型(如stringint),且能修改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 非常简单(mockisTypeAny
    缺点:
  • 实现复杂,需要进行类型检查/转换,易错且性能差
  • 可读性差,需小心管理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/模板化)。

小结(关键提醒)

  1. 模板成员不能是虚函数,因此你不能直接MOCK_METHOD一个模板方法。
  2. 注入必须被使用—— 把std::function放到类里无效,除非你把调用点改为调用该函数对象。
  3. 在遗留系统中,优先选择最小侵入可测试的方案(通常是 A 或 B 或 C 的某种折衷),再视情况推进更强拆解。
  4. 每种方案都有权衡:易改 vs 易测试 vs 性能开销 vs 侵入面,根据实际代码库规模与约束选型。

问题回顾(要点)

C++ 不允许把成员模板声明为virtual。因此像下面的设计:

classHeader{public:template<typenameT>TypeNumisType(constT&)const;// 不能 virtual};

无法通过继承/虚方法去覆盖/MockisType<T>。这是 DI(基于继承/虚函数)的一个根本性限制
你必须用另一种方法让行为在测试时可替换。
数学上可以把问题表述为:你想让一个映射
T↦行为T T \mapsto \text{行为}_TT行为T
在运行时可替换,但模板实现在编译期确定,所以两者语义冲突。

解决思路总览(三类主流方案)

  1. 把需要覆盖的具体类型变成普通的非模板虚方法(把部分模板“具体化”成虚方法)。
  2. 在调用点做类型擦除 / 注入函数对象(type-erasure 或 function-injection),让调用者依赖注入点而不是类模板。
  3. 把继承 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/替换少数常见类型(例如stringint),并且可以改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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!