耦合和内聚的适用范围
架构设计常以“高内聚、低耦合”为目标,但实际讨论中,这两个概念的边界经常不清楚:
- 将所有依赖都视为应避免的耦合。
- 知道要“高内聚”,但缺少衡量方式。
- 无法判断某个耦合是否会带来额外的变更成本。
在讨论小程序架构演进中的“逻辑内聚”时,需要先明确这两个概念的定义和度量范围。
概念定义
基础定义
| 概念 | 定义 | 说明 |
|---|---|---|
| 耦合(Coupling) 共生(Connascence) | 有 A、B 两个模块,修改 A 时必须修改 B,则 B 耦合 A | • 耦合发生在模块之间,不表示父子层级关系 • 静态类型语言中可将其理解为由编译错误提示的连接约束 • 业务上经常同时修改的代码不必然构成耦合 |
| 内聚(Cohesion) | 若干模块因某种关系被包含在一起的紧密程度 | • 具有父子层级关系 • 内聚 = 子模块间耦合数量 ÷ 子模块总数 • 关注代码组织,而非业务逻辑 |
关系本质
内聚依赖模块内部的耦合关系,二者都是模块化的度量指标。设计时需要识别会扩大修改范围或增加运行时约束的耦合。
内聚性的 7 个层次
根据《软件架构:架构模式、特征及实践指南》,内聚性有7个层次(按强度从弱到强):
内聚性层次表
| 层次 | 类型 | 定义 | 特征 | 示例 |
|---|---|---|---|---|
| 1 | 偶然内聚 | 模块内元素间没有任何联系 | 最低内聚度,巧合组合 | 工具类大杂烩 |
| 2 | 逻辑内聚 | 相关功能组合,通过参数选择 | 功能相关但实现独立 | 各种验证函数放在一起 |
| 3 | 时间内聚 | 需要同时执行的动作组合 | 时间相关性 | 初始化模块 |
| 4 | 过程内聚 | 按特定顺序执行的处理元素 | 顺序相关但无数据传递 | 操作流程步骤 |
| 5 | 通信内聚 | 操作同一数据结构的元素 | 数据相关性 | 用户信息CRUD |
| 6 | 顺序内聚 | 前一个输出是后一个输入 | 数据流相关性 | 数据处理管道 |
| 7 | 功能内聚 | 所有元素为完成同一功能存在 | 最高内聚度,单一职责 | 计算水费模块 |
内聚性改进示例
低内聚的代码:
class Module {
a = 1;
b = 2;
fn1() {
this.a = 1;
}
fn2() {
return this.b;
}
fn3() {
console.log('fn');
}
}改进后的高内聚代码:
class ModuleA {
a = 1;
fn1() {
this.a = 1;
}
}
class ModuleB {
b = 3;
fn2() {
return this.b;
}
}
class ModuleC {
fn3() {
console.log('fn');
}
}用共生性(Connascence)度量耦合
1996年 Meilir Page-Jones 在《What Every Programmer Should Know About Object-Oriented Design》中完善了耦合的度量,命名为”Connascence”:
如果一个组件的改变会要求另一个组件进行修改,才能保持系统的整体正确性,那么这两个组件就是共生的。
静态共生性(Static Connascence)
1. 名称共生性(Connascence of Name, CoN)
特征:引用相同的名称
class Customer {
Age = 25; // 第3行
}
const customer = new Customer();
console.log(customer.Age); // 第8行 - 如果第3行改名,这里必须同步修改现代 IDE 可以自动处理名称重命名,因此这类共生通常较容易管理。
2. 类型共生性(Connascence of Type, CoT)
特征:必须就数据类型达成一致
function sayHello(name: string): string {
return `Hello ${name}`;
}
sayHello(123); // 错误:类型不匹配3. 意义共生性(Connascence of Meaning, CoM)
特征:必须就特定值的含义达成一致(Magic Number)
// 不好的例子
if (status === 1) { /* 激活 */ }
if (status === 2) { /* 禁用 */ }
// 改进
const STATUS = {
ACTIVE: 1,
DISABLED: 2
};
if (status === STATUS.ACTIVE) { /* 激活 */ }4. 位置共生性(Connascence of Position, CoP)
特征:必须就顺序达成一致
// 位置耦合
function createUser(firstName, lastName, address) {
// ...
}
createUser("Bob", "Marley", "Jamaica");
// 改进:名称耦合
function createUser(userInfo) {
const { firstName, lastName, address } = userInfo;
// ...
}
createUser({
firstName: "Bob",
lastName: "Marley",
address: "Jamaica"
});5. 算法共生性(Connascence of Algorithm, CoA)
特征:必须就算法达成一致
// 客户端和服务端必须使用相同的加密算法
const clientHash = md5(password + salt);
const serverHash = md5(password + salt); // 必须保持一致动态共生性(Dynamic Connascence)
1. 执行共生性(Connascence of Execution, CoE)
特征:代码执行顺序的耦合
// 错误的顺序
email = new Email();
email.setRecipient("foo@example.com");
email.setSender("me@me.com");
email.send(); // 先发送了
email.setSubject("whoops"); // 后设置主题
// 正确的顺序
email = new Email();
email.setRecipient("foo@example.com");
email.setSender("me@me.com");
email.setSubject("Important Message");
email.send();2. 时间共生性(Connascence of Timing, CoT)
特征:执行时间上的耦合
// Bootstrap Modal 的时间耦合问题
$(element).modal('hide');
$(element).modal('show'); // 错误!动画未完成
// 正确的做法
$(element).modal('hide');
$(element).on('hidden.bs.modal', () => {
$(element).modal('show'); // 等待动画完成
});3. 值共生性(Connascence of Values, CoV)
特征:必须同时更改多个值
// 测试中的值耦合
function calculateTotal() {
return 50; // 函数返回值
}
// 测试代码
expect(calculateTotal()).toBe(50); // 必须保持一致4. 身份共生性(Connascence of Identity, CoI)
特征:必须引用同一个对象实例
// 问题代码:对象副本导致的身份耦合
function changeMovieTitle(movie, newTitle) {
const updatedMovie = { ...movie, title: newTitle };
return updatedMovie; // 返回新对象
}
function displayMovie(movie) {
console.log(movie.title); // 显示旧标题
}
// 改为返回对象 ID
function changeMovieTitle(movieId, newTitle) {
// 直接修改数据库
database.updateMovie(movieId, { title: newTitle });
return movieId;
}共生性的三个属性
1. 强度(Strength)
静态共生性通常可由 IDE 和静态分析工具检测;动态共生性需要在运行时识别,处理成本更高。
2. 局部性(Locality)
共生关系越靠近同一模块边界,越容易被局部修改和测试覆盖。
- 同一模块内:可以有较强的共生性
- 跨模块:应该使用较弱的共生性
3. 程度(Degree)
共生关系影响的模块和类越少,变更风险越容易控制。
- 影响几个类:可接受
- 影响几十个类:需要重构
实践指南
Page-Jones 的指导原则
- 通过模块化拆分降低整体共生性。
- 减少跨越模块边界的共生性。
- 将完成同一职责所需的共生关系保留在模块内部。
Jim Weirich 的建议
- 将强共生性改为较弱的共生性。
- 随着模块距离增加,使用较弱的共生性。
实际操作建议
设计阶段
-
模块边界设计
- 明确模块职责边界
- 最小化跨模块依赖
- 优先使用名称共生性
-
接口设计
- 避免位置耦合,使用命名参数
- 减少算法耦合,抽象通用接口
- 控制类型耦合的传播范围
开发阶段
-
代码组织
- 相关功能聚集在同一模块
- 避免循环依赖
- 清晰的模块导入/导出
-
重构策略
- 识别高耦合点
- 逐步降低共生性强度
- 提高模块内聚性
维护阶段
-
监控指标
- 模块间依赖数量
- 共生性强度分布
- 变更影响范围
-
持续改进
- 定期重构高耦合模块
- 优化模块边界划分
- 提升代码可读性
在前端项目中的应用
组件设计
// 低内聚:功能混杂
class UserComponent {
renderProfile() { /* 渲染用户信息 */ }
validateForm() { /* 表单验证 */ }
sendEmail() { /* 发送邮件 */ }
calculateAge() { /* 计算年龄 */ }
}
// 高内聚:单一职责
class UserProfile {
renderProfile() { /* 只负责渲染 */ }
}
class UserValidator {
validateForm() { /* 只负责验证 */ }
}
class EmailService {
sendEmail() { /* 只负责邮件 */ }
}状态管理
// 强耦合:直接修改全局状态
function updateUser(userId, data) {
window.globalState.users[userId] = data; // 位置耦合
window.globalState.lastUpdate = Date.now(); // 值耦合
}
// 弱耦合:通过接口操作
function updateUser(userId, data) {
userStore.update(userId, data); // 名称耦合
eventBus.emit('user:updated', { userId, data }); // 名称耦合
}结论
模块之间需要通过接口、类型和数据协议协作,因此耦合无法被完全消除。应优先识别跨模块的动态共生、位置共生和算法共生,并把影响范围限制在明确的模块边界内。模块内部可以保留服务同一职责所需的关联;跨模块接口则应减少隐含顺序、时间和共享值约束。
相关阅读:
参考资料:
