什么是设计模式?
设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结。
使用设计模式的目的:为了代码可重用性、让代码更容易被他人理解、保证代码可靠性,使代码编写真正工程化!
今天我们来聊一下设计模式的六大原则:
1、单一职责原则(Single responsibility principle,SRP)
2、里氏替换原则(Liskov Substitution Principle,LSP)
3、依赖倒置原则(Dependency Inversion Principle,DIP)
4、接口隔离原则(Interface Segregation Principle,ISP)
5、迪米特法则(Principle of Least Knowledge,PLK)
6、开闭原则(Open Closed Principle,OCP)
单一职责原则
定义:一个类只负责一项职责,不能存在多于一个导致类变更的原因;
问题由来:由类S负责两个不同的职责S1、S2,当由于职责S2需求发生改变而需要修改S类时,有可能导致原本运行正常的职责S1发生故障;
解决方案:遵循单一职责原则,分别建立两个类X1、X2,使X1负责S1的职责,X2负责S2的职责,这样使职责S1和S2独立,互不影响;
举例说明:
有个工具类
public class Utils {
//方法1:求平方
public int function1(int a) {
return a * a;
}
//方法2:求某个值的平方后并除10
public int function2(int b) {
return function1(b) / 10;
}
}
有个需求,我要求10的平方并除以10
Utils utils = new Utils();
utils.function2(10);
但是现在我们有了另一个需求,求10的立方并除以10
这个时候我们就要修改Utils类里的function1方法,这显然违背了单一职责原则,按照单一职责原则我们应该function1新建一个类util1负责 ,function2新建一个类util2负责,如果有了新需求(求立方)我们只需新建一个类util3创建function3方法即可,但是新建类花销也是很大的,我们可以直接在Utils里新建一个function3方法来求立方也可以啊。这样虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。
本文所举的这个例子,它太简单了,它只有一个方法,所以,无论是在代码级别上违反单一职责原则,还是在方法级别上违反,都不会造成太大的影响。实际应用中的类都要复杂的多,一旦发生职责扩散而需要修改类时,除非这个类本身非常简单,否则还是遵循单一职责原则的好。
单一职责原则的优点:
1、可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;
2、提高类的可读性,提高系统的可维护性;
3、变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
单一职责原则不只是面向对象编程思想所特有的,只要是模块化的程序设计,都适用单一职责原则。
里氏替换原则
定义:如果对每一个类型为 S1的对象 o1,都有类型为 S2 的对象o2,使得以 S1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 S2 是类型 S1 的子类型。
即:所有引用基类的地方必须能透明地使用其子类的对象。
问题由来:类A有一功能A1, A{A1},现在功能要扩展,扩展为类B(A的子类) 功能为原有A1和新功能B2,即B{A1,B2},这样有可能会导致在实现新功能B2时导致原功能A1异常;
解决方案:当使用继承时,遵循里氏替换原则,在B继承A时,除添加新功能B2,尽量不要重新父类(A1)方法,也不要重载父类方法;
继承包含这样一层含义:父类中凡是已经实现好的方法,实际上是在设定一系列的规范和契约,如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。
举例说明:
现在有一个类A实现一个方法计算两个数的和
public class A {
public int func1(int a, int b) {
return a + b;
}
}
A a = new A();
System.out.println(a.func1(20, 10));
运行结果:30
现在由于功能要扩展,我们要计算两个数的和的两倍,于是我们建立了一个新类B
public class B extends A {
public int func1(int a, int b) {
return a - b;
}
public int func2(int a, int b) {
return func1(a, b) * 2;
}
}
由于父类已经实现了两个数相加,因为我们扩展的方法可以直接使用父类的方法
B b = new B();
System.out.println("20+10=" + b.func1(20, 10));
System.out.println("(20+10)*2=" + b.func2(20, 10));
运行结果:
20+10=10
(20+10)*2=20
可以看到,原本运行正常的func1出现了问题,原因就是B类在起名的时候无意中重写了父类的方法,造成所有运行相加功能的代码全部调用了B类复写后的方法,造成原本运行正常的功能出现了错误。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。
它包含以下4层含义:
1、子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
2、子类中可以增加自己特有的方法。
3、当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
4、当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
依赖倒置原则
核心原则:
A:高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。
B:抽象不应该依赖于具体,具体应该依赖于抽象。
问题由来:类B继承类A,如果将类B改为继承类C,则必须通过修改类B的代码来达成;这种情况下类B一般是高层模块,负责复杂的业务逻辑,类A和C都是低层模块,负责基本的原始操作,这时候如果修改类B,给程序带来了不必要的风险;
解决方案:将类B依赖接口S,类A和C各自实现接口S,类B通过接口S与类A和C发生联系,则大大降低修改类B的几率;
依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。在java中,抽象指的是接口或者抽象类,细节就是具体的实现类,使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成。
依赖倒置原则的核心思想是面向接口编程;
举例说明:
假如有一个吃水果的需求,有这样几个类:
public class Apple {
public String getApple() {
return "苹果";
}
}
public class Me {
//吃苹果
public void eat(Apple apple) {
System.out.println("我开始吃:" + apple.getApple());
}
}
Me me = new Me();
me.eat(new Apple());
运行结果:我开始吃苹果
假如有一条,我想吃梨于是我们新建一个类
public class Pear {
public String getPear() {
return "梨";
}
}
public class Me {
//吃苹果
public void eat(Apple apple) {
System.out.println("我开始吃:" + apple.getApple());
}
//吃梨
public void eat(Pear pear) {
System.out.println("我开始吃:" + pear.getPear());
}
}
Me me = new Me();
me.eat(new Apple());
me.eat(new Pear());
运行结果:
我开始吃苹果
我开始吃梨
如果有一个我想吃橘子,想吃香蕉,什么都想吃呢,难道还要这样一直加下去?当然不是,我们可以根据依赖倒置原则,面向接口编程;我们引入一个接口IFruits;
public interface IFruits {
String getFruit();
}
public class Apple implements IFruits {
@Override
public String getFruit() {
return "苹果";
}
}
public class Pear implements IFruits {
@Override
public String getFruit() {
return "梨";
}
}
Me me = new Me();
me.eat(new Apple());
me.eat(new Pear());
这样,无论我们以后想吃什么,都无需修改Me类,如有想吃的水果,只需新建一个类实现IFruits接口即可;
这就是依赖倒置原则的典型应用,在实际开发过程中,我们应该遵循以下三点:
1、低层模块尽量都要有抽象类或接口,或者两者都有。
2、变量的声明类型尽量是抽象类或接口。
3、使用继承时遵循里氏替换原则。
依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。
接口隔离原则
定义:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
核心原则:
1、使用多个专门的接口比使用单一的总接口要好。
2、一个类对另外一个类的依赖性应当是建立在最小的接口上的。
3、一个接口代表一个角色,不应当将不同的角色都交给一个接口。没有关系的接口合并在一起,形成一个臃肿的大接口,这是对角色和接口的污染。
4、“不应该强迫客户依赖于它们不用的方法。接口属于客户,不属于它所在的类层次结构。”这个说得很明白了,再通俗点说,不要强迫客户使用它们不用的方法,如果强迫用户使用它们不使用的方法,那么这些客户就会面临由于这些不使用的方法的改变所带来的改变。
问题由来:类a通过接口i依赖类A,类b通过接口i依赖类B,如果接口i对于a和b来说不是最小接口,则类A和B必须要去实现它们不需要的方法;
解决方案:将臃肿的类i拆分独立的几个接口,类a和b分别与他们需要的接口建立依赖关系,也就是采用接口隔离原则;
举例说明:
//定义一个接口I
public interface I {
public void method1();
public void method2();
public void method3();
public void method4();
public void method5();
}
类A
public class A implements I {
@Override
public void method1() {
System.out.println("实现接口I的方法1");
}
@Override
public void method2() {
System.out.println("实现接口I的方法2");
}
@Override
public void method3() {
System.out.println("实现接口I的方法3");
}
@Override
public void method4() {
//不需要
}
@Override
public void method5() {
//不需要
}
}
类B
public class B implements I{
@Override
public void method1() {
//不需要
}
@Override
public void method2() {
//不需要
}
@Override
public void method3() {
//不需要
}
@Override
public void method4() {
System.out.println("实现接口I的方法4");
}
@Override
public void method5() {
System.out.println("实现接口I的方法5");
}
}
可以看到类A和B都实现了接口I,但是对于A和B来说,它们都实现了它们不需要的方法,I对于A和B来说并不是最小接口,因此我们应该遵循接口隔离原则,分别建立I1和I2定义A和B各自所需要的方法,如下:
public interface I1 {
public void method1();
public void method2();
public void method3();
}
public interface I2 {
public void method4();
public void method5();
}
public class A implements I1 {
@Override
public void method1() {
System.out.println("实现接口I1的方法1");
}
@Override
public void method2() {
System.out.println("实现接口I1的方法2");
}
@Override
public void method3() {
System.out.println("实现接口I1的方法3");
}
}
public class B implements I2 {
@Override
public void method4() {
System.out.println("实现接口I2的方法4");
}
@Override
public void method5() {
System.out.println("实现接口I2的方法5");
}
}
以上接口定义看起来就清晰了很多,类A和B分别依赖自己所需要的方法,不去依赖不需要的方法。 接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。
到这里可能有人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。
使用接口隔离原则是要注意一下几点:
1、接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
2、为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
3、提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
迪米特法则
定义:一个对象应该对其他对象保持最少的理解;
问题由来:类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
解决方案:降低类与类之间的耦合;
迪米特法则又叫最少知道原则,就是一个类对自己所依赖的类知道的越少越好。迪米特法则还有一个定义即:只与直接的朋友通信。什么是直接的朋友?
每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。
举例说明
一个集团公司都有自己的下属子公司,现在我们想打印一下集团所有员工的ID包括子公司;
//总公司员工
public class Employee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
//子公司员工
public class SubEmployee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
public class SubCompanyManager {
public List<SubEmployee> getAllEmployee() {
List<SubEmployee> list = new ArrayList<SubEmployee>();
for (int i = 0; i < 100; i++) {
SubEmployee emp = new SubEmployee();
//为分公司人员按顺序分配一个ID
emp.setId("分公司" + i);
list.add(emp);
}
return list;
}
}
public class CompanyManager {
public List<Employee> getAllEmployee() {
List<Employee> list = new ArrayList<Employee>();
for (int i = 0; i < 30; i++) {
Employee emp = new Employee();
//为总公司人员按顺序分配一个ID
emp.setId("总公司" + i);
list.add(emp);
}
return list;
}
//打印公司所有员工ID
public void printAllEmployee(SubCompanyManager sub) {
List<SubEmployee> list1 = sub.getAllEmployee();
for (SubEmployee e : list1) {
System.out.println(e.getId());
}
List<Employee> list2 = this.getAllEmployee();
for (Employee e : list2) {
System.out.println(e.getId());
}
}
}
CompanyManager e = new CompanyManager();
e.printAllEmployee(new SubCompanyManager());
现在这个设计的主要问题出在CompanyManager中,根据迪米特法则,只与直接的朋友发生通信,而SubEmployee类并不是CompanyManager类的直接朋友(以局部变量出现的耦合不属于直接朋友),从逻辑上讲总公司只与他的分公司耦合就行了,与分公司的员工并没有任何联系,这样设计显然是增加了不必要的耦合。按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合。修改后的代码如下:
class SubCompanyManager{
public List<SubEmployee> getAllEmployee(){
List<SubEmployee> list = new ArrayList<SubEmployee>();
for(int i=0; i<100; i++){
SubEmployee emp = new SubEmployee();
//为分公司人员按顺序分配一个ID
emp.setId("分公司"+i);
list.add(emp);
}
return list;
}
public void printEmployee(){
List<SubEmployee> list = this.getAllEmployee();
for(SubEmployee e:list){
System.out.println(e.getId());
}
}
}
class CompanyManager{
public List<Employee> getAllEmployee(){
List<Employee> list = new ArrayList<Employee>();
for(int i=0; i<30; i++){
Employee emp = new Employee();
//为总公司人员按顺序分配一个ID
emp.setId("总公司"+i);
list.add(emp);
}
return list;
}
public void printAllEmployee(SubCompanyManager sub){
sub.printEmployee();
List<Employee> list2 = this.getAllEmployee();
for(Employee e:list2){
System.out.println(e.getId());
}
}
}
修改后,为分公司增加了打印人员ID的方法,总公司直接调用来打印,从而避免了与分公司的员工发生耦合。
迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,例如本例中,总公司就是通过分公司这个“中介”来与分公司的员工发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。
开闭原则
主要特征
1、对于扩展是开放的(Open for extension)。这意味着模块的行为是可以扩展的。当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。也就是说,我们可以改变模块的功能。
2、对于修改是关闭的(Closed for modification)。对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。模块的二进制可执行版本,无论是可链接的库、DLL或者.EXE文件,都无需改动。
定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
问题由来:软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。
解决方案:当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统。开闭原则可能是设计模式六项原则中定义最模糊的一个了,它只告诉我们对扩展开放,对修改关闭,可是到底如何才能做到对扩展开放,对修改关闭,并没有明确的告诉我们。
在仔细思考以及仔细阅读很多设计模式的文章后,终于对开闭原则有了一点认识。其实,我们遵循设计模式前面5大原则,以及使用23种设计模式的目的就是遵循开闭原则。也就是说,只要我们对前面5项原则遵守的好了,设计出的软件自然是符合开闭原则的,这个开闭原则更像是前面五项原则遵守程度的“平均得分”,前面5项原则遵守的好,平均分自然就高,说明软件设计开闭原则遵守的好;如果前面5项原则遵守的不好,则说明开闭原则遵守的不好。
开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。
最后说明一下如何去遵守这六个原则。对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。
我自己的总结,其实设计模式强调的是一种思想,非逻辑处理,我们虽然明白了其明面上的定义,但具体的实现还要靠我们在项目中去运用,如何灵活的运用,做优秀的设计,写出更好的代码,才能真正掌握设计模式的精髓。