依赖注入——同一实例多处调用时怎么共享

虚函数解决了「怎么定义接口」,但还有一个问题:如果三个地方都需要用同一个 AuthService 实例,这个实例从哪来?怎么传过去?这就是依赖注入要解决的问题。依赖注入不是一种设计模式,而是「共享同一实例」的四种机制的总称。


一、问题:同一实例需要多处调用

假设有一个日志系统,多个模块都需要用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class LogSystem {
public:
void info(const char* msg);
void error(const char* msg);
};

class AuthService {
LogSystem& log_; // 需要日志
public:
AuthService(LogSystem& log) : log_(log) {}
void login() { log_.info("login"); }
};

class NetworkClient {
LogSystem& log_; // 也需要日志
public:
NetworkClient(LogSystem& log) : log_(log) {}
void send() { log_.info("sent"); }
};

AuthServiceNetworkClient 都需要 LogSystem。问题是:**LogSystem 的实例从哪来?**


二、四种共享机制

机制 1:全局变量

最简单的做法——把 LogSystem 做成全局变量:

1
2
3
4
5
LogSystem global_log;  // 全局

class AuthService {
void login() { global_log.info("login"); } // 直接用
};

优点:简单直接。
缺点:

  • 任何地方都能修改它,没有边界
  • 无法替换(测试时不能用 MockLogSystem)
  • 隐藏了依赖关系(看不出 AuthService 依赖 LogSystem)

全局变量 = 最弱的共享机制。能用,但没有约束。

机制 2:构造函数传参

把实例通过构造函数传进去:

1
2
3
4
5
6
7
8
9
class AuthService {
LogSystem& log_;
public:
AuthService(LogSystem& log) : log_(log) {}
};

LogSystem log; // 创建一次
AuthService auth(log); // 传进去
NetworkClient net(log); // 传进去

优点:依赖显式(看构造函数就知道依赖什么)。
缺点:创建链条长——如果 AuthService 还依赖 HttpClientHttpClient 还依赖 EventLoop,创建顺序就很复杂。

构造函数传参 = 最显式的共享机制。依赖清晰,但创建链条可能很长。

机制 3:组装者集中创建

由一个「组装者」(main 或 Composition Root)统一创建所有实例,再传给需要的地方:

1
2
3
4
5
6
7
8
9
10
11
12
int main() {
// 集中创建
LogSystem log;
HttpClient http;
AuthService auth(log, http);
NetworkClient net(log, http);
LoginViewModel vm(auth);
LoginView view(vm);

// 使用
view.show();
}

优点:所有依赖关系一目了然;可以轻松替换任何一个实现。
缺点:main 知道太多——它知道所有组件的创建方式和依赖关系。

组装者集中创建 = 依赖注入的标准形式(Composition Root)。

机制 4:子系统内部组装

不让 main 知道太多——每个子系统自己负责内部组件的创建:

1
2
3
4
5
6
7
8
9
10
11
class AuthService {
LogSystem log_; // 内部创建
HttpClient http_; // 内部创建
LoginParser parser_; // 内部创建
public:
AuthService() = default; // 外部不需要传任何东西
LoginResult login(...) { /* 内部组合使用 */ }
};

// main 只需要:
AuthService auth; // 一行搞定

优点:外部简单,不需要知道内部组成。
缺点:内部组件无法替换(测试时不能注入 MockLogSystem)。

子系统内部组装 = 最简单的共享机制。外部简单,但牺牲了可替换性。


三、四种机制的对比

1
2
3
4
5
6
              全局变量    构造函数传参    组装者集中    内部组装
依赖显式? ❌ 隐藏 ✅ 显式 ✅ 显式 ❌ 隐藏
可替换? ❌ ✅ ✅ ❌
外部复杂度 低 高 高 低
内部复杂度 低 低 低 高
适合场景 快速原型 小型项目 中大型项目 子系统封装

四、选择依据

1
2
3
4
5
6
7
8
9
10
11
12
13
问自己三个问题:

1. 这个实例会被多处调用吗?
→ 不会 → 不需要注入,直接创建
→ 会 → 继续

2. 需要替换实现吗(测试/多平台)?
→ 不需要 → 子系统内部组装
→ 需要 → 继续

3. 组件多吗(超过 5 个)?
→ 不多 → 构造函数传参
→ 多 → 组装者集中创建

五、依赖注入和虚函数的关系

两条线交叉的地方:

1
2
3
4
5
6
7
虚函数定义了:怎么定义接口(ISearcher)
依赖注入解决了:怎么传递实例(ISearcher&)

两者配合:
1. 定义接口:class ISearcher { virtual find() = 0; }
2. 创建实现:FileSearcher fs("file.bin");
3. 注入实例:do_search(fs, pattern); // 通过参数传入
1
2
3
没有虚函数:接口固定,不需要注入(直接用具体类)
有虚函数但没注入:接口可替换,但实例传递困难
有虚函数 + 有注入:接口可替换 + 实例可共享 = 完整的可扩展架构

六、框架化组件被调用 → 依赖注入必然出现

核心结论(已用代码验证):框架化组件一旦被其他组件调用,就必然出现依赖注入。

为什么?框架化组件的三个特征凑在一起:

1
2
3
4
5
6
7
8
9
框架化组件(LogSystem / EventLoop / NetworkClient):
→ 有生命周期(start/stop)
→ 有状态(缓冲、事件列表、连接表)
→ 全程序通常只有一份实例(共享)

被调用的场景:
ProcessService 要 log.info(...)
NetworkService 也要 log.info(...)
→ 两个组件都要「同一个 LogSystem 实例」

实例从哪来?三个选项:

1
2
3
❌ 全局变量:太弱(不可替换、依赖隐藏)
❌ 每个组件内部创建:生命周期被复制、状态不共享
✅ 依赖注入:main 创建一份,构造函数传进去

所以链条是:

1
2
3
4
框架化 + 被多处调用
→ 必须共享同一实例
→ 实例必须从外部传入
→ 依赖注入

依赖注入的本质:解决「框架化组件的唯一实例怎么被多个调用方共享」。

对照:接口化组件不需要注入:

1
2
3
4
5
6
接口化组件(file_read、StringUtil::trim):
→ 无状态
→ 即调即用
→ 没有「实例」概念

调用:直接调用,没有实例可传 → 不需要注入

七、总结

1
2
3
4
5
6
7
8
9
10
11
12
依赖注入解决的问题:同一实例多处调用时怎么共享

四种机制:
1. 全局变量 → 最简单,最弱
2. 构造函数传参 → 最显式
3. 组装者集中 → 中大型项目标配
4. 子系统内部 → 最简单但不可替换

和虚函数的关系:
虚函数 = 怎么定义接口
依赖注入 = 怎么传递实例
两者配合 = 完整的可扩展架构

两条线到这里讲完:接口形态演化决定了「用函数还是用类」,依赖注入决定了「实例怎么共享」。它们共同构成了组件对外暴露能力的完整机制。