1.简述
设计模式总共有六大基本原则,统称为SOLID (稳定)原则,分别是S-单一职责原则(Single Responsibility Principle), O-开闭原则(Open closed Principle),L-里氏替换原则(Liskov Substitution Principle),L-迪米特原则(Law of Demeter),I-接口隔离原则(Interface Segregation Principle),D-依赖倒置原则(Dependence Invension Principle)。
开闭原则是面向对象的可复用设计的基石。其他设计原则是实现开闭原则的手段和工具。
一般,可以把这六个原则分成了以下两个部分:
- 设计目标:开闭原则、里氏代换原则、迪米特原则
- 设计方法:单一职责原则、接口分隔原则、依赖倒置原则
2. S-单一职责原则(Single Responsibility Principle)
单一职责原则的核心概念是:一个类、接口、方法仅有一个职责(注:通俗的讲,就是让一个类只做一件事)
为什么只能有一个职责:
- 扩展:某一个职责发生变化(例:需求变更,修改逻辑)的时候可能会影响其他职责
- 资源:某一个职责引用了某些资源(例:类库),则另一个职责也会引用这些资源,造成资源浪费
遵循单一职责原则的优点有:
- 简洁:可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多
- 性能:提高类的可读性,提高系统的可维护性
- 扩展:变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响
举例说明
public class Test{ public static void main(String[] args){ Animal animal = new Animal(); animal.behavior("狗"); animal.behavior("牛"); animal.behavior("羊"); } } class Animal{ public void behavior(String animal){ System.out.println(animal+"在跑"); } }
运行结果:
狗在跑
牛在跑
羊在跑
程序上线后,发现问题了,并不是所有的动物都用跑的,比如鱼就是游的。修改时如果遵循单一职责原则,需要将Animal类细分为陆生动物类Terrestrial,水生动物Aquatic,代码如下:
public class Test{ public static void main(String[] args){ Terrestrial terrestrial = new Terrestrial(); terrestrial.behavior("狗"); terrestrial.behavior("牛"); terrestrial.behavior("羊"); Aquatic aquatic = new Aquatic(); aquatic.behavior("鱼"); } } class Terrestrial{ public void behavior(String animal){ System.out.println(animal+"在跑"); } } class Aquatic{ public void behavior(String animal){ System.out.println(animal+"在游"); } }
运行结果:
狗在跑
牛在跑
羊在跑
鱼在游
我们会发现如果这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。而直接修改类Animal来达成目的虽然违背了单一职责原则,但花销却小的多,代码如下:
public class Test{ public static void main(String[] args){ Animal animal = new Animal(); animal.behavior("狗"); animal.behavior("牛"); animal.behavior("羊"); animal.behavior("鱼"); } } class Animal{ public void behavior(String animal){ if("鱼".equals(animal)){ System.out.println(animal+"在游"); }else{ System.out.println(animal+"在跑"); } } }
可以看到,这种修改方式要简单的多。但是却存在着隐患:有一天需要将鱼分为游的快鱼和游的慢的鱼,则又需要修改Animal类的behavior方法,而对原有代码的修改会对调用“狗”“牛”“羊”等相关功能带来风险,也许某一天你会发现程序运行的结果变为“羊在游”了。这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却是最大的。还有一种修改方式:
public class Test{ public static void main(String[] args){ Animal animal = new Animal(); animal.behavior("狗"); animal.behavior("牛"); animal.behavior("羊"); animal.behavior2("鱼"); } } class Animal{ public void behavior(String animal){ System.out.println(animal+"在跑"); } public void behavior2(String animal){ System.out.println(animal+"在游"); } }
这种修改方式没有改动原来的方法,而是在类中新加了一个方法,这样虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。
这三种方式各有优缺点,在实际编程中,采用应该使用哪一种?其实这真的比较难说,需要根据实际情况来确定。我的理解是:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;
所举的这个例子,比较简单,它只有一个方法,所以,无论是在代码级别上违反单一职责原则,还是在方法级别上违反,都不会造成太大的影响。实际应用中的类都要复杂的多,一旦发生职责扩散而需要修改类时,除非这个类本身非常简单,否则还是遵循单一职责原则的好。
单一职责看似简单,实际上在实际运用过程中,会发现真的会出现很多职责扩展的现象,这个时候采用直接违反还会方法上遵循还是完全遵循单一职责原则还是取决于当前业务开发的人员的技能水平和这个需求的时间。不过不管采用什么方式解决,心中至少要知道有几种解决方法。
3. O-开闭原则(Open Close Principle)
开闭原则的核心概念:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭(注:通俗的讲,就是程序新增需求的时候,尽量不要改之前的代码,尽量新增)
为什么对扩展开放,对修改关闭:
- 稳定:当出现新的需求时,修改已写好的底层可能会影响程序的稳定
遵守开闭原则的优点:
- 扩展:扩展已有的功能,可以提供新的行为,满足程序的新需求,提高扩展性
- 稳定:已有的功能,不能进行修改,保证程序的稳定性
举例说明
// 测试 public class Test{ public static void main(String[] args) { ICourse course = new EnglishCourse("小学英语", 199D, "Mr.Zhang"); System.out.println( "课程名字:"+course.getName() + " " + "课程价格:"+course.getPrice() + " " + "课程作者:"+course.getAuthor() ); } } /** * 定义课程接口 */ public interface ICourse { String getName(); // 获取课程名称 Double getPrice(); // 获取课程价格 Integer getType(); // 获取课程类型 } /** * 英语课程接口实现 */ public class EnglishCourse implements ICourse { private String name; private Double price; private Integer type; public EnglishCourse(String name, Double price, Integer type) { this.name = name; this.price = price; this.type = type; } @Override public String getName() { return null; } @Override public Double getPrice() { return null; } @Override public Integer getType() { return null; } }
运行结果
课程名字:小学英语
课程加个:199D
课程作者:Mr.Zhang
程序上线,课程正常销售,但是我们产品需要做些活动来促进销售,比如:打折。那么问题来了:打折这一动作就是一个变化,而我们要做的就是拥抱变化,现在开始考虑如何解决这个问题。修改时如果遵循开闭原则,需要添加一个子类 SaleEnglishCourse ,重写 getPrice()方法,代码如下:
public class SaleEnglishCourse extends EnglishCourse { public SaleEnglishCourse(String name, Double price, String author) { super(name, price, author); } @Override public Double getPrice() { return super.getPrice() * 0.85; } }
可以看到这样处理对源代码没有影响,符合开闭原则,以后再来个语文课程,数学课程等等的价格变动都可以采用此方案,维护性极高而且也很灵活
开闭原则表达意思是:用抽象构建框架,用实现扩展细节。因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节,我们用从抽象派生的实现类来进行扩展,当软件需要发生变化时,我们只需要根据需求重新派生一个实现类来扩展就可以了。当然前提是我们的抽象要合理,要对需求的变更有前瞻性和预见性才行。
4. L-里氏替换原则(Liskov Substitution Principle)
里氏替换原则的核心概念是:所以利用基类的地方必须能透明的使用其子类的对象(注:通俗的讲,子类可以扩展父类的功能,但不能改变父类原有的功能)
为什么子类不能改变父类原有方法:
稳定:在面向对象的设计思想中,继承这一特性为系统的设计带来了极大的便利性,但是由之而来的也潜在着一些风险,如果子类修改了父类的原有方法,可能会造成父类原有功能出现问题
遵守里氏替换原则的优点:
- 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
- 提高代码的重用性;
- 子类可以形似父类,但又异于父类,“龙生龙,凤生凤,老鼠生来会打洞”是说子拥有父的“种”,“世界上没有两片完全相同的叶子”是指明子与父的不同;
- 提高代码的可扩展性,实现父类的方法就可以“为所欲为”了,君不见很多开源框架的扩展接口都是通过继承父类来完成的;
- 提高产品或项目的开放性。
举例说明
public class Test{ public static void main(String[] args) { A a = new B(); System.out.println("2+1=" + a.func(2, 1)); } } public class A { public int func(int a, int b){ return a+b; } } public class B extends A{ @Override public int func(int a, int b) { return a-b; } }
运行结果:2+1=1
可以看到运行结果明显是错误的。类B继承A,后来需要增加新功能,类B并没有新写一个方法,而是直接重写了父类A的func方法,违背里氏替换原则,引用父类的地方并不能透明的使用子类的对象,导致运行结果出错。所以对上面的代码加以更改,使其符合里氏替换原则,代码如下:
public class Test{ public static void main(String[] args) { B c = new B(); System.out.println("2-1=" + c.func2(2, 1)); } } public class A { public int func(int a, int b){ return a+b; } } public class B extends A{ public int func2(int a, int b) { return a-b; } }
运行结果:2-1=1
可以看到这样编写就不会存在问题
继承作为面向对象三大特性之一,在给程序设计带来巨大便利的同时,也带来了一些弊端,它增加了对象之间的耦合性。因此在系统设计时,遵循里氏替换原则,尽量避免子类重写父类的方法,可以有效降低代码出错的可能性。
面向对象有三大特性:封装、继承、多态。所以我们在实际开发过程中,子类在继承父类后,根据多态的特性,可能是图一时方便,经常任意重写父类的方法,那么这种方式会大大增加代码出问题的几率。比如下面场景:类A实现了某项功能F1。现在需要对功能F1作修改扩展,将功能F1扩展为F,其中F由原有的功能F1和新功能F2组成。新功能F由类A的子类B来完成,则子类B在完成功能F的同时,有可能会导致类A的原功能F1发生故障。这时候里氏替换原则就闪亮登场了。
5. L-迪米特原则(最少知道原则) (Law of Demeter)
迪米特原则的核心概念:降低类之间的耦合。只有耦合降低了,类的复用率才能提高(注:通俗的讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息)
为什么一个类应该对其他类保持最少的了解:
- 耦合:类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大
遵守迪米特原则的优点:
- 降低了类之间的耦合度,提高了模块的相对独立性。
- 由于亲合度降低,从而提高了类的可复用率和系统的扩展性。
举例说明
public class Test{ public static void main(String[] args){ CompanyManager e = new CompanyManager(); e.printAllEmployee(new SubCompanyManager()); } } //总公司员工 class Employee{ private String id; public void setId(String id){ this.id = id; } public String getId(){ return id; } } //分公司员工 class SubEmployee{ private String id; public void setId(String id){ this.id = id; } public String getId(){ return id; } } 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; } } 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){ 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类中依赖了SubEmployee类,然而SubEmployee类并不在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的方法,总公司直接调用来打印,从而避免了与分公司的员工发生耦合。
迪米特原则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,例如本例中,总公司就是通过分公司这个“中介”来与分公司的员工发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特原则时要反复权衡,既做到结构清晰,又要高内聚低耦合。
6. I-接口隔离原则 (Interface Segregation Principle)
接口隔离原则的核心概念:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上(注:通俗的讲,接口尽量细化,同时接口中的方法尽量少)
为什么接口尽量细化:
- 灵活:接口的设计粒度越小,系统越灵活
遵守接口隔离原则的优点:
- 将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
- 接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。
- 如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
- 使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
- 能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。
举例说明
interface I { public void method1(); public void method2(); public void method3(); public void method4(); public void method5(); } class A{ public void depend1(I i){ i.method1(); } public void depend2(I i){ i.method2(); } public void depend3(I i){ i.method3(); } } class B implements I{ public void method1() { System.out.println("类B实现接口I的方法1"); } public void method2() { System.out.println("类B实现接口I的方法2"); } public void method3() { System.out.println("类B实现接口I的方法3"); } //对于类B来说,method4和method5不是必需的,但是由于接口A中有这两个方法, //所以在实现过程中即使这两个方法的方法体为空,也要将这两个没有作用的方法进行实现。 public void method4() {} public void method5() {} } class C{ public void depend1(I i){ i.method1(); } public void depend2(I i){ i.method4(); } public void depend3(I i){ i.method5(); } } class D implements I{ public void method1() { System.out.println("类D实现接口I的方法1"); } //对于类D来说,method2和method3不是必需的,但是由于接口A中有这两个方法, //所以在实现过程中即使这两个方法的方法体为空,也要将这两个没有作用的方法进行实现。 public void method2() {} public void method3() {} public void method4() { System.out.println("类D实现接口I的方法4"); } public void method5() { System.out.println("类D实现接口I的方法5"); } } public class Client{ public static void main(String[] args){ A a = new A(); a.depend1(new B()); a.depend2(new B()); a.depend3(new B()); C c = new C(); c.depend1(new D()); c.depend2(new D()); c.depend3(new D()); } }
可以看到,如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。如果将这个设计修改为符合接口隔离原则,就必须对接口I进行拆分。代码如下:
interface I1 { public void method1(); } interface I2 { public void method2(); public void method3(); } interface I3 { public void method4(); public void method5(); } class A{ public void depend1(I1 i){ i.method1(); } public void depend2(I2 i){ i.method2(); } public void depend3(I2 i){ i.method3(); } } class B implements I1, I2{ public void method1() { System.out.println("类B实现接口I1的方法1"); } public void method2() { System.out.println("类B实现接口I2的方法2"); } public void method3() { System.out.println("类B实现接口I2的方法3"); } } class C{ public void depend1(I1 i){ i.method1(); } public void depend2(I3 i){ i.method4(); } public void depend3(I3 i){ i.method5(); } } class D implements I1, I3{ public void method1() { System.out.println("类D实现接口I1的方法1"); } public void method4() { System.out.println("类D实现接口I3的方法4"); } public void method5() { System.out.println("类D实现接口I3的方法5"); } }
可以看到,上面的例子将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。
采用接口隔离原则对接口进行约束时,要注意以下几点:
- 接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
- 为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
- 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。
7. D-依赖倒置原则 (Dependence Inversion Principle)
依赖倒置原则的核心概念:程序要依赖于抽象接口,不要依赖于具体实现(注:通俗的讲,就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合)
遵守依赖倒置原则的优点:
- 可以减少类与类之间的耦合性,提供系统的稳定性
- 提供代码的可读性和可维护性
- 降低修改程序所造成的影响
举例说明
class Book{ public String getContent(){ return "很久很久以前有一个阿拉伯的故事……"; } } class Mother{ public void narrate(Book book){ System.out.println("妈妈开始讲故事"); System.out.println(book.getContent()); } } public class Client{ public static void main(String[] args){ Mother mother = new Mother(); mother.narrate(new Book()); } }
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
可以看到上面讲述的是面向实现的编程,即依赖的是Book这个具体的实现类;看起来功能都很OK,也没有什么问题。运行良好,假如有一天,需求变成这样:不是给书而是给一份报纸,让这位母亲讲一下报纸上的故事。代码如下:
class Newspaper{ public String getContent(){ return "林书豪38+7领导尼克斯击败湖人……"; } }
这位母亲却办不到,因为她不会读报纸上的故事,只是将书换成报纸,就必须要修改Mother才能读。假如以后需求换成杂志呢?换成网页呢?还要不断地修改Mother,这显然不是好的设计。原因就是Mother与Book之间的耦合性太高了,必须降低他们之间的耦合度才行。
我们引入一个抽象的接口IReader。读物,只要是带字的都属于读物:
interface IReader{ public String getContent(); }
Mother类与接口IReader发生依赖关系,而Book和Newspaper都属于读物的范畴,他们各自都去实现IReader接口,这样就符合依赖倒置原则了,代码如下:
class Newspaper implements IReader { public String getContent(){ return "林书豪17+9助尼克斯击败老鹰……"; } } class Book implements IReader{ public String getContent(){ return "很久很久以前有一个阿拉伯的故事……"; } } class Mother{ public void narrate(IReader reader){ System.out.println("妈妈开始讲故事"); System.out.println(reader.getContent()); } } public class Client{ public static void main(String[] args){ Mother mother = new Mother(); mother.narrate(new Book()); mother.narrate(new Newspaper()); } }
运行结果:
妈妈开始讲故事
很久很久以前有一个阿拉伯的故事……
妈妈开始讲故事
林书豪17+9助尼克斯击败老鹰……
可以看到,这样修改后,无论以后怎样扩展Client类,都不需要再修改Mother类了。这只是一个简单的例子,实际情况中,代表高层模块的Mother类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。
在实际编程中,我们一般需要做到如下3点:
- 低层模块尽量都要有抽象类或接口,或者两者都有。【可能会被人用到的】
- 变量的声明类型尽量是抽象类或接口。
- 使用继承时遵循里氏替换原则。
依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置。
总结
对这六个原则的遵守并不是是和否的问题,而是多和少的问题,也就是说,我们一般不会说有没有遵守,而是说遵守程度的多少。任何事都是过犹不及,设计模式的六个设计原则也是一样,制定这六个原则的目的并不是要我们刻板的遵守他们,而需要根据实际情况灵活运用。对他们的遵守程度只要在一个合理的范围内,就算是良好的设计。