前提:模块化设计
为什么需要模块化设计?
理论上可以只使用一个函数完成全部功能,但是太过复杂,超过人的掌控极限。因此必须要划分开,对问题进行分解。(面向过程->面向对象)
模块化设计遇到的两个问题
- 如何划分模块?
- 模块之间如何连接?
软件设计
为何要做软件设计?
软件设计是为了让软件在长期范围内容易应对变化。即:尽量降低变化对软件的影响。否则维护成本太大。
HOW?
高内聚、低耦合原则
- 内聚:一个单位内部事物之间关联的紧密程度。
- 高内聚:将同类事物放在一起,只做一件事(单一职责)。
- 耦合:不同单位之间关联的紧密程度。
- 低耦合:不同单位之间尽量不要互相影响。
什么是好的模块化设计?
模块本身高内聚,不同模块间低耦合。
正交
什么是正交?
- 二维空间:两条直线正交、正交分解
- 多维空间:向量正交
- 特点:正交的两个单位互不影响。
模块化设计 & 正交设计
- 模块之间互不影响(正交)
- 模块的划分实际上是功能职责的划分(如何划分模块)
- 每个模块保证自己功能的实现,不关心其他模块如何实现
- 模块之间联系的桥梁是接口(API),接口是模块之间的分界线(模块之间如何连接)
通俗的说,只要接口不变,一个模块对另一个模块的影响就是0,无论提供接口的模块内部实现如何变动,对调用接口的模块来说都是透明的。
举例:
例1:
1. 模块A负责排序,提供接口sort
2. 模块B在功能实现逻辑中需要得到排好序的数据,调用了模块A的sort接口,实现了自身的功能
- 模块A不关心谁来调用sort接口,不论是模块B还是模块C。外部的变动对模块自身无影响。
- 模块B只需要知道调用模块A的sort接口可以对数据进行排序,而不关心模块A内部使用了什么排序算法。即使有一天模块A的排序算法从冒泡排序变成了归并排序,模块B的实现代码也不需要有任何改动。
例2:设计一个结账计算器
public Class Goods {
private double price;
public double getPrice() {return price;}
public void setPrice(double price) {this.price = price;}
}
- 简单粗暴第一版:
public double checkout (List<Goods> shoppingGoods) {
double sum = 0d;
for (Goods good : shoppingGoods) {
sum += good.getPrice();
}
return sum;
}
- 打五折第二版:
public double checkout (List<Goods> shoppingGoods) {
double sum = 0d;
for (Goods good : shoppingGoods) {
sum += good.getPrice();
}
return sum * 0.5;
}
- 打九折第三版:
public double checkout (List<Goods> shoppingGoods) {
double sum = 0d;
for (Goods good : shoppingGoods) {
sum += good.getPrice();
}
return sum * 0.9;
}
- 灵活折扣第四版:
public Class Goods {
private double price;
private double discount;
//getter setter...
}
public double checkout (List<Goods> shoppingGoods) {
double sum = 0d;
for (Goods good : shoppingGoods) {
sum += good.getPrice() * good.getDiscount();
}
return sum;
}
- 忍无可忍终极版(?):
public interface Saleable {
double checkout();
}
public abstract Class Goods implements Saleable {
private double price;
//getter setter...
@Override
public double checkout() {
...
}
}
public Class GoodsA {
...
}
public double checkout (List<Goods> shoppingGoods) {
double sum = 0d;
for (Goods good : shoppingGoods) {
sum += good.checkout();
}
return sum;
}
设计的问题:不应当把物品自己的算账细节与购物车的算账细节放到一起,模块之间耦合度太高。
四个策略
策略一:消除重复
重复的代码意味着相同的事物没有被放到一起,即低内聚。
举例:有多个地方都需要对数据排序,应当写一个工具类进行数据排序,而不是在每个地方自己写重复的排序代码。
有重复的代码意味着违反单一职责原则,他们做了不止一件事。
所以,每当发现自己在写重复的代码时,应当将重复的部分剥离出来。
策略二:分离不同的变化方向
如果总是因为一些类似的原因修改模块内同一部分代码,而其他部分的代码不变,则应该将变化的方向抽离出来。
举例:一件商品有时候打五折,有时候打九折,有时候不打折,那么就应当将折扣抽离出来,为商品增加“折扣”这个属性。
策略三:缩小依赖范围
两个模块之间依靠API进行关联,因此API决定了模块间的耦合度。
API设计的要点:
1. API应包含尽量少的知识,因为任何一项知识的变化都会导致双方的变化;
2. API也应该高内聚,而不应该强迫API的调用者依赖它不需要的东西。
举例:
public interface Saleable {
double checkout();
}
public abstract Class Goods implements Saleable {
private double price;
//getter setter...
@Override
public double checkout() {
...
}
}
public Class GoodsA {
...
}
public double checkout (List<Goods> shoppingGoods) {
double sum = 0d;
for (Goods good : shoppingGoods) {
sum += good.checkout();
}
return sum;
}
对于checkout方法来说,声明了传入的参数类型是List<Goods>
,但是在方法内部只调用了good.checkout()
方法,而这个方法实际上是Saleable
接口所声明的。因此,这个函数的声明应当是:
public double checkout (List<Saleable> saleableList) {
double sum = 0d;
for (Saleable saleable : saleableList) {
sum += saleable.checkout();
}
return sum;
}
之前的设计中,API包含了多余的知识,要求参数必须是List<Goods>
,然而在实现细节中没有用到Goods类的任何东西,它强迫这个API的调用者必须传入List<Goods>
类型的参数。然而实际上,如果有另一个实现了Saleable接口但没有继承Goods类的类,那么这个类是无法使用这个API的,即API的内聚程度不够高,它强迫API的调用者依赖它不需要的东西。
策略四:向着稳定的方向依赖
两个模块之间如果有API的调用关系,那么这两个模块必然有一定程度的耦合。因此我们只能尽量降低耦合度而无法在存在API调用关系的情况下消除耦合。为了提高依赖方(API调用者)的稳定性,我们应当努力使API稳定。
如何使API稳定?在设计API时应当站在API调用者而不是API提供者的角度,思考API的调用者需要什么,不关心什么,在这个原则上进行封装/信息隐藏。
总结
- 模块化的正交设计:高内聚、低耦合。
- 四个策略:前两个策略解决如何划分模块的问题,后两个策略解决模块之间如何连接的问题。