目录
1.单一职责原则
单一职责原则的英文名称是Single Responsibility Principle,简称SRP,单一职责原则的定义是应该有且仅有一个原因引起类的变更。这个原则存在争议之处在于对职责的定义,什么是类的职责以及怎么划分类的职责。
看如下的接口定义IPhone:
public interface IPhone {
//拨打电话
void dial(String phoneNumber);
//通话
void chat(Object o);
//通话完毕,挂电话
void hangup();
}
IPhone这个接口的设计是违反单一职责原则的,它包含了两个职责:一个是协议管理,一个是数据传送。dial()和hangup()两个方法实现的是协议管理,分别负责拨号接通和挂机;chat()实现的是双方通话数据的传送,例如将我们说的话转换成模拟信号或者数字信号传递给对方。在这里,协议的变化以及数据传送的变化都会引起类的变化,与此同时这两个职责的变化并不会相互影响。通过这样的分析,我们发现IPhone接口包含了两个职责,而且这两个职责的变化不相互影响,那就考虑拆分成两个接口,其类图如下图所示:
这个类图看起来有点复杂了,虽然满足了单一职责原则的要求,但是在我们设计的时候肯定不会采用这种方式,一个手机类要把ConnectionManager和DataTransfer组合在一起才能使用。组合是一种强耦合关系,你和我都拥有了相同的生命周期,这样的强耦合关系增加类的复杂性,多了两个类。经过思考后,我们修改一下类图:
这样的设计才是完美的,一个类实现了两个接口,把两个职责融合在一个类中。也许我们会觉得这个Phone有两个原因引起变化了呀,是的,但是别忘了我们是面向接口编程,我们对外公布的是接口而不是实现类。而且,如果真要实现类的单一职责,这个就必须使用上面的组合模式了,这样会引起类耦合过重、类的数量增加等问题,人为地增加了设计的复杂性。
单一职责原则最难划分的就是职责,一个职责一个接口,但“职责”没有一个量化的标准,一个类到底要负责哪些职责?这些职责该怎么细化?细化后是否都要有一个接口或类?这些都需要从实际的项目去考虑,项目要考虑可变因素和不可变因素,以及相关的收益成本比率,因此上面我们设计一个IPhone接口也可能是没有错的。在现实实践中,我们必须要考虑工期、成本因素,去“理性”看待单一职责原则,我的建议是接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。
单一职责原则适用于接口、类,同时也适用于方法。比如,一个方法尽可能做一件事情,比如一个方法修改用户密码,不要把这个功能包含到“修改用户信息”方法中,这个方法的颗粒度很粗。
2.里氏替换原则
在面向对象的语言中,继承是必不可少的,它有如下的优点:
- 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性。
- 提高代码的重用性和可扩展性。
- 提高产品和项目的开放性。
但是继承也存在如下的缺点:
- 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法。
- 降低代码的灵活性。子类必须要拥有父类的属性和方法,让子类自由的世界中多了一些约束。
- 增强了耦合性,当父类的常量、变量和方法被修改时,需要考虑到子类的修改。
当然,从整体上看,利大于弊,怎么才能让“利”的因素发挥最大的作用,同时减少“弊”带来的麻烦呢?解决方案是引入里氏替换原则。里氏替换原则原则的通俗定义是只要父类能出现的地方,子类就能出现,而且替换为子类也不会产生任何错误和异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必可以适用。
里氏替换原则包含以下的四层含义。
2.1 子类必须完全实现父类的方法
我们在做系统设计时,经常会定义一个接口或抽象类,然后编码实现,调用类则直接传入接口或抽象类,其实这就是里氏替换原则。我们举个打CS游戏的例子:
枪的主要职责是射击,如何射击在各个具体子类中定义。相关类的源代码设计如下:
/**
* 枪支抽象类
*
* @author: martin
* @date: 2019/9/15 11:14
* @description:
*/
public abstract class AbstractGun {
/**
* 枪支用来杀敌
*/
public abstract void shoot();
}
/**
* @author: martin
* @date: 2019/9/15 11:18
* @description:
*/
public class Handgun extends AbstractGun {
@Override
public void shoot() {
System.out.println("手枪射击...");
}
}
/**
* @author: martin
* @date: 2019/9/15 11:20
* @description:
*/
public class MachineGun extends AbstractGun {
@Override
public void shoot() {
System.out.println("机枪扫射...");
}
}
/**
* @author: martin
* @date: 2019/9/15 11:19
* @description:
*/
public class Rifle extends AbstractGun {
@Override
public void shoot() {
System.out.println("步枪射击...");
}
}
/**
* 士兵实现类
*
* @author: martin
* @date: 2019/9/15 11:27
* @description:
*/
public class Soldier {
/**
* 定义士兵的枪支
*/
private AbstractGun gun;
public void setGun(AbstractGun gun) {
this.gun = gun;
}
public void killEnemy() {
System.out.println("士兵开始杀敌人...");
gun.shoot();
}
}
场景类Client实现如下:
/**
* 场景类
*
* @author: martin
* @date: 2019/9/15 11:23
* @description:
*/
public class Client {
public static void main(String[] args) {
Soldier sanMao = new Soldier();
sanMao.setGun(new Rifle());
sanMao.killEnemy();
}
}
运行Client场景类,执行结果如下:
士兵开始杀敌人...
步枪射击...
在这个程序中,我们给三毛这个士兵一把步枪,然后就开始杀敌了。如果三毛要使用机枪,直接把setGun修改为setGun(new MachineGun())即可。
在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
我们再来想一想,如果我们有一把玩具枪,该如何定义呢?我们可以先在类图上添加一个类TonyGun,然后继承于AbstractGun类。
玩具枪是不能用来射击的,杀不死人的,这个不应该写在shoot()方法中。新增加的ToyGun的源代码设计如下:
public class ToyGun extends AbstractGun {
@Override
public void shoot() {
//玩具枪无法射击,这个方法就不能实现了
}
}
我们把玩具枪传递给三毛用来杀敌,代码运行结果如下:
public class Client {
public static void main(String[] args) {
Soldier sanMao = new Soldier();
sanMao.setGun(new ToyGun());
sanMao.killEnemy();
}
}
士兵开始杀敌人...
坏了,士兵拿着玩具枪来杀敌人,但是射不出子弹啊。在这种情况下,我们发现业务调用类已经出现了问题,正常的业务逻辑已经不能运行,那怎么办?正常的解决方案是ToyGun脱离继承,建立一个独立的父类,为了实现代码复用,可以与AbastractGun建立关联委托关系,类图如下:
此时,我们可以在AbstractToy中声明将“公共部分”——声音、形状都委托给AbstractGun处理,然后两个基类下的子类自由延展,互不影响。
如果子类不能完整的实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
2.2 子类可以有自己的个性
子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。这一点非常的好理解,就不再举例子了。
2.3 覆盖或实现父类的方法时输入参数可以被放大
子类可以覆写父类方法,也可以重载这个方法,前提是这个方法的输入参数类型要宽于父类的类型覆盖范围,这样才能满足里氏替换原则(父类出现的地方,子类也可以出现。)
子类中方法的前置条件必须与超类中被覆写的方法的前置条件相同或者更宽松。
2.4 覆写或实现父类的方法时输出结果可以被缩小
父类的一个方法的返回值是一个类型T,子类相同方法(重载或覆写)的返回值为S,那么里氏替换原则就要求S必须小于等于T,也就是说,要么S和T是同一个类型,要么S是T的子类,
2.5 最佳实战
在项目中,采用里氏替换原则时,尽量避免子类的“个性”,一旦子类有“个性”,这个子类和父类之间的关系就很难调和了,把子类当做父类使用,子类的“个性”被抹杀;把子类单独作为一个业务来使用,则会让代码间的耦合关系变得复杂。
3. 依赖倒置原则
依赖倒置原则(DIP)在Java语言中的表现就是:
- 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。
- 接口或抽象类不依赖于实现类
- 实现类依赖接口或抽象类。
在实际项目中,我们经常需要协作并行开发,此时依赖倒置原则就隆重登场了。两个类之间有依赖关系,只要制定出两者之间的接口(或抽象类)就可以独立开发了,而且项目之间的单元测试也可以独立的运行,而TDD(测试驱动开发)开发模式就是依赖倒置原则的最高级应用。例如,我们有一个司机驾驶汽车的例子,甲程序员负责司机的开发,乙程序员负责汽车的开发,两个开发人员只要制定了接口就可以独立地开发了。假如甲程序员开发进度比较快,完成了IDriver以及相关的实现类Driver的开发工作,而乙程序员滞后开发,那甲是否可以进行单元测试呢?答案是可以,我们可以引入一个JMOCK工具,其最基本的功能是根据抽象虚拟出一个对象进行测试,相关的代码定义如下:
public interface IDriver {
void drive(ICar car);
}
public class Driver implements IDriver {
@Override
public void drive(ICar car) {
car.run();
}
}
public interface ICar {
void run();
}
测试类:
public class DriverTest extends TestCase {
Mockery context = new JUnit4Mockery();
@Test
public void testDriver() {
//根据接口虚拟一个对象
final ICar car = context.mock(ICar.class);
IDriver driver = new Driver();
//内部类
context.checking(new Expectations() {{
oneOf(car).run();
}});
driver.drive(car);
}
}
上面的测试代码中,我们只需要一个ICar的接口,就可以对Driver类进行单元测试。从这一点来看,两个相互依赖的对象可以分别进行开发,孤立的进行单元测试,进而保证并行开发的效率和质量,TDD开发的精髓就在这里。测试驱动开发,先写好单元测试,然后再写实现类,这对提高代码的质量又非常大的帮助,特别适合研发类项目或者成员整体水平比较低的情况下采用。
抽象是对实现的约束,对依赖者而言,也是一种契约。对象的依赖关系有三种方式来进行传递:构造函数传递依赖对象、Setter方法传递依赖对象、接口声明依赖对象。
3.1 最佳实践
依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不相互影响,实现模块间的松耦合。我们怎样使用这个规则呢?只要遵循以下的几个规范:
- 每个类尽量都有接口或抽象类,或者接口和抽象类两者都具备。
- 变量的表面类型尽量是接口或者抽象类。
- 任何类都不应该从具体类派生。
- 尽量不要覆写基类的方法:如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。
- 结合里氏替换原则去使用。
4.接口隔离规则
接口隔离原则要求我们建立单一接口,不要建立庞大臃肿的接口。通俗一点讲就是,接口要尽量细化,同时接口中的方法尽量少。它的定义和单一职责原则很像,但是又存在很大的区别。接口隔离原则与单一职责原则的审视角度是不同的,单一职责要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要求接口的方法尽量少。
4.1 举例
我们用类图来实现星探发现美女的过程,我们假设美女必须具备:面貌、身材和气质。类图设计如下:
相关的类设计如下:
美女类:
public interface IPettyGirl {
void goodLooking();
void niceFigure();
void greatTemperament();
}
public class PettyGirl implements IPettyGirl {
private String name;
public PettyGirl(String name) {
this.name = name;
}
@Override
public void goodLooking() {
System.out.println(name + "很漂亮");
}
@Override
public void niceFigure() {
System.out.println(name + "身材好");
}
@Override
public void greatTemperament() {
System.out.println(name + "气质好");
}
}
星探类:
public abstract class AbstractSearcher {
protected IPettyGirl pettyGirl;
public AbstractSearcher(IPettyGirl pettyGirl) {
this.pettyGirl = pettyGirl;
}
public abstract void show();
}
public class Searcher extends AbstractSearcher {
public Searcher(IPettyGirl pettyGirl) {
super(pettyGirl);
}
@Override
public void show() {
System.out.println("美女的信息如下:");
super.pettyGirl.goodLooking();
super.pettyGirl.niceFigure();
super.pettyGirl.greatTemperament();
}
}
场景实现类:
public class Client {
public static void main(String[] args) {
//定义美女
IPettyGirl zhaoLiYing = new PettyGirl("赵丽颖");
AbstractSearcher searcher = new Searcher(zhaoLiYing);
searcher.show();
}
}
星探搜索美女的运行结果如下:
美女的信息如下:
赵丽颖很漂亮
赵丽颖身材好
赵丽颖气质好
星探寻找美女的程序开发完毕了,运行结果也是正确的。但是这个IPettyGirl接口是否做到了最优化的设计呢?答案是没有,还可以对这个接口进行优化。随着时代的发展,我们的审美观点在不断的发生变化,比如唐以胖为美,现在却以瘦为美。再比如,有人觉得只要气质好就算美女,有人认为身材好就算美女,还有人认为两者兼具才算。分析到这里,我们发现接口IPettyGirl的设计是有缺陷的,过于庞大了,容纳了很多可变因素,根据接口隔离规则,星探AbstractSearcher应该依赖于具有部分特质的女孩子,而我们却把这些特质全部封装起来了。问题找到了,我们重新设计一下类图,修改后的类图如下图所示:
把原来的IPrettyGirl接口拆分成两个接口,一种是外形美的美女IGoodBodyGirl,一种是气质型美女IGreatTemperamentGirl。这样我们把一个比较臃肿的接口拆分成了两个专门的接口,灵活性就大大提高了。
通过上面的重构以后,不管以后是要气质美女还是要外形美女,都可以保持接口的稳定。当然,你可能要说,以后可能审美观点再次发生变化,只要外表好看就是美女,那这个IGoodBody接口还要进行接口拆分啊。确实是这样的,但是设计是有限度的,没法无限地考虑未来全部的情况,否则就会陷入设计的泥沼。
以上把一个臃肿的接口变更为两个独立的接口所依赖的原则就是接口隔离原则。接口是我们设计时对外提供的契约,通过分散定义多个接口,可以预防未来变更的扩散,提高系统的灵活性和可维护性。
4.2 最佳实战
接口隔离原则是对接口进行规范约束,其包含四层含义:
- 接口要尽量小
- 接口要高内聚
- 接口设计是有限度的,不能过度设计
接口隔离原则是对接口的定义,同时也是对类的定义,接口和类尽量使用原子接口或原子类来组装。但是这个原子该怎么划分是设计模式中的一大难题,在实践中可以根据以下规则衡量:
- 一个接口只服务与一个子业务或业务模块
- 通过业务逻辑压缩接口中的public方法,尽量让接口达到精简。
贯彻使用接口隔离规则最好的方法就是一个接口一个方法,保证绝对符合接口隔离规则。但在现实中没有人会这么做,正确使用接口隔离规则是根据经验和常识决定接口的粒度大小,粒度既不能太粗也不能太细。
5.迪米特法则
迪米特法则也称为最少知识原则,就是指一个对象应该对其他对象有最少的了解。其核心理念就是类间解耦,弱耦合,提高类的复用率。
迪米特法则对类的低耦合提出了明确地要求,其包含以下含义:
- 类与类之间的关系是建立在类间的,而不是方法间的,因此一个方法尽量不要去引入一个类中不存在的对象,当然JDK API除外。
- 一个类公开的public属性或者方法越多,修改时涉及到的面也就越广,变更引起的风险扩散也就越大。迪米特法则要求类尽量不要对外公布太多的public方法属性,尽量内敛。多私用private、friendly、protected等访问权限。
6.开闭原则
一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已知的代码来完成变化。它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,底层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。