我们在实际的项目中使用各个原则时需要审时度势,不要抓住一个原则不放,每个原则的优点都是有限度的,并不是放之四海而皆准的真理,所以别为了遵循一个原则而放弃了一个项目的终极目标:投产上线和盈利。
—— 《设计模式之禅》
虽然目前在实际开发过程中并没有"机会"使用太多的设计模式,但是“听闻”许多优秀的开源项目将设计模式用得神乎其神而自己算是看得云里雾里,窃以为要摆脱重复性低层次的编程就必须对设计模式有足够的理解,最近在读秦小波的《设计模式之禅》,受益良多,拾人牙慧,笔记于此。
设计模式有6个原则,本文介绍前三个原则,分别是 单一职责原则,里氏替换原则,依赖倒置原则。
1. 单一职责原则(Single Responsibility Principle)
含义:There should never be more than one reason for a class to change;即应该有且仅有一个原因引起类的变更
单一职责原则最难划分的就是职责,一个职责一个接口,但问题是”职责“没有一个量化的标准,一个类到底要负责哪些职责?这些职责该怎么细化?细化后是否都要有一个接口或类?这些都需要从实际的项目去考虑 —— 收益成本比率
例子
- 这个类图,将用户信息和用户信息糅合到了一个接口里,“有两种原因会引起类的变化”,明显不符合单一职责原则
将用户信息抽取成一个BO(Business Object 业务对象),将行为抽取成一个Biz(Business Logic 业务逻辑),一个接口负责用户信息的收集和反馈,一个接口负责用户行为,两个接口负责不同的职责,独立变化。
对于接口,我们在设计的时候一定要做到单一,但是对于实现类就需要多方面考虑了。生搬硬套单一职责原则会引起类的剧增,给维护带来非常多的麻烦,而且过分细分类的职责也会认为地增加系统的复杂性。本来一个类可以实现的行为硬要拆分成两个类,然后再使用聚合或者组合的方式耦合在一起,人为制造了系统的复杂性。所以原则是死的,人是活的,这句话很有道理。
一般实践是:接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化
2. 里式替换原则(Liskov Substitution Principle)
-
里式替换原则有两种说法
- If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.(如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型)
- Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it .(所有引用基类的地方必须能够透明地使用其子类的对象。)
通俗的讲就是,父类能够出现的地方子类也能够出现,而且子类替换掉父类不会引起任何错误或者异常,调用者根本不关心是父类还是子类。
在C++ 或者Java这类高级语言来说,则形如
Father* o1 = new Son1();
o1 = new Son2();
其中Son1 和 Son2 是 Father类的子类,Sonx类能够无痕地传入到表面类型为Father类的指针中。
-
这个原则包含了四层含义
-
- 子类必须完全实现父类的方法
- 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生”畸变“,则建议断开父子继承关系,采用依赖、聚合、组合等关系代替继承
-
- 子类有自己的个性
- 表面父类类型可以传入子类,但是表面子类类型则不可以传入父类;我看到一个例子很好理解,苹果是水果,香蕉是水果,有水果的地方,可以传入苹果、香蕉;但是有香蕉的地方,不能传入水果,因为水果不一定是香蕉
- 其实也就是说,向下转型(downcast)是不安全的
-
- 覆盖或者实现父类的方法时输入参数可以被放大
- 先了解一下前置条件和后置条件
- 前置条件:输入必须满足的条件
- 后置条件:输出必须满足的条件
- 覆写(override)要求方法名和参数都完全相同,而重载(overload)则只要求方法名相同,当你在子类中“覆写”父类的一个方法时,如果你“覆写”时将参数的类型范围缩小了(即使你的参数个数还是跟父类相同,如父类中参数类型是map,而子类中参数类型是hashmap),这时候其实变成了重载,因此放大了参数范围
-
- 覆写或实现父类的方法时输出结果可以被缩小
- 即子类覆写之后,后置条件必须比父类要窄(父类方法返回S,子类方法返回T,T是S的子类)
-
-
里式替换原则的目的是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。
- —— 在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑
3. 依赖倒置原则(Dependence Inversion Principle)
High level modules should not depend upon low level modules. Both should depend upon abstractions. Abstractions should not depend upon details. Details should depend upon abstractions.
-
有三层含义
- 高层模块不应该依赖底层模块,两者都应该依赖其抽象;
- 抽象不应该依赖细节;
- 细节应该依赖抽象;
-
什么是高层模块、低层模块?
- 每个逻辑的实现都是由原子逻辑组成,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块【当然,高和低是相对的】
-
什么是抽象、细节?
- 在形如C++、Java这类高级语言中,抽象就是指接口或者抽象类,两者都是不能直接被实例化的
- 细节就是实现类,实现接口或者继承抽象类而产生的类就是细节
-
在高级语言的表现就是
- 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或者抽象类产生的
- 接口或者抽象类不依赖实现类
- 实现类依赖接口或抽象类
说白了就是 面向接口编程,这也是OOD(Object-Oriented Design 面向对象设计)的精髓
举个例子
- 实现类司机类和奔驰类高度耦合,如果想新增宝马类汽车,需要修改司机类,十分不稳定
模块(司机类和各种车类)之间的依赖通过抽象(接口)发生,当需要添加另一种车类型时(变更发生),只需要实现接口ICar即可,司机类不能修改任何东西,比较稳定。
一个变量有两种类型:表面类型和实际类型;表面类型是在定义的时候声明的类型,实际类型是对象的类型
-
来看看一个客户端使用场景
-
Client 属于高层业务逻辑,它对低层模块的依赖都建立在抽象上
- —— 这意味着,大部分类变量的表现都是抽象类类型,而实际的类型由实际实例化的对象决定。【这也暗合 里式替换原则】
依赖倒置原则的本质就是通过抽象(接口或者抽象类)使得各个类或模块的实现彼此独立,不相互影响,实现模块间的松耦合
最佳实践
- 每个类尽量都有接口或抽象类,或者两者皆有
- 变量的表面类型尽量是接口或者抽象类 (一般工具类除外)
- 任何类都不应该从具体类派生 【当然这并不是绝对的,一般不超过两层的继承都是可以忍受的】
- 尽量不要覆写基类的已实现方法 【类间的依赖是抽象,覆写了基类方法,对依赖的稳定性会产生一定的影响】
-
什么是倒置
- 先了解什么是正置:依赖正置就是类间的依赖是实实在在的实现类的依赖,也就是面向实现编程 —— 我要开奔驰车就依赖奔驰车,我要用笔记本电脑就直接依赖笔记本电脑,这也符合人的思维模式
- 而我们编程的时候,使用抽象间的依赖,替代了人们传统思维中的事物间的依赖,“倒置”就是从这里产生的