设计模式之禅(一) —— 六大设计原则
1.1 单一职责原则
单一职责原则:Singel Responsibility Principle,SRP
单一职责原则的定义:应该有且仅有一个原因引起类的变更。
书中提到一个例子:对电话的抽象。
电话类图.png
继续细化,向下拆分,向上抽象
电话通话时可以抽象出4个过程:拨号、通话、回应、挂机。
在举着个例子的时候,作者提到大部分人可能都会说这个没什么问题,动作定义比较清晰。其实如果更深层的了解电话的结构,应该可以对电话类进行一个更完整的抽象。
比如:电话的通话过程,是否是自始至终都是这四个阶段,在发展过程中会不会增加或者减少。如果以阶段为维度来进行抽象,是否会出现经常变更的情况。如果换一个维度,如:从更底层职责的角度来进行抽象,会抽象出“协议管理”和“数据传输” 两个角度,无论中间阶段发生什么变化,这两个职责,是一个电话必须拥有的。
所以我们在对业务模型进行抽象定义时,也需要尽量的事无巨细的了解业务的模型,然后再做分析。
回归原则定义
原则定义:应该有且仅有一个原因引起类的变更。
上面的接口并不是“只有一个原因引起变化”的。IPhone 不只是只有一个职责,它包含两个职责:
- 协议管理:dial() 和hangup() 方法负责拨号接通和挂机
- 数据传送:chat() 实现数据传送,把话转换成信号在双方之间传递
这两个职责都会引起这个接口或者实现类的变化:
日常习惯
比如一个用户信息接口:
image.png
这个接口有一个“修改用户信息”的方法。这个方法太过于笼统,一个方法承担了多个职责,这样的接口虽然对上层来说只提供了一个接口,但是它的职责并不是单一的。这样做需要在接口文档上做额外的注释,说明这个修改接口都可以修改哪些信息,操作参数什么情况需要传什么样的值。在《代码整洁之道》中建议过:提供好的注释,不如将代码写的别人一看就明白,无需注释。这里也一样,一个好的接口的定义,不需要文档中做长篇大论的调用说明。与其做一堆说明,不如在定义接口的时候,定义的清晰易懂。
image.png
这样定义会对上层更友好一点,将修改用户信息拆解为多个方法,每个方法只负责一件事,别人一看就知道,那个方法改的是什么,这个接口每个方法都能修改什么,清晰完整。
实际开发
虽然一直说要按照SRP的原则去进行设计,但毕竟理论是理论,实践是实践。在实际开发过程中,有很多因素导致最终无法达到按照SRP原则的最终效果,比如开会讨论业务模型中职责的划分;又比如deadline比较紧急,没有足够的时间进行讨论和设计。一个行业的驱动最终还是业务,代码只是实现业务的工具,是轮子。一个功能,最低要求就是先能跑起来,完成功能。只是在一开始实现的时候,尽可能的去往 SRP 上靠,读者的建议是:
对于单一职责原则, 我的建议是接口一定要做到单一职责, 类的设计尽量做到只有一个原因引起变化。
1.2 里氏替换原则
里氏替换原则原则是在继承方面上的一个要求。它是针对继承的弊端而出现的一个原则。
继承的优点:
- 共享代码,提供代码的重用性
- 提高代码的扩展性
- 提高产品或者项目的开放性
继承的缺点:
- 继承是侵入性的,只要继承,就必须拥有父类的所有属性和方法
- 增强了耦合性。当父类的内容修改时,需要考虑子类的修改,可能会造成大段代码需要重构
定义
Liskov Substitution Principle, LSP
所有引用父类的地方必须能透明的使用其子类的对象。
通俗的讲,只要父类出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或者异常,使用者可能根本不需要知道是父类还是子类。但是反过来就不行,有子类的地方,无法用父类进行替换。
LSP 对良好的继承定义了一个规范,这个规范包含四层含义:
1. 子类必须完全实现父类的方法
书中的例子是“士兵和枪”的例子。当定义“ToyGun”时,由于 ToyGun 无法杀人,程序无法正常运行。原因是 ToyGun 无法完整的实现 shoot 功能。
在具体应用场景中就要考虑下面这个问题了: 子类是否能够完整地实现父类的业务, 否则就会出现像上面的拿枪杀敌人时却发现是把玩具枪的笑话
如果子类不能完整地实现父类的方法, 或者父类的某些方法在子类中已经发
生“畸变”, 则建议断开父子继承关系, 采用依赖、 聚集、 组合等关系代替承。
注意:在类中调用其他类时务必要使用父类或接口, 如果不能使用父类或接口, 则说明类的设计已经违背了LSP原则。
2. 子类可以有自己的个性
书中给出的例子是“狙击手使用狙击枪杀人”,表达的意思是:如果实例类型为子类,则子类无法强转成父类类型进行调用。
3. 覆盖或实现父类的方法是输入参数可以被放大
书中举出了一个例子,这个例子会导致“子类在没有覆写父类的方法的前提下,子类方法被执行了”。
这个例子中子类对于方法的定义就有问题:
public class Father {
public Collection doSomething(Map map) {
System.out.println("父类被执行...");
return map.values();
}
}
public class Son extends Father {
//缩小输入参数范围
public Collection doSomething(HashMap map) {
System.out.println("子类被执行...");
return map.values();
}
}
public class Client {
public static void invoker() {
//有父类的地方就有子类
Father f = new Father();
HashMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
子类的doSomething方法,并不是覆盖,而是对从父类继承过来的doSomething方法的重载。在 Client 执行时,如果使用子类去替换,实际执行的将会是子类的 doSomething 方法。从而导致了“子类在没有覆写父类的方法的前提下,子类方法被执行了”。
注意,这里是有一个前提:“子类在没有覆写父类的方法的前提下”
如果是子类在复写了父类的方法下,使用子类去替换父类,调用的实际是子类的方法,这样是ok的。但是上面却是没有复写到父类的方法。没有复写,而且输入参数的范围比父类的方法大,就会出现问题。
正确的例子是:
public class Father {
public Collection doSomething(HashMap map) {
System.out.println("父类被执行...");
return map.values();
}
}
public class Son extends Father {
//缩小输入参数范围
public Collection doSomething(Map map) {
System.out.println("子类被执行...");
return map.values();
}
}
public class Client {
public static void invoker() {
//有父类的地方就有子类
Father f = new Father();
HashMap map = new HashMap();
f.doSomething(map);
}
public static void main(String[] args) {
invoker();
}
}
这样执行,子类也没有复写到父类的方法,但是在Client中用子类去替换父类,实际执行的还是父类方法。
最根本的原因,就是 子类在定义同名方法时,输入参数的范围比父类更大。
4. 覆盖或实现父类的方法是输出结果可以被缩小
书中分了两种情况:
- 子类覆盖:返回值范围要小于等于父类的方法
- 方法重载:这个无所谓,因为不会调用到该方法
这个也比较好理解,目的是:
让上层在调用目标方法后,在使用方法的返回值时不会出现不存在方法的现象。如果返回值是父类,而实际返回值类型是子类,这样没什么问题;如果反过来,就可能会出现问题,上层在调用返回值中的方法,有可能是子类独有的方法,而返回值类型是父类,会出现调用失败的现象。
总结:
遵守了这四个规范,也就相当于遵守了 LSP 原则
1.3 依赖倒置原则
依赖倒置原则的表现:
- 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;
- 接口或抽象类不依赖于实现类;
- 实现类依赖接口或抽象类。
书中用“司机开车”的例子来说明。
image.pngpublic class Benz {
//汽车肯定会跑
public void run() {
System.out.println("奔驰汽车开始运行...");
}
}
public class Client {
public static void main (String[] args) {
Driver zhangSan = new Driver();
Benz benz = new Benz();
//张三开奔驰车
zhangSan.drive(benz);
}
}
Driver 和 Benz 类都是实现类,Driver 强依赖 Benz 类。
如果将来需要司机去开 BMW,程序将无法完成。此处进行结构优化,对实现类进行抽象,解除强依赖关系。
image.png
public interface IDriver {
//是司机就应该会驾驶汽车
public void drive(ICar car);
}
public class Driver implements IDriver{
//司机的主要职责就是驾驶汽车
public void drive(ICar car){
car.run();
}
}
public interface ICar {
//是汽车就应该能跑
public void run();
}
public class Benz implements ICar{
//汽车肯定会跑
public void run(){
System.out.println("奔驰汽车开始运行...");
}
}
public class BMW implements ICar{
//宝马车当然也可以开动了
public void run(){
System.out.println("宝马汽车开始运行...");
}
}
public class Client {
public static void main (String[] args) {
IDriver zhangSan = new Driver();
ICar benz = new Benz();
//张三开奔驰车
zhangSan.drive(benz);
//IDriver zhangSan = new Driver();
//ICar bmw = new BMW();
//张三开奔驰车
//zhangSan.drive(bmw);
}
}
总结一下依赖倒置的好处:
- 在新增加低层模块时, 只修改了业务场景类, 也就是高层模块, 对其他低层模块如Driver类不需要做任何修改, 业务就可以运行, 把“变更”引起的风险扩散降到最低
- 两个类之间有依赖关系, 只要制定出两者之间的接口( 或抽象类) 就可以独立开发了, 而且项目之间的单元测试也可以独立地运行
最佳实践
我们怎么在项目中使用这个规则呢:
- 每个类尽量都有接口或抽象类, 或者抽象类和接口两者都具备
- 变量的表面类型尽量是接口或者是抽象类
- 任何类都不应该从具体类派生
- 尽量不要覆写基类的方法
- 结合里氏替换原则使用
1.4 接口隔离原则
定义
接口:
- 类接口
- 实例接口
隔离:
- 客户端不应该依赖它不需要的接口
- 类之间的依赖关系应该建立在最小的接口之上
接口隔离原则概括为一句话:
建立单一接口,接口尽量细化,同时接口中方法尽量少。
例子
书中给出的例子是“美女类”的例子
image.png
接口定义了一个美女:
public interface IPettyGirl {
//要有姣好的面孔
public void goodLooking();
//要有好身材
public void niceFigure();
//要有气质
public void greatTemperament();
}
接口存在的问题是:审美会随着时间的改变而改变。
接口IPettyGirl的设计是有缺陷的, 过于庞大了, 容纳了一些可变的因素。而我们却把这些特质都封装了起来, 放到了一个接口中, 封装过度了。
把原IPettyGirl接口拆分为两个接口, 一种是外形美的美女IGoodBodyGirl, 这类美女的特点就是脸蛋和身材极棒, 超一流, 但是没有审美素质, 比如随地吐痰, 文化程度比较低; 另外一种是气质美的美女IGreatTemperamentGirl, 谈吐和修养都非常高。把一个比较臃肿的接口拆分成了两个专门的接口, 灵活性提高了, 可维护性也增加了, 不管以后是要外形美的美女还是气质美的美女都可以轻松地通过PettyGirl定义。
修改后的星探寻找美女类图
/** 外表型美女 **/
public interface IGoodBodyGirl {
//要有姣好的面孔
public void goodLooking();
//要有好身材
public void niceFigure();
}
/** 气质型美女 **/
public interface IGreatTemperamentGirl {
//要有气质
public void greatTemperament();
}
/** 最标准的美女,拥有所有优点 **/
public class PettyGirl implements IGoodBodyGirl,IGreatTemperamentGirl {
private String name;
//美女都有名字
public PettyGirl(String _name){
this.name=_name;
}
//脸蛋漂亮
public void goodLooking() {
System.out.println(this.name + "---脸蛋很漂亮!");
}
//气质要好
public void greatTemperament () {
System.out.println(this.name + "---气质非常好!");
}
//身材要好
public void niceFigure () {
System.out.println(this.name + "---身材非常棒!");
}
}
让客户端去依赖两个专用的接口,比去依赖一个综合的接口要更加灵活。
接口隔离原则的目的
接口隔离原则是对接口进行规范的约束
接口要尽量的小
这一点上面的例子已经体现了,如果一个接口已经存在了臃肿现象,它会影响一个正常的代码结构,一些不需要实现的方法强制去实现。要对接口进行细化。
接口要高内聚
高内聚 就是提高接口、 类、 模块的处理能力, 减少对外的交互。
具体到接口隔离原则就是, 要求在接口中尽量少公布public方法, 接口是对外的承诺, 承诺越少对系统的开发越有利, 变更的风险也就越少, 同时也有利于降低成本
定制服务
只提供访问者需要的方法
接口设计是有限度的:
- 对接口的拆分也需要有度,根据接口隔离原则拆分接口时,首先必须满足单一职责原则
- 接口的设计粒度越小, 系统越灵活, 这是不争的事实。 但是, 灵活的同时也带来了结构的复杂化, 开发难度增加, 可维护性降低。
如何衡量原子接口或原子类的划分:
- 一个接口只服务于一个子模块或业务逻辑
- 通过业务逻辑压缩接口中的public方法, 接口时常去回顾, 尽量让接口达到“满身筋骨肉”, 而不是“肥嘟嘟”的一大堆方法
- 已经被污染了的接口, 尽量去修改, 若变更的风险较大, 则采用适配器模式进行转化处理
- 了解环境, 拒绝盲从。 每个项目或产品都有特定的环境因素,深入了解业务逻辑
与单一职责原则的区别
接口隔离原则与单一职责的审视角度是不相同的, 单一职责要求的是类和接口职责单一, 注重的是职责, 这是业务逻辑上的划分, 而接口隔离原则要求接口的方法尽量少。
1.5 迪米特法则
定义
原则
对外公开的范围
一个类公开的public属性或方法越多, 修改时涉及的面也就越大, 变更引起的风险扩散也就越大,因此, 在设计时需要反复衡量:
- 是否还可以再减少 public方法和属性
- 是否可以修改为private、 package-private(包类型, 在类、 方法、 变量前不加访问权限, 则默认为包类型) 、 protected等访问权限
- 是否可以加上final关键字等
成员的归属
如果一个方法或者属性放在本类中, 既不增加类间关系, 也对本类不产生负面影响, 那就放置在本类中
最佳实践
迪米特法则的核心观念就是类间解耦, 弱耦合,既做到让结构清晰, 又做到高内聚低耦合。
1.6 开闭原则
定义
对扩展开放, 对修改关闭, 其含义是说一个软件实体应该通过扩展来实现变化, 而不是通过修改已有的代码来实现变化
例子
书中用 “书店买书” 的例子来进行说明
image.png
/* 书籍接口 */
public interface IBook {
//书籍有名称
public String getName();
//书籍有售价
public int getPrice();
//书籍有作者
public String getAuthor();
}
/* 小说类 */
public class NovelBook implements IBook {
//书籍名称
private String name;
//书籍的价格
private int price;
//书籍的作者
private String author;
//通过构造函数传递书籍数据
public NovelBook(String _name,int _price,String _author){
this.name = _name;
this.price = _price;
this.author = _author;
}
//作者是谁
public String getAuthor() {
return this.author;
}
//书籍叫什么名字
public String getName() {
return this.name;
}
//获得书籍的价格
public int getPrice() {
return this.price;
}
}
/* 模拟业务流程类 */
public class BookStore {
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//static静态模块初始化数据, 实际项目中一般是由持久层完成
static{
bookList.add(new NovelBook("天龙八部",3200,"金庸"));
bookList.add(new NovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new NovelBook("悲惨世界",3500,"雨果"));
bookList.add(new NovelBook("金瓶梅",4300,"兰陵笑笑生"));
}
//模拟书店买书
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("-----------书店卖出去的书籍记录如下: -----------");
for(IBook book:bookList){
System.out.println("书籍名称: " + book.getName()+"\t书籍作者: "
book.getAuthor()+"\t书籍价格: "+ formatter.format (book.getPrice()/100.0)+"元");
}
}
}
此时需求增加,需要对打折的书籍的价格进行特殊调整。
- 打折行为只会出现在打折书籍中,并不存在于所有书籍。所以不能改动 IBook 接口;
- 例如采购书籍人员也是要看价格的, 由于该方法已经实现了打折处理价格, 因此采购人员看到的也是打折后的价格, 会因信息不对称而出现决策失误的情况。 因此, 该方案也不是一个最优的方案。(说来惭愧,书上的这一段我没咋明白作者想表达的意思...)
此时需要构造一个新的类作为 NovelBook 的子类
image.png
public class OffNovelBook extends NovelBook {
public OffNovelBook(String _name,int _price,String _author){
super(_name,_price,_author);
}
//覆写销售价格
@Override
public int getPrice () {
//原价
int selfPrice = super.getPrice();
int offPrice = 0;
if (selfPrice > 4000) { //原价大于40元, 则打9折
offPrice = selfPrice * 90 /100;
} else {
offPrice = selfPrice * 80 /100;
}
return offPrice;
}
}
/* 业务流程类 */
public class BookStore {
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//static静态模块初始化数据, 实际项目中一般是由持久层完成
static{
bookList.add(new OffNovelBook("天龙八部",3200,"金庸"));
bookList.add(new OffNovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new OffNovelBook("悲惨世界",3500,"雨果"));
bookList.add(new OffNovelBook("金瓶梅",4300,"兰陵笑笑生"));
}
//模拟书店买书
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("-----------书店卖出去的书籍记录如下: -----------");
for(IBook book:bookList){
System.out.println("书籍名称: " + book.getName()+"\t书籍作者: "
}
}
}
在定义了新的子类之后,输入的图书列表对象可能存在正常的 NovelBook,也会有 OffNovelBook,无论存在什么,业务主流程还是无需改动的。关键点在于
在 BookStore 类中,也可以将 bookList 看做是一种外界输入,参数的类型为接口类型,main 方法中也是使用的是接口类型对象进行操作。
开闭原则的意义
- 主业务流程不会改动的太频繁
- 单测用例不需要频繁改动
- 提高复用性
- 提高可维护性