设计模式
平常老是说设计模式、单例模式什么的,干了两年开发完全没感觉…这个也只能有经验了才能说会有明确的认识,趁现在有时间查查资料整理下,做做积累。
为啥要有设计模式
设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。
设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。
设计模式的六大原则
1. 开闭原则(Open Close Principle)
- 开闭原则:对扩展开放,对修改关闭。 在程序需要进行拓展的时候,不能去修改原有的代码,尽量通过扩展软件实体的行为来实现变化,实现一个热插拔的效果。所以一句话概括就是:为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,需要面向接口编程。
- 问题由来: 在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。
2.单一职责(Single Responsibility Principle)
- 单一职责:它规定一个类应该只有一个发生变化的原因或者说应该只有一个职责。 如果一个类有一个以上的职责,这些职责就耦合在了一起。这会导致脆弱的设计。当一个职责发生变化时,可能会影响其它的职责。另外,多个职责耦合在一起,会影响复用性。
- 问题由来: 比如一个类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。
- 有何优点:
- 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多;
- 提高类的可读性,提高系统的可维护性;
- 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。
- 实际扩展: 职责扩散。所谓职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2。
说明:通常上边的情况在我们开发中肯定会遇到,比如有个类一开始写好了要实现传入“牛”输出“牛呼吸空气”,结果客户要添加另一种情况“鱼呼吸水”,怎么办。
针对这个问题有3个解决方案:1、遵循单一职责,将这个类拆卸成2个类(很麻烦);2、直接修改原代码,加个if判断(很简单但隐患大:如果有一天鱼又区分呼吸淡水或海水呢,这种直接修改原有的代码,有一天你就会发现程序运行变成“牛呼吸海水”了);3、再类中新加个方法(在方法上符合单一原则)。
这个问题就仁者见仁。。。
3.里氏替换原则(Liskov Substitution Principle)
- 里氏替换原则:里氏代换原则是面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP 是继承复用的基石,只有当派生类可以替换掉基类,且软件单位的功能不受到影响时,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。里氏代换原则是对开闭原则的补充。实现开闭原则的关键步骤就是抽象化,而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。简单的说就是:子类可以扩展父类的功能,但不能改变父类原有的功能。
- 定义1: 如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。(简单来讲,我有A类和B类,我在别的方法里引用的A类对象,现在我把A直接替换成B了,并且原方法没有受到影响。)
- 定义2 所有引用基类的地方必须能透明地使用其子类的对象。
- 问题由来: 假设我父类实现了加法,你在子类时重写了加法实现了减法。然后你在用子类实现程序的时候记得父类有实现加法,你直接使用了。或者说,你在使用子类的时候发现子类也要实现加法。
这种情况就很明显的增加了继承的复杂性,并且复用性降低。
里氏转换原则要求子类从抽象继承而不是从具体继承,如果从抽象继承,子类必然要重写父类方法。因此里氏转换原则和多态是相辅相成的!
注意:如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。 - 包含以下4层含义:
1)子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
2)子类中可以增加自己特有的方法。
3)当子类的方法重载父类的方法时,方法的输入参数可以被放大。【注意区分重载和重写】【父类的一个方法的形参是一个类型T,子类的相同方法(重载)的形参是S,那么里氏替换原则就要求S必须大于等于T。也就是说,要么S和T是同一类型,要么S是T的父类。】
4)当子类的方法实现父类的抽象方法时,方法的返回值可以被缩小。【父类的一个方法返回值是一个类型T,子类的相同方法(重载或重写)的返回值是S,那么里氏替换原则就要求S必须小于等于T。也就是说,要么S和T是同一类型,要么S是T的子类】
4.依赖倒置原则 (Dependence Inversion Principle)
- 依赖倒置原则:这个原则是开闭原则的基础,具体内容:针对接口编程,依赖于抽象而不依赖于具体。
- 定义:
- 上层模块不应该依赖底层模块,它们都应该依赖于抽象。
- 抽象不应该依赖于细节,细节应该依赖于抽象。
- 什么是上层模块和底层模块?
不管你承认不承认,“有人的地方就有江湖”,我们都说人人平等,但是对于任何一个组织机构而言,它一定有架构的设计有职能的划分。按照职能的重要性,自然而然就有了上下之分。并且,随着模块的粒度划分不同这种上层与底层模块会进行变动,也许某一模块相对于另外一模块它是底层,但是相对于其他模块它又可能是上层。
公司管理,CEO 是整个事业群的上层,那么 CEO 职能之下就是底层。
然后,我们再以事业群为整个体系划分模块,各个部门经理以上部分是上层,那么之下的组织都可以称为底层。
由此,我们可以看到,在一个特定体系中,上层模块与底层模块可以按照决策能力高低为准绳进行划分。
- 依赖倒置原则,究竟倒置在哪里?:
在依赖倒置原则中的倒置指的是和一般OO设计的思考方式完全相反。
举个例子,现在你需要实现一个比萨店,你第一件想到的事情是什么?我想到的是一个比萨店,里面有很多具体的比萨,如:芝士比萨、素食比萨、海鲜比萨……
比萨店是上层模块,比萨是下层模块,如果把比萨店和它依赖的对象画成一张图,看起来是这样:
没错!先从顶端开始,然后往下到具体类,但是,正如你看到的你不想让比萨店理会这些具体类,要不然比萨店将全都依赖这些具体类。现在“倒置”你的想法……别从上层模块比萨店开始思考,而是从下层模块比萨开始,然后想想看能抽象化些什么。你可能会想到,芝士比萨、素食比萨、海鲜比萨都是比萨,所以它们应该共享一个Pizza接口。对了,你想要抽象化一个Pizza。好,现在回头重新思考如何设计比萨店。
图一的依赖箭头都是从上往下的,图二的箭头出现了从下往上,依赖关系确实“倒置”了
另外,此例子也很好的解释了“上层模块不应该依赖底层模块,它们都应该依赖于抽象。”,在最开始的设计中,高层模块PizzaStroe直接依赖低层模块(各种具体的Pizaa),调整设计后,高层模块和低层模块都依赖于抽象(Pizza)
这段转载自https://www.jianshu.com/p/c3ce6762257c
- 接下来结合实际例子说明依赖倒置(DIP)和控制反转(IoC)和依赖注入(DI):
小明要睡觉了,让妈妈给他读故事书。咱们在平常开发时大概会写成这样:
public class StoryBook {
public String getContent(){
return "很久很久以前......";
}
}
public class Mother {
private StoryBook sBook;
public void read(){
System.out.println("妈妈开始读:");
sBook = new StoryBook();
System.out.println(sBook.getContent());
}
}
public class Child {
public static void main(String[] args) {
Mother m = new Mother();
m.read();
}
}
运行结果:
妈妈开始读:
很久很久以前…
不过早晨小明又告诉,让妈妈读报纸给他听,说是老师布置的作业。此时的妈妈只会读故事书,所以要修改Mother方法,让他会读报纸:
class Newspaper{
public String getContent(){
return "现在是早间新闻......";
}
}
public class Mother {
private StoryBook sBook;
private Newspaper newspaper;
public void read(){
System.out.println("妈妈开始读:");
//sBook = new StoryBook();
//System.out.println(sBook.getContent());
newspaper = new Newspaper();
System.out.println(newspaper.getContent());
}
}
这只是很小的示例,如果工程大了,到时改动的不是一点半点。又假如,小明还需要读杂志呢?读别的东西呢?
而依赖倒置原则正好适用于解决这类情况。下面,让我们尝试运用依赖倒置原则对代码进行改造。
我们再次回顾下它的定义:
上层模块不应该依赖底层模块,它们都应该依赖于抽象;
抽象不应该依赖于细节,细节应该依赖于抽象;
首先是上层模块和底层模块的拆分。
按照决策能力高低或者重要性划分,Mother属于上层模块,StoryBook、Newspaper属于底层模块。
上层模块不应该依赖于底层模块。但是我们看到Mother很明显依赖于StoryBook、Newspaper,它的阅读能力read()依赖在StoryBook、Newspaper上。
它们都应该依赖于抽象。但是我们的代码中没有抽象,所以我们得引进抽象。此时我们又该怎么设计这个抽象呢,很明显的妈妈肯定只有一个,小明也只有一个,但是妈妈要读的东西有很多种,而读物又都是不同的,没有抽象父类的意义,所以这里把要读的东西设计为一个接口。而不同的读物提供的内容不同,但它们只要按照接口提供内容,那么妈妈就都可以读了:
public interface IReader {
public String getContent();
}
class StoryBook implements IReader{
public String getContent(){
return "很久很久以前......";
}
}
class Newspaper implements IReader{
public String getContent(){
return "现在是早间新闻......";
}
}
此时的妈妈:
public class Mother {
private IReader iReader;
public void read(){
System.out.println("妈妈开始读:");
iReader = new StoryBook();
System.out.println(iReader.getContent());
}
}
运行结果:
妈妈开始读:
很久很久以前…
现在,Mother类中read() 这个方法依赖于IReader接口的抽象,它没有限定自己读什么类型的书,任何读物都可以的。
到这一步,我们可以说是符合了上层不依赖于底层,依赖于抽象的准则了。那么,抽象不应该依赖于细节,细节应该依赖于抽象又是什么意思呢?
以上面为例,IReader是抽象,它代表一种行为,而 StoryBook、Newspaper都是实现细节。Mother需要的是IReader,需要的是读物,但不是说读物一定是 StoryBook、Newspaper。这既是细节应该依赖于抽象。
- 控制反转 (IoC)
控制反转 IoC 是 Inversion of Control的缩写,意思就是对于控制权的反转,那么控制权是什么控制权呢?
假如小明需要听故事书,但是妈妈此时心情不好,偏要读报纸。。。此时Mother自己掌控着内部 IReader 的实例化。并且还有一个问题,当我们用编程的思想去看上面的代码,虽然Mother可以读各种各样的读物,但是每次变更读物时read() 这个方法还是要修改。
那么我们现在更改一种方式,让IReader不通过Mother实例化,而是交给小明,此时:
public class Mother {
public void read(IReader iReader){
System.out.println("妈妈开始读:");
System.out.println(iReader.getContent());
}
}
public class Child {
public static void main(String[] args) {
Mother m = new Mother();
m.read(new StoryBook());
}
}
就这样无论要读什么,Mother这个类都不需要更改代码了,并且小明可以自己选择读什么。此时的Mother把它的内部的依赖IReader的创建权限转交给了Child的main方法,这种思想其实就是 IoC,它对上层模块与底层模块进行了更进一步的解耦。控制反转的意思是反转了上层模块和底层模块的依赖控制。而 Child 在 IoC 中又指代了 IoC 容器这个概念。
- 依赖注入(Dependency Injection)
其实在上一节中,我们已经见到了它的身影。它是一种实现 IoC 的手段。什么意思呢?
看Mother代码,为了不因为依赖实现的变动而去修改Mother,我们使用IoC模式改写了代码,这个需要我们移交出对于依赖实例化的控制权,那么依赖怎么办?Mother无法实例化依赖了,它就需要在外部(IoC 容器)赋值给它,这个赋值的动作有个专门的术语叫做注入(injection),需要注意的是在 IoC 概念中,这个注入依赖的地方被称为 IoC 容器,但在依赖注入概念中,一般被称为注射器 (injector)。
简单讲就是:我不想自己实例化依赖,你(injector)创建它们,然后在合适的时候注入给我吧。
再比如我们去餐厅吃饭,我们对服务员说给我一副餐具。在这个场景中如果按照正常的编程方式,餐具本身是我们的依赖,但是应用 IoC 模式之后 ,餐具是服务员提供(注入)给我们的,我们不用关心吃饭的时候用什么餐具,因为吃不同的菜品,可能餐具不同,吃牛排用刀叉,喝汤用调羹,虽然我们就餐时需要餐具,但是餐具的配置应该交给餐厅的工作人员。
如果以软件角度来描述,餐具是顾客的依赖,服务员给顾客配置餐具的过程就是依赖注入。
实现依赖注入有 3 种方式:
- 构造函数中注入:即在Mother的构造函数中初始化赋值读物。
- 优点:在 Mother一开始创建的时候就确定好了依赖。
- 缺点:后期无法更改依赖。
- setter方式注入:就是咱们经常用的get,set。
- 优点:Mother对象在运行过程中可以灵活地更改依赖。
- 缺点:Mother对象运行时,可能会存在依赖项为 null 的情况,所以需要检测依赖项的状态。
- 接口注入:
- 优点:
- 缺点:接口注入模式因为具备侵入性,它要求组件必须与特定的接口相关联,因此并不被看好,实际使用有限。
总结:
依赖倒置是面向对象开发领域中的软件设计原则,它倡导上层模块不依赖于底层模块,抽象不依赖细节。
依赖反转是遵守依赖倒置这个原则而提出来的一种设计模式,它引入了 IoC 容器的概念。
依赖注入是为了实现依赖反转的一种手段之一。
它们的本质是为了代码更加的“高内聚,低耦合”。
5.接口隔离原则(Interface Segregation Principle)
这个原则的意思是:使用多个隔离的接口,比使用单个接口要好。它还有另外一个意思是:降低类之间的耦合度。由此可见,其实设计模式就是从大型软件架构出发、便于升级和维护的软件设计思想,它强调降低依赖,降低耦合。
- 定义:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
- 问题由来:假如类A通过接口I依赖类B(大概上文的Mother通过IReader依赖Newspaper),类C通过接口I依赖类D(假设类D要实现开车,在接口I里加入了开车的方法),如果接口I对于类A和类B来说不是最小接口,则类B必须去实现不需要的方法(即多了开车的方法)。
- 解决方案:将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。
- 运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。
6.迪米特法则,又称最少知道原则(Demeter Principle)
- 定义:最少知道原则是指一个实体应当尽量少地与其他实体之间发生相互作用,使得系统功能模块相对独立。 这样,当一个模块修改时,就会尽量少的影响其他的模块,扩展会相对容易,这是对软件实体之间通信的限制,它要求限制软件实体之间通信的宽度和深度。
- 问题由来:类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。
- 解决方案:尽量降低类与类之间的耦合。
- 迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。
7.合成复用原则(Composite Reuse Principle)
合成复用原则是指:尽量使用合成/聚合的方式,而不是使用继承。