依赖关系的演化——从隐式调用到显式边界

依赖关系是软件中最基本的关系——A 用到了 B,A 就依赖 B。但依赖在每个阶段的形态完全不同:函数级是隐式的,文件级是 include,分层后变成单向约束,系统拆分后变成接口边界,多系统后变成包依赖和构建依赖。这一篇把依赖关系的完整演化讲清楚。


一、什么是依赖

一句话定义:A 需要 B 才能工作,A 就依赖 B。

1
2
3
4
函数 A 调用了函数 B → A 依赖 B
文件 A include 了文件 B 的头文件 → A 依赖 B
模块 A 使用了模块 B 的数据结构 → A 依赖 B
系统 A 通过网络调用了系统 B → A 依赖 B

依赖的本质:没有 B,A 就不能工作。


二、函数级:隐式依赖

函数之间的依赖是隐式的——你调用了谁,你就依赖了谁,不需要声明:

1
2
3
4
5
void do_scan() {
int fd = open("file.bin", O_RDONLY); // 依赖 open
search_pattern(data, len); // 依赖 search_pattern
printf("done\n"); // 依赖 printf
}

do_scan 依赖了 opensearch_patternprintf 三个函数。但代码里没有显式声明「我依赖谁」——依赖关系藏在函数调用里。

函数级依赖的特点:

1
2
3
4
✅ 简单直接:调用就是依赖
✅ 编译器自动检查:调用未声明的函数会报错
❌ 不可见:看不出完整依赖图,只有逐行阅读才能发现
❌ 无法约束:A 可以调用任何函数,没有方向限制

函数级依赖是「能用但看不见」的。


三、文件级:include 依赖

文件拆分后,依赖变成了 include 关系:

1
2
3
4
5
6
// main.c
#include "print.h" // 依赖 print 模块
#include "format.h" // 依赖 format 模块

// print.c
#include "format.h" // 依赖 format 模块

文件级依赖比函数级多了一个东西:头文件声明。头文件明确告诉编译器「我提供了什么接口」,但依赖方向仍然没有约束。

1
2
3
main.c → print.h → print.c
main.c → format.h → format.c
print.c → format.h → format.c

文件级依赖的特点:

1
2
3
4
✅ 依赖可见:看 #include 就知道依赖了谁
✅ 编译器检查:include 了不存在的头文件会报错
❌ 仍然没有方向约束:print.c 可以 include main.h(反向依赖)
❌ 容易出现循环依赖:A.h include B.h,B.h include A.h

文件级依赖是「可见但无约束」的。


四、分层后:单向约束出现

分层是依赖关系发生质变的地方。分层引入了一个硬规则:

1
2
3
4
5
6
7
8
依赖方向只能从上往下,不能从下往上。

入口层 → 业务层 → 基础设施层

✅ 入口层依赖业务层(上→下)
✅ 业务层依赖基础设施层(上→下)
❌ 基础设施层依赖业务层(下→上)← 禁止
❌ 业务层依赖入口层(下→上)← 禁止

为什么必须单向:

1
2
3
4
5
6
7
8
9
10
11
如果允许反向依赖:
→ 改业务逻辑可能破坏基础设施
→ 基础设施无法独立测试
→ 底层无法被其他项目复用
→ 改一个文件牵连一大片

如果坚持单向:
→ 改业务逻辑不影响基础设施
→ 基础设施可以独立测试
→ 底层可以被任何上层复用
→ 改动范围可控

分层后依赖的特点:

1
2
3
4
5
✅ 方向明确:只能上→下
✅ 改动可控:改上层不影响下层
✅ 可测试:底层可以脱离上层独立测试
✅ 可复用:底层可以被不同上层使用
❌ 需要显式管理:不能靠自觉,需要规则和审查

分层后依赖从「无约束」变成「硬约束」。


五、依赖方向的判断方法

怎么判断两个模块之间的依赖方向?

1
2
3
4
5
6
7
8
9
10
11
问:如果 B 改了接口,A 需要跟着改吗?
→ 是:A 依赖 B(A → B)
→ 否:B 依赖 A(B → A)或无依赖

问:如果删掉 B,A 还能工作吗?
→ 不能:A 依赖 B
→ 能:A 不依赖 B

问:A 创建时需要 B 的实例吗?
→ 是:A 依赖 B
→ 否:A 可能不依赖 B

分层后的依赖方向判断:

1
2
3
4
5
6
7
8
入口层 依赖 业务层?
→ 是:入口层调用业务层函数 → 入口层 → 业务层 ✅

业务层 依赖 基础设施层?
→ 是:业务层使用基础设施层的函数 → 业务层 → 基础设施层 ✅

基础设施层 依赖 业务层?
→ 如果是:基础设施层调用了业务层 → 基础设施层 → 业务层 ❌ 禁止!

六、循环依赖:分层后必须消除

循环依赖是依赖关系中最危险的模式:

1
A → B → C → A  ← 循环!

循环依赖的危害:

1
2
3
4
5
6
7
8
9
10
1. 改 A 可能破坏 B,改 B 可能破坏 C,改 C 可能破坏 A
→ 改动牵连不可控

2. 编译顺序不确定
→ A 需要先编译 B,B 需要先编译 C,C 需要先编译 A
→ 编译器不知道先编译谁

3. 无法独立测试
→ 测试 A 需要 B,测试 B 需要 C,测试 C 需要 A
→ 没法单独测试任何一个

消除循环依赖的方法:

1
2
3
4
5
6
7
8
9
10
方法 1:提取公共接口
A → Interface ← B
A 和 B 都依赖 Interface,不互相依赖

方法 2:依赖倒置
原来:A → B(A 调用 B 的实现)
改为:A → Interface ← B(A 依赖接口,B 实现接口)

方法 3:合并
如果 A 和 B 总是一起用,合并成一个模块

七、接口依赖:依赖倒置

当上层直接调用下层的具体函数时,上层就依赖了下层的实现。如果要换实现,就要改上层代码。

依赖倒置的做法:双方都依赖接口,不依赖实现

1
2
3
4
5
6
7
8
原来:
业务层 → 基础设施层(直接调用具体函数)
业务层依赖了基础设施层的实现

依赖倒置后:
业务层 → 接口 ← 基础设施层
业务层依赖接口(不关心谁实现)
基础设施层实现接口(不关心谁使用)
1
2
3
4
5
6
7
8
9
10
11
12
13
// 业务层定义接口
struct IFileOps {
virtual void* open(const char* path) = 0;
virtual int read(void* fd, void* buf, size_t len) = 0;
virtual void close(void* fd) = 0;
};

// 基础设施层实现接口
class PosixFileOps : public IFileOps {
void* open(const char* path) override { return posix_open(path); }
int read(void* fd, void* buf, size_t len) override { return posix_read(fd, buf, len); }
void close(void* fd) override { return posix_close(fd); }
};
1
2
3
4
依赖方向:
业务层 ──定义接口──→ IFileOps
PosixFileOps ──实现接口──→ IFileOps
业务层 ←──使用── PosixFileOps(通过 IFileOps 指针)

依赖倒置的核心:把「上层依赖下层实现」翻转为「双方都依赖接口」。


八、系统拆分后:依赖变成显式边界

当程序从单系统拆成多系统时,依赖关系变得显式:

1
2
3
4
5
6
7
8
9
单系统时:
所有模块在同一个编译单元里
依赖 = include + 链接
隐式,没有物理边界

多系统时:
每个子系统是独立的库/进程
依赖 = 链接库 / IPC / 网络调用
显式,有物理边界

系统拆分后的依赖规则:

1
2
3
4
✅ 子系统 A 依赖子系统 B 的公开接口(B 的 .h 文件)
❌ 子系统 A 依赖子系统 B 的内部实现(B 的 .c 文件)
❌ 子系统 A 和子系统 B 互相依赖(循环依赖)
❌ 子系统 A 直接访问子系统 B 的内存(破坏边界)

系统拆分把「逻辑上的依赖方向」变成了「物理上的接口边界」。


九、包依赖和构建依赖

当系统进一步增长,依赖关系延伸到包(库)和构建系统:

1
2
3
4
5
6
7
8
9
10
11
12
源码级依赖:
A.cpp #include "B.h" → A 依赖 B

库级依赖:
A 链接了 libB.a → A 依赖 B

包级依赖:
CMakeLists.txt: target_link_libraries(A B) → A 依赖 B
package.json: "dependencies": {"B": "^1.0"} → A 依赖 B

构建依赖:
Makefile: A: B → B 必须先构建,A 才能构建

构建依赖是启动流的延伸——依赖顺序决定了构建顺序

1
2
3
4
5
构建依赖图:
Utils(无依赖,最先构建)
→ Infrastructure(依赖 Utils)
→ Business(依赖 Infrastructure)
→ App(依赖 Business)

构建依赖 = 启动流在编译期的体现。


十、依赖关系的演化总结

1
2
3
4
5
6
7
阶段         依赖形态           方向约束      可见性
─────────────────────────────────────────────────────
函数级 隐式调用 无 不可见
文件级 include 无 可见但无约束
分层后 显式单向 必须上→下 显式管理
系统拆分后 接口边界 必须单向 物理隔离
多系统 包/库/构建依赖 必须单向 构建系统管理

演化规律:

1
2
3
4
依赖从隐式 → 显式
方向从无约束 → 硬约束
边界从逻辑 → 物理
管理从人工 → 工具

十一、依赖关系和其他线的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
依赖方向 ←→ 分层(从指令到系统)
分层产生了单向约束

依赖方向 ←→ 组件/子系统(从指令到系统)
子系统拆分产生了接口边界

依赖方向 ←→ 接口形态(接口)
接口形态决定了依赖的方式(函数调用 vs 虚函数)

依赖方向 ←→ 依赖注入(本主题 02)
注入解决了同一实例多处依赖时的共享问题

依赖方向 ←→ 七条流·启动流
依赖顺序 = 启动顺序 = 构建顺序

依赖关系是贯穿所有线的底层约束——它在每个阶段都有不同的表现,影响着分层、接口、组装、测试、构建等所有方面。它不是某一条线的附属品,而是所有线共享的底层规则。