依赖关系的演化——从隐式调用到显式边界
依赖关系是软件中最基本的关系——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); search_pattern(data, len); printf("done\n"); }
|
do_scan 依赖了 open、search_pattern、printf 三个函数。但代码里没有显式声明「我依赖谁」——依赖关系藏在函数调用里。
函数级依赖的特点:
1 2 3 4
| ✅ 简单直接:调用就是依赖 ✅ 编译器自动检查:调用未声明的函数会报错 ❌ 不可见:看不出完整依赖图,只有逐行阅读才能发现 ❌ 无法约束:A 可以调用任何函数,没有方向限制
|
函数级依赖是「能用但看不见」的。
三、文件级:include 依赖
文件拆分后,依赖变成了 include 关系:
1 2 3 4 5 6
| #include "print.h" #include "format.h"
#include "format.h"
|
文件级依赖比函数级多了一个东西:头文件声明。头文件明确告诉编译器「我提供了什么接口」,但依赖方向仍然没有约束。
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 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) 注入解决了同一实例多处依赖时的共享问题
依赖方向 ←→ 七条流·启动流 依赖顺序 = 启动顺序 = 构建顺序
|
依赖关系是贯穿所有线的底层约束——它在每个阶段都有不同的表现,影响着分层、接口、组装、测试、构建等所有方面。它不是某一条线的附属品,而是所有线共享的底层规则。