内聚
内聚的含义:内聚指一个模块内部元素彼此结合的紧密程度
在面向对象领域,谈到“内聚”的时候,模块的概念远远不止我们通常所理解的“系统内的某个模块”这个范围,而是可大可小,大到一个子系统,小到一个函数,你都可以理解为内聚
里所说的 “模块”。所以可以用“内聚”来判断一个函数设计是否合理,一个类设计是否合理,一个接口设计是否合理, 一个包设计是否合理,一个模块/子系统设计是否合理。
其次:“元素”究竟是什么? 有了前面对“模块”的深入研究后,元素的含义就比较容易明确了(不同语言稍有不同)。
函数:函数的元素就是“代码”
类/接口:类的元素是“函数、属性”
包:包的元素是“类、接口、全局数据”等
模块:模块的元素是“包、命名空间”
再次:“结合”是什么? 英文的原文是“belong”,有“属于”的意思,翻译成中文“结合”,更加贴近中文的理解。但“结合”本 身这个词容易引起误解。绝大部分人看到“结合”这个单词,
想到的肯定是“你中有我、我中有你”这样 的含义,甚至可能会联想到“美女和帅哥”的结合,抑或“青蛙王子和公主”的结合这种情况。这样的理解本身也并没有错,但比较狭隘。 我们
以类的设计为例:假如一个类里面的函数都是只依赖本类其它函数(当然不能循环调用啦),那内聚性肯定是最好的,因为“结合”得很紧密
判断一个模块(函数、类、包、子系统)“内聚性”的高低,最重要的是关注模块的元素是否都忠于模块的职责,简单来说就是“不要挂羊头卖狗肉”。
【内聚的分类】
以下各种形式的内聚的内聚性越来越高
【巧合内聚(Coincidental cohesion)】
模块内部的元素之所以被划分在同一模块中,仅仅是因为“巧合”!
这是内聚性最差的一种内聚,从名字上也可以看出,模块内的元素没有什么关系,元素本身的职责也各不 相同。基本上可以认为这种内聚形式实际上是没有内聚性。
但在实际应用中,这种内聚也是存在的,最常见的莫过于类似“Utils”这样的包,或者“Miscellaneous” 这样的包。
例如,如下样例中,包package com.oo.cohesion.utils 即我们的“模块”,每个类即我们的“元 素” 。可以看出,HtmlUtil、ImageUtil、StringUtil、UrlUtil都属于同一个包
utils,但从类名称所体 现的职责来看,相互间并无多大关系,也没有明确的凝聚力。
HtmlUtil.java
package com.oo.cohesion.utils; public class HtmlUtil { }
ImageUtil.java
package com.oo.cohesion.utils; public class ImageUtil { }
StringUtil.java
package com.oo.cohesion.utils; public class StringUtil { }
UrlUtil.java
package com.oo.cohesion.utils; public class UrlUtil { }
【逻辑内聚(Logical cohesion)】
模块内部的元素之所以被划分在同一模块中,是因为这些元素逻辑上属于同一个比较宽泛的类别!
模块的元素逻辑上都属于一个比较宽泛的类别,但实际上这些元素的职责可能也是不一样的。 例如将“鼠标”和“键盘”划分为“输入”类,将“打印机“、“显示器”等划分为“输出”类。
相比巧合内聚来说,逻辑内聚的元素之间还是有部分凝聚力的,只是这个凝聚力比较弱,但比巧合内聚来 说要强一些。
例如,如下样例中,包package com.oo.cohesion.input即我们的模块,每个类即我们的元素。可以 看出,Mouse、Keyboard、Microphone都是输入设备的一种,这是它们的
凝聚力所在,但这这些类本身 的职责是完全不同的。
Keyboard.java
package com.oo.cohesion.input; public class Keyboard { }
Mouse.java
package com.oo.cohesion.input; public class Mouse { }
【时序内聚】
模块内部的元素之所以被划分在同一模块中,是因为这些元素必须按照固定的“时间顺序”进行处理。
这种内聚一般在函数这个级别的模块中比较常见,例如“异常处理”操作,一般的异常处理都是“释放资 源(例如打开的文件、连接、申请的内存)、记录日志、通知用户”,那么把这
几个处理封装在一个函数中, 它们之间的内聚就是“时序内聚”。
【过程内聚(Procedural cohesion)】
模块内部的元素之所以被划分在同一模块中,是因为这些元素必须按照固定的“过程顺序”进行处理。
过程内聚和时间内聚比较相似,也是在函数级别的模块中比较常见。例如读写文件的操作,一般都是按照 这样的顺序进行的:判断文件是否存在、判断文件是否有权限、打开文件、
读(或者写)文件,那么把这 些处理封装在一个函数中,它们之间的内聚就是“过程内聚”。
【交互内聚(Communicational cohesion)】
模块内部的元素之所以被划分在同一模块中,是因为这些元素都操作相同的数据。
交互内聚的名称和定义差别较大,我也不知道老外为啥这样命名,其实我觉得还不如叫“共享内聚” :) 虽然我们觉得命名不太正确,但为了交流的时候不至于引起误会,我们还是
使用交互内聚来说明。
交互内聚最常见的就是数据结构的类定义了,例如Java HashMap的get、put、clear等操作。
【顺序内聚(Sequential cohesion)】
模块内部的元素之所以被划分在同一模块中,是因为某些元素的输出是另外元素的输入。
顺序内聚其实就像一条流水线一样,上一个环节的输出是下一个环节的输入。最常见的就是“规则引擎” 一类的处理,一个函数负责读取配置,将配置转换为执行指令;另外一个函数
负责执行这些指令。
例如,如下样例中,包package com.oo.cohesion.ruleengin即我们的模块,每个类即我们的元素。 可以看出,Parser类的输出正好是Process类的输入。
Parser.java
package com.oo.cohesion.ruleengin; /**
* 规则引擎解析类:解析配置文件,生成执行计划ExecutePlan
*
*/
public class Parser { public ExecutePlan parse(String configFile){ ExecutePlan plan = new ExecutePlan(); //...... //TODO:读取配置文件,生成ExecutePlan对象 //...... return plan; } }
Process.java
package com.oo.cohesion.ruleengin; /**
* 规则引擎执行类:执行输入的执行计划 ExecutePlan,返回执行结果
*
*/
public class Process { public int process(ExecutePlan plan){ //TODO:执行规则引擎的指令 return 0; } }
ExecutePlan.java
package com.oo.cohesion.ruleengin; import java.util.ArrayList; /** * 执行计划类:包含规则引擎执行相关的信息 * */ public class ExecutePlan { public ArrayList codes = new ArrayList(); //指令序列 }
【功能内聚(Functional cohesion)】
模块内部的元素之所以被划分在同一模块中,是因为这些元素都是为了完成同一项任务。功能内聚是内聚性最好的一种方式,但在实际操作过程中,对于是否满足功能内聚并不很好
判断,原因在 于“同一项任务”这个定义也是比较模糊的。比如说我们前面各种内聚方式的解读中涉及的样例,理解功能内聚的关键,还是在于到底什么是“同一项任务”。 关于“同一项
任务”这个定义的理解,其实另外一个地方也会涉及到,那就是类的设计原则SRP(下一篇会详细讲解)。
耦合
耦合的含义:耦合(或者称依赖)是程序模块相互之间的依赖程度。
从定义来看,耦合和内聚是相反的:内聚关注模块内部的元素结合程度,耦合关注模块之间的依赖程度。
理解耦合的关键有两点:什么是模块,什么是依赖。
什么是模块? 模块和内聚里面提到的模块一样,耦合中的模块其实也是可大可小。常见的模块有:函数、类、包、子模 块、子系统等
什么是依赖? 依赖这个词很好理解,通俗的讲就是某个模块用到了另外一个模块的一些元素。 例如:A类使用了B类作为参数,A类的函数中使用了B类来完成某些功能等等
【耦合的分类】
【无耦合(No coupling)】
无耦合意味着模块间没有任何关系或者交互。
【消息耦合(Message coupling (low))】
模块间的耦合关系表现在发送和接收消息。
这里的“消息”随着“模块”的不同而不同,例如: 系统/子系统:两个系统交互的协议数据,例如:HTTPPOST数据,Java RPC、Socket数据等; 类/函数:函数的参数即“消息”。
例如:A类的函数调用了B类的某个函数,传入的参数就是消息(此处与数据耦合冲突);
【数据耦合(Data coupling)】
两个模块间通过参数传递基本数据,称为数据耦合。 这里有两点需要特别关注,这也是数据耦合区别于其它耦合类型的关键特征:
1)通过参数传递,而不是通过全局数据、配置文件、共享内存等其它方式
2)传递的是基本数据类型,而不是传递对象,例如Java中传递integer、double、String等类型
如下样例中,Teacher类和Student类的耦合就是数据耦合:
Student.java
package com.oo.coupling.datacoupling; public class Student {
/**
* 根据学号获取学生姓名
* @param studentId 学号,这里就是数据耦合的地方
*
* @return
*/
public String getName(int studentId){
//TODO: 查询数据库,获取学生姓名,这里演示代码省略这部分代码
String name = "Bob";
return name;
} public int getRank(int studentId){
//TODO: 查询数据库,获取学生排名,这里演示代码省略这部分代码
int rank = 1;
return rank;
} }
Teacher.java
package com.oo.coupling.datacoupling; public class Teacher { public void printStudentRank(int studentId){
Student stdu = new Student(); //Teacher类依赖Student类,通过参数传递类型为int基础数据
studentId String name = stdu.getName(studentId); //Teacher类依赖Student类,通过参数传递类型为int基础数据
studentId int rank = stdu.getRank(studentId); System.out.printf(" %s's rank: %d", name, rank); } }
【数据结构耦合( Data-structured coupling)】
两个模块通过传递数据结构的方式传递数据,成为数据结构耦合,又称为标签耦合(Stamp coupling)。 但标签耦合不是很好理解,而且没法和上面的“数据耦合”联系起来,因此
我们一般都用数据结构耦合这个称呼。
数据结构耦合和数据耦合是比较相近的,主要差别在于数据结构耦合中传递的不是基本数据,而是数据结构数据。
另外需要注意的是,数据结构中的成员数据并不需要每一个都用到,可以只用到一部分。 如下样例中,Teacher类和Student类的耦合就是数据结构耦合,且只用到了Student.id
这个成员数据:
StudentInfo.java
package com.oo.coupling.dscoupling; public class StudentInfo {
public String name = "";
public int id = 0;
public int rank = 0;
}
Student.java
package com.oo.coupling.dscoupling; public class Student { /** * 获取学生的姓名 * @param info 学生的信息,这里就是data-structure coupling的地方 * @return */ public String getName(StudentInfo info){ //注意:只用到了StudentInfo类的id,其它的数据成员都没有用 return getNameById(info.id); } /** * 获取学生排名 * @param info 学生的信息,这里就是data-structure coupling的地方 * @return */ public int getRank(StudentInfo info){ //注意:只用到了StudentInfo类的id,其它的数据成员都没有用 return getRankById(info.id); } private String getNameById(int id){ String name = ""; //TODO: 查询数据库,获取学生姓名,这里演示代码省略这部分代码 return name; } private int getRankById(int id){ int rank = 0; //TODO: 查询数据库,获取学生排名,这里演示代码省略这部分代码 return rank; } }
Teacher.java
package com.oo.coupling.dscoupling; public class Teacher { public void printStudentRank(int studentId){ StudentInfo info = new StudentInfo(); info.id = studentId; Student stdu = new Student(); //Teacher类依赖Student类,通过参数传递StudentInfo数据 String name = stdu.getName(info); //Teacher类依赖Student类,通过参数传递StudentInfo数据 int rank = stdu.getRank(info); System.out.printf(" %s's rank: %d", name, rank); } }
【控制耦合(Control coupling) 】
当一个模块可以通过某种方式来控制另外一个模块的行为时,称为控制耦合。
最常见的控制方式是通过传入一个控制参数来控制函数的处理流程或者输出,例如常见的工厂类。
package com.oo.coupling.controlcoupling; enum ToyType{Bear, Car, Gun, Bus}; public class ToyFactory { public String getToy(ToyType type){ switch(type){ case Bear: return "bear"; case Car: return "car"; case Gun: return "gun"; case Bus: return "bus"; default: return "Unknown toy"; } } }
【外部耦合(External coupling)】
当两个模块依赖相同的外部数据格式、通讯协议、设备接口时,称为外部耦合。
理解外部耦合的关键在于:为什么叫“外部”? 这里的“外部”当然是与“内部”相对应的,比如前面我们提到的各种耦合方式,可以认为都是“内部耦 合”,因为这些耦合都是由模块内部
来实现的。但在外部耦合的场景下,两个模块内部都保持原样,但通 过一个外部模块(或者设备、程序、协议等)进行交互。
在软件系统,外部依赖最典型的莫过于各种“proxy”模块或者子系统了。比如说A系统输出XML格式, B系统只能接收JSON格式的数据,为了能够让两个系统连接起来,需要开发一
个转换程序,完成格式转 换。
【全局耦合(Globaling coupling)】
当两个模块共享相同的全局数据,称为全局耦合,又叫普通耦合(Common coupling), 不过普通耦合这个名称太容易让人误解了,还是全局耦合能够让人顾名思义。
全局耦合是一种比较常见的耦合方式,尤其是在C/C++的程序中,多多少少都会有一些全局数据。
【内容耦合(Content coupling)】
当一个模块依赖另外一个模块的内部内容(主要是数据成员)时,称为内容耦合。内容耦合是最差的一中 耦合方式,因此有另外一个很形象的名称:病态耦合(Pathological coupling)。
高内聚低耦合
高内聚低耦合,可以说是每个程序猿,甚至是编过程序,或者仅仅只是在大学里面学过计算机,都知道的一个简单的设计原则。
虽然如此流行和人所众知,但其实真正理解的人并不多,很多时候都是人云亦云。 要想真正理解“高内聚低耦合”,需要回答两个问题:
1)为什么要高内聚低耦合?
2)高内聚低耦合是否意味内聚越高越好,耦合越低越好?
第一个问题:为什么要高内聚低耦合?
经典的回答是:降低复杂性。 确实很经典,当然,其实也是废话!我相信大部分人看了后还是不懂,什么叫复杂性呢?
要回答这个问题,其实可以采用逆向思维,即:如果我们不做到这点,将会怎样? 首先来看内聚,试想一下,假如我们是低内聚,情况将会如何?
前面我们在阐述内聚的时候提到内聚的关键在于“元素的凝聚力”,如果内聚性低,则说明凝聚力低;
对 于一个团队来说,如果凝聚力低,则一个明显的问题是“不稳定”;
对于一个模块来说,内聚性低的问题 也是一样的“不稳定”。
具体来说就是如果一个模块内聚性较低,则这个模块很容易变化。一旦变化,设 计、编码、测试、编译、部署的工作量就上来了,而一旦一个模块变化,与之相关的模块都需要跟着
改变。
举一个简单的例子,假设有这样一个设计不好的类:Person,其同时具有“学生”、 “运动员”、“演员”3 个职责,有另外3个类“老师”、“教练”、“导演”依赖这个类。
Person.java
package com.oo.cohesion.low; /** * “人”的类设计 * */ public class Person { /** * 学生的职责:学习 */ public void study() { //TODO: student's responsibility } /** * 运动员的职责:运动 */ public void play(){ //TODO: sportsman's responsibility } /** * 演员的职责:扮演 */ public void act(){ //TODO: actor's responsibity } }
Teacher.java
package com.oo.cohesion.low; /** * “老师”的类设计 * */ public class Teacher { public void teach(Person student){ student.study(); //依赖Person类的“学生”相关的职责 } }
Coach.java
package com.oo.cohesion.low; /** * “教练”的类设计 * */ public class Coach { public void train(Person trainee){ trainee.play(); //依赖Person类的“运动员”职责 } }
Director.java
package com.oo.cohesion.low; /** * “导演”的类设计 * */ public class Director { public void direct(Person actor){ actor.act(); //依赖Person类“演员”的相关职责 } }
在上面的样例中,Person类就是一个典型的“低内聚”的类,很容易发生改变。比如说,现在老师要求学生也要考试,则Person类需要新增一个方法:test,如下:
package com.oo.cohesion.low; /** * “人”的类设计 * */ public class Person { /** * 学生的职责:学习 */ public void study() { //TODO: student's responsibility } /** * 学生的职责:考试 */ public void test(){ //TODO: student's responsibility } /** * 运动员的职责:运动 */ public void play(){ //TODO: sportsman's responsibility } /** * 演员的职责:扮演 */ public void act(){ //TODO: actor's responsibity } }
由于Coach和Director类都依赖于Person类,Person类改变后,虽然这个改动和Coach、Director都没 有关系,但Coach和Director类都需要重新编译测试部署(即使是PHP这样
的脚本语言,至少也要测试)。
同样,Coach和Director也都可能增加其它对Person的要求,这样Person类就需要同时兼顾3个类的业 务要求,且任何一个变化,Teacher、Coach、Director都需要重新编译测试
部署。
对于耦合,我们采用同样的方式进行分析,即:如果高耦合,将会怎样? 高耦合的情况下,模块依赖了大量的其它模块,这样任何一个其它依赖的模块变化,模块本身都需要受到
影响。所以,高耦合的问题其实也是“不稳定”,当然,这个不稳定和低内聚不完全一样。对于高耦合的 模块,可能本身并不需要修改,但每次其它模块修改,当前模块都要编译、测
试、部署,工作量同样不小。
无论是“低内聚”,还是“高耦合”,其本质都是“不稳定”,不稳定就会带来工作量,带来风险, 这当然不是我们希望看到的,所以我们应该做到“高内聚低耦合”。
回答完第一个问题后,我们来看第二个问题:
高内聚低耦合是否意味着内聚越高越好,耦合越低越好? 按照我们前面的解释,内聚越高,一个类越稳定;耦合越低,一个类也很稳定,所以当然是内聚越高越好, 耦合越低越好
了。 但其实稍有经验的同学都会知道这个结论是错误的,并不是内聚越高越好,耦合越低越好,真正好的设计是在高内聚和低耦合间进行平衡,也就是说高内聚和低耦合是冲突的。
对于内聚来说,最强的内聚莫过于一个类只写一个函数,这样内聚性绝对是最高的。但这会带来一个明显的问题:类的数量急剧增多,这样就导致了其它类的耦合特别多,于是整个
设计就变成了“高内聚高耦合” 了。由于高耦合,整个系统变动同样非常频繁。
同理,对于耦合来说,最弱的耦合是一个类将所有的函数都包含了,这样类完全不依赖其它类,耦合性是最低的。但这样会带来一个明显的问题:内聚性很低,于是整个设计就变成
了“低耦合低内聚”了。由于 低内聚,整个类的变动同样非常频繁。 对于“低耦合低内聚”来说,还有另外一个明显的问题:几乎无法被其它类重用。原因很简单,类本身太庞大了,
要么实现很复杂,要么数据很大,其它类无法明确该如何重用这个类。
所以,内聚和耦合的两个属性,排列组合一下,只有“高内聚低耦合”才是最优的设计。 因此,在实践中我们需要牢牢记住需要在高内聚和低耦合间进行平衡,而不能走极端。