1 前言
看过许多关于设计模式的博客,也读过关于设计模式的书。几乎所有的介绍的开头,直接就引入了“设计模式”或者“某某模式”。设计模式到底是因什么而来?这是一个很重要的问题。孙悟空从石头缝里蹦出来,《西游记》还介绍了这个石头的来历呢。
要想了解一个东西,至少有“3W”——what、why、how——是什么、为什么、怎么用。看现在大部分的文章或者书籍,重点介绍的还是“what”,这就有点类似于:为了用设计模式用设计模式。在这种思想的教导下去了解设计模式,学不会也很正常。
另外,介绍一个东西的用处时,不要弄一些小猫小狗、肯德基、打篮球、追MM这话总例子。这就像用小学课本的儿童故事来给你讲解一个人生道理一样,你听着明白,但是真能理解吗?
2 概述
记得之前看过一篇博客,具体内容现在都忘记了。记得上面有句话大体是什么说的:所谓的设计模式,我们日常工作中经常用,只是我们没有想过像GoF一样,把这些日常用到的模式总结归纳,形成结构化的理论。
可见,设计模式不真正是GoF提出的概念,而是他们作为一个有心人,把人们日常工作中遇到的设计问题,全面的总结,才形成了之后的“23种设计模式”。
首先,设计模式解决的肯定是系统设计问题,而且会用到面向对象来解决的。所以,本书开头先说设计原则和面向对象。面向对象基础知识,大部分人应该都了解;至于设计原则,不了解的人必须要先了解。
其次,我们将模拟一个简单的对象声明周期过程,从对象的创建、封装、组合、执行和操作,一步一步走来,会遇到许多情况和问题。针对问题,我们将通过思考,利用面向对象和设计原则,解决这个问题。而解决这个问题的方法,便是一种设计模式。
最后,23种设计模式不是一盘散沙,是有关系的。就是对象的生命周期一步一步的将各个设计模式串联在了一起。对象的生命周期中,会一步一步的遇到总共23种设计问题,所以才会有23种设计模式。
3 设计原则
设计模式解决的肯定是系统设计的问题,所以首先从“设计”说起。
设计所要解决的主要问题,是如何高效率、高质量、低风险的应对各种各类变化,例如需求变更、软件升级等。设计的方式主要是提取抽象、隔离变化,有5大设计原则——“SOLID”,具体体现了这个思路。
- S - 单一职责原则:
一个类只能有一个让它变化的原因。即,将不同的功能隔离开来,不要都混合到一个类中。
- O - 开放封闭原则:
对扩展开放,对修改封闭。即,如果遇到需求变化,要通过添加新的类来实现,而不是修改现有的代码。这一点也符合单一职责原则。
- L - Liskov原则:
子类可以完全覆盖父类。
- I - 接口隔离原则:
每个接口都实现单一的功能。添加新功能时,要增加一个新接口,而不是修改已有的接口,禁止出现“胖接口”。符合单一职责原则和开放封闭原则。
- D – 依赖倒置原则:
具体依赖于抽象,而非抽象依赖与具体。即,要把不同子类的相同功能抽象出来,依赖与这个抽象,而不是依赖于具体的子类。
总结这些设计原则可知,设计最终关注的还是“抽象”和“隔离”。面向对象的封装、继承和多态,还有每个设计模式,分析它们都离不开这两个词。
4 面向对象基础
继承、封装、多态
接口、抽象类
5 一个对象的生命周期
一个对象在系统中的生命周期可以概括为以下几点:
- 对象创建:
想到对象创建,最多的就是通过new一个类型来创建对象。但也会有许多特殊的情况,例如对象创建过程很复杂,如何解耦?等等。
- 对象组合、包装:
一个对象创建后,可能需要对其就行包装或者封装,还可能由多个对象组成一个组合结构。在这过程中,也会遇到各种问题。
- 对象操作:
对象创建了,也组合、包装完毕,然后就需要执行对象的各种操作,这是对象真正起作用的阶段。对象的操作情况众多,问题也很多。
- 对象消亡:
直到最后对象消亡,在C#中将被GC回收。
以上简单介绍这个过程,其中的具体描述以及遇到的情况和问题,会在下文中详细讲解
6 创建一个对象
6.1 过程描述
一般对象的创建可以new一个类型,相信系统中绝大部分的对象创建也是这么做的。但是如果遇到以下情况,直接用new一个类型,会遇到各种各样的问题。
6.2 情况1:拷贝创建
系统中肯定会遇到这种情况,新建对象时,要用到一个现有对象的许多属性、方法等。这时候再通过new一个新的空对象,还需要把这些属性、方法都赋值到新对象中,带来不必要的工作量。
提出这个问题,我们会想到克隆,也可能已经在系统中用到了克隆。其实这个就是一个比较简单的设计模式——原型模式。我们把这个“克隆”动作抽象到一个接口中,需要克隆的类型,实现这个接口即可。
6.3 情况2:限制单一对象
如果一个对象定义的属性和方法,可供系统的所有模块使用,例如系统的一些配置项。此时无需再去创建多个对象。也不允许用户创建多个对象,因为一旦修改,只修改这一个对象,系统的所有模块都将生效。
我们把这个只能实例化一次的对象叫做“单例”,这种模式叫做单例模式。
其实系统中的静态类,就是这种“单例”的设计思想。例如FCL中的Console类,它是一个静态类,它给系统提供的就是一个“单例”类。
6.4 情况3:复杂对象
创建一个新对象时,一般需要初始化对象的一些属性。简单的初始化可以用通过构造函数和直接赋值来完成。
但是如果一个对象的属性过多,业务逻辑很复杂,就会导致复杂的创建过程。这种情况下,用构造函数是不好解决的。如果用直接赋值,就会导致大量的if…else…或者switch…case…的条件判断。这样的代码将给系统的维护和扩展带来不便,而且如果不改变设计,会随着维护和扩展,会出现更多的条件判断。随着代码量的增加,维护难度更大。如果再是多人同时维护,那就麻烦了。
显然,这样的代码不是我们所期望的。设计上也不符合单一指责原则、开放封闭原则。所以,对于一个复杂对象的创建过程,我们将考虑重构。
我们把对象创建的过程抽象出来,做成一个框架,然后派生不同的子类,来实现不同的配置。将复杂对象的构建与其表示分离,这就是建造者模式。
所以,我们将对象创建过程抽象到一个Builder抽象类中,然后用不同的子类去实现具体的对象创建。这样的设计相比之前大量的if-else-代码,优势是非常明显的,并且符合单一职责原则和开放封闭原则。应对需求变更、新功能增加、多人协同开发都是有好处的。
6.5 情况4:功能相同的对象
最经典的就是数据操作。创建一个用于SQL server的SQLDBHelper类,又创建了一个用于Oracle的OracleDBHelper类,这两个类所实现的功能是完全一样的,都是增删改查等。如果这两个类是孤立的,那系统数据库切换时候,将导致SQLDBHelper和OracleDBHelper两个类之间的切换,而且改动工作量随着系统复杂度增加。
而且如果增加一个数据库类型,也会导致系统代码的大量修改。
这个问题的根本是违反了依赖倒置原则。客户端应该依赖于抽象,而不是具体实现。我们应该把数据操作的功能抽象出来,然后通过派生子类来实现具体。
面对不同的数据库,我们需要判断并创建不同的实现类。
可以把这段代码封装成一个方法,这就是一个简单的“工厂”。所谓工厂,就是封装一个对象创建过程,对于一种抽象,到底由哪个具体实现,由工厂决定。
这是一个简单工厂模式。另外,工厂方法模式、抽象工厂模式也都是在这个基础上再去抽象、分离,而出来的。
6.6 总结
对象创建并不是new一个类型这么简单,以上四种情况在日常开发过程中应用也都比较常见。
上面通过对象创建过程的种种情况,随之介绍出了:原型模式、代理模式、建造者模式、工厂模式。虽然现在还不能完全了解这些模式的细节,但是至少明白了这些模式应对什么问题,有了明确的定位。而这才是最关键的,有了定位,有了高层次的理解,再看细节就变得容易多了。
7 多个对象组成结构
7.1 过程描述
上一节介绍了如何创建一个对象。但大多数情况,一个对象是不够用的,这时候就需要把对象包装、封装、多对象组合。有时候还需要将一个组合作为一个整体使用,组合要提供对外的接口,也可能会用到系统原有的接口。
下面针对每种情况详细介绍。
7.2 情况1:借用外部接口
有开发经验的人知道,日常大部分开发都是在已有系统基础上开发的。即便是从新开发的系统,也要依赖于一个框架或者库。
所以,我们每时每刻都在用系统已有的接口。但是如果这些接口不满足我们的需求,我们就需要重新对接口封装一下,让其符合当前的规则。就是这个我们日常用的技巧,被GoF总结成为一个模式——适配器模式。
不用看代码和类图,也能明白它的意思。不必太计较代码和类图的细节,重点在于理解设计思想。
顾名思义,适配器就是做一个隔离,起到了解耦的作用。例如我们日常用的笔记本电脑适配器。
7.3 情况2:给对象增加新功能
系统总是在不断的维护和升级当中,也可能在不断的需求变更当中,因此为对象增加新功能,是再常见不过的了。那么如何为对象增加新功能呢?
最直接的回答就是改代码呗。改类型的代码,增加方法、属性等。
对于这种修改,首先想到的应该是违反了“开放封闭原则”和“单一职责原则”,违反这种原则带来的坏处很多。代码越改越多;每次更改都有可能影响以前代码;多人维护一个文件,不利于协同开发……
如果用“抽象”“隔离”的思想来思考这一问题,很容易就能找出思路:第一,把原有功能和新增功能隔离;第二,两者都依赖于一个抽象,这个抽象就是对象应该有的所有功能;第三,外部客户将依赖于抽象,它不会察觉内部的变化(依赖倒置原则)。
这就是装饰模式。
从上面的类图看,ConreteComponent是原始类型,Decorator是一个抽象类,它的派生类负责添加新功能。这里理解的重点,在于Decorator类中有一个Component属性,相当于Decorator封装了一个Component,直接调用他原有的功能,并且可以新增功能。当然,这些操作都是可以派生在子类中实现的。而且不同的子类可以实现增加不同的功能。
这样的抽象和分离就符合开放封闭原则和单一职责原则,也不会出现代码量过多、多人维护不便等问题。
7.4 情况3:封装功能
对于有些功能,我们不希望客户端直接调用,而是在调用时候先做一个判断,或者加一个缓存。其实就是在真实功能和客户端之间,加一个中间层。而这不能让客户端调用察觉。
如果你把这个中间层直接加入到真是功能中,虽然这可以不让客户端察觉,那将会给系统带来隐患,违反“单一职责原则”。如下:
首先,如何不让客户端察觉?答案很简单——依赖倒置原则——让客户端依赖于一个抽象。这个抽象将如何实现呢? 具体的实现和中间层都要去实现。如下:
类图如下:
这就是代理模式。
每个设计模式要体现的都是一种设计的思路,代理模式就是要在客户端和底层实现加一层,在该层中实现一些业务场景。4s店就是客户于汽车厂家的代理。
具体是否要都去实现同一个接口,这种细节不重要,不要去过于纠结这些类图和代码。
7.5 情况4:递归关系的组合
上文提到如何更有效率的维护对象功能和新增功能,以及更有效率的封装对象。这两种做法的输出,其实还是一个单个的对象。如何将一个个对象组合成一个视图,系统中最常见的无非是两种——列表、树,以及两者的结合体——TreeGrid
列表是比较简单的结构,按实际的需求应用,不会产生太多误解。而树结构却有值得讨论之处。最简单的树节点实现的代码如下:代码很简单,只有节点的名称,和对代码下级节点的管理。
如果我们应对的业务很简单,例如类似于windows系统的文件夹树,即每个节点的类型都一样,每个节点的功能也都一样,叶子节点和摘要节点在功能上没有区别。这种情况下,可以用以上代码轻松应对。
但是如果遇到以下情况呢,如下图:
这也是个树结构,但是每个节点类型都不一样,形式的功能也不一样,“个人”是个叶子节点,不能再添加下级节点。这种情况下,再用以上那段代码就会出现许多问题,如多个功能集中在一个代码文件中,多人维护一段代码等。
如何解决这一问题,组合模式给予我们灵感。
根据以上类图,可以看出组合模式解决这一问题的思路是:将树结构中的节点的统一功能抽象出来,不同类型的节点,用不同的子类去实现。类图中只有两个子类,我们可以根据自己的实际情况来派生多个子类。
这样解释想必大部分人都能理解该模式的设计思路,不必再用代码挨着表达了。关键在于理解如何分析问题,如何抽象,如何隔离,如何解耦,最终就是如何设计。
这样设计符合开放封闭原则、职责单一原则,对于客户端也符合依赖倒置原则。
7.6 情况5:分离多层继承
在对象组合过程中,难免会出现继承的情况,甚至会出现多层继承。根据设计原则——少继承、多聚合。因此不建议我们使用多层继承。而是尽量把这种多层继承的关系,变成聚合的关系。
在一个多层继承结构中,如果底层节点可以抽象出相同的功能,即可变为聚合关系。如:
如上图,子类可以提取出一个抽象。变成这样的设计:
这样就把多继承变成了聚合。
可以总结归纳以下这种情况。我们把左侧“发送消息”及其子类叫做“抽象”,右侧的“发送方式”及其子类叫做“实现”。那么我们现在做的就是从“实现”和“抽象”的继承关系,变成了两者的聚合关系。
这就是——桥接模式。以下是类图:
他应对的场景是抽象有多样性,实现也有多样性。抽象的抽象只依赖于实现的抽象。从而解耦抽象和实现的关联。
7.7 情况6:封装组合,供外部使用
当一个组合封装完成后,要提供统一的接口供外部客户端使用。而不是让客户端在组合内部任意的调用。
这就是外观模式。很好理解,也经常用到,可能只是不知道这个名字而已。它像一个包袱一样包起来组合,只留规定的接口。
外观模式简单易懂,不需要类图和代码过多解释。
7.8 总结:
(注:未包括“Flyweight享元模式”。将在后续版本更新中加入。)
其实以上这几种情况,就是结构性的设计模式对应的问题,每种情况对应一种设计模式。结合自己或多或少的开发经验,仔细考虑分析这几种情况,肯定每种情况都是你在编码中遇到的,也是一个对象组合很可能需要的。
遇到这些问题时,你当时是怎么解决的?不一定非得按照设计模式上的解决方式。但是要已定符合设计原则。设计模式只是一个“术”,提供一个解决思路或者灵感,而设计原则、设计思想才是“道”。