《设计模式-可复用面向对象软件的基础》读书笔记

引言

why要学习设计模式

  • 有经验的面向对象设计者的确能做出良好的设计,而新手则面对众多选择无从下手。
  • 内行的设计者更愿意复用以前使用过的解决方案,当找到一个好的解决方案时,他们会一遍又一遍的使用。
  • 设计模式使人们更加简单方便的复用成功的设计和体系结构。

设计模式相关概念

  • 设计模式确定所包含的类和实例的角色,协作方式和职责分配。
  • 设计模式一个主要动机是对变化的概念进行封装

设计模式四要素

  • 模式名(pattern name)
  • 问题(problem)
  • 解决方案(solution)
  • 效果(consequence)

设计模式的目的

  • 灵活
  • 模块化
  • 可复用
  • 易理解

设计模式如何解决问题

  • 寻找合适的对象
  • 决定对象的粒度
  • 指定对象接口
  • 描述对象的实现
  • 运用复用机制
  • 关联运行时刻和编译时刻的结构
  • 设计应支持变化

怎样使用设计模式

  • 1.大致浏览一遍模式
  • 2.回头研究结构部分,参与者部分和协作部分
  • 3.看代码示例部分,看看这个模式代码的具体例子
  • 4.选择模式参与者的名字,使它们在应用上下文中有意义
  • 5.定义类
  • 6.定义模式中专用于应用的操作名称
  • 7.实现执行模式中责任和协作的操作

设计模式的分类

创建型
  • Abstract Factory(抽象工厂)
  • Factory Method(工厂方法)
  • Builder(生成器)
  • Prototype(原型)
  • Singleton(单例)
结构型
  • Adapter
  • Bridge
  • Composition
  • Decorator
  • Facade
  • Flyweight
  • Proxy
行为型
  • Interpreter
  • Template Method
  • Chain of Responsibility
  • Command
  • Iterator
  • Mediator
  • Memento
  • Observer
  • State
  • Strategy
  • Visitor

UML类图关系

在详解各个设计模式之前,先来复现一下UML中怎么表示类的各种关系。

  • 聚合关系是非强依赖的,而组合关系是强依赖的。
  • 继承关系中,实现关系是继承的抽象类,泛化关系是继承的非抽象类。
  • 依赖关系是动态关系,而关联关系是静态关系。

创建型设计模式

创建型设计模式主要用来创建相关对象,将创建和使用解耦,同时隐藏创建的细节等。创建型设计模式的优势是将原本硬编码的创建过程分散到其他类,使创建行为易于扩展。

Factory Method(工厂方法)

工厂方法将对象创建的过程进行了封装,使用者不用关心具体返回的对象类型。在工厂方法中,父类负责定义创建对象的方法,而具体的创建逻辑则放到了子类中完成,这也方便了新类型的扩展,在不修改旧代码的情况下就能正常使用新的类型。

工厂方法各类的关系大致如下图:

Abstract Factory(抽象工厂)

使用抽象工厂模式时,每个子工厂往往需要生产一个产品族的产品,而不单只生产一种产品。这是抽象工厂和工厂的主要区别。

抽象工厂各类的关系大致如下图:

其中,每个子工厂都至少生产了2类产品。而且这2类产品应该是有比较强的关联。比如Factory1是生产apple旗下的iPhone和iPad,而Factory2是生产华为旗下的P30和华为Pad。

Builder(生成器)

生成器比较适合一些需要分步创建的对象,一个生成器会将对象创建过程中的所有需要步骤都抽象出来,并在子类中一一实现,并提供一个返回对象实例的方法。在使用生成器时,我们往往需要一个导演类去帮我们选择生成对象的步骤。

生成器类图:

对于一个比较复杂的对象,创建的过程可能依赖很多上下文,这种对象的创建就比较适合使用生成器来创建。下面是KFC点餐系统应用生成器模式创建订单的例子:

Prototype(原型)

原型模式使用频率并不高,主要是这种模式比较奇葩,会先将对象创建一份在内存中,然后通过拷贝构造的方式生成一个新的对象并返回。

原型的类图:

原型模式必须要提供一个Clone()方法,而且为了保证产生的每个对象都是新对象,需要对自身进行深拷贝操作。原型的好处是它可以减少类的数目,简化创建对象的过程,并将创建的细节隐藏。

Singleton(单例)

单例模式原理简单,对于数据共享和同步是较好的实现方式,所以应用比较广泛。但是容易被滥用导致内存问题等。iOS中的metaclass(元类)就是使用的单例模式。

单例类图:

单例一旦创建了就不会被释放,所以除非必要,还是不要滥用。不能只图一时方便就选择一种并不适合的设计模式,这样其实是违背了设计模式的初衷。

小结

工厂方法使用比较简单,但是扩展性不太强,而抽象工厂有更好的灵活性,同时又增加了系统的复杂度和类的层级。生成器模式适合创建复杂对象,原型模式则更适合创建同时存在多个子类的对象。最后的单例模式使用最方便,但是可能会因为滥用导致一些系统资源问题。通常,一个创建型模式会以工厂模式开始,然后在迭代过程中慢慢进化为更适合自身的模式。

结构型设计模式

结构型模式所做的事情是关于如何组合类和对象以获得更大的结构。它们不是对接口和实现进行组合,而是描述如何对一些对象进行组合,从而实现新功能的一些方法。

Adapter(适配器)

适配器模式又叫Wrapper(包装器),主要用来扩展当前类的接口,适配器模式又分为类适配器和对象适配器两种实现方式,很显然,对象适配器是操作对象,而类适配器是操作类。

对象适配器类图:

类适配器类图:

对于类适配器,可能依赖语言本身的继承体系,比如OC只支持单继承,则不能将多个类的接口统一为一个适配器,只能使用对象适配器或者使用协议去重构类的结构。对象适配器则相对更灵活,而且不会改变类本身的性质。

Bridge(桥接)

桥接模式的一个重要目的是将抽象接口和具体实现进行分离。这样就打破了抽象和具体实现的绑定关系,增强了抽象类的扩展性。而且对实现的修改并不会直接影响抽象类,符合ISP原则。

桥接模式类图:

设计模式中抽象的概念一直都比较重要,良好的抽象能力是掌握设计模式的基础。将抽象和具体实现进行解耦是设计模式的准则。

Composite(组合)

组合模式将对象组合成树形结构以表示部分和整理的层次结构。保证用户对单一和组合对象使用具有一致性。

组合模式类图:

iOS中,UIView就是一个典型的组合模式的应用。UIView在渲染或者处理点击事件时,都会逐一遍历子视图,分别对单个子视图进行处理。

Decorator(装饰)

装饰模式可以动态的给一个类添加职责而不影响改类本身,也不用修改该类的具体实现。同时,装饰比继承更为灵活,方便扩展,且不会造成子类爆炸等问题。

装饰模式类图:

变形金刚的装饰模式应用:

这里需要注意一下装饰类和被装饰类的接口至少需要保持一致,当然对于不同的装饰行为,可以扩展不同的特定接口。

Facade(外观模式)

外观模式通常应用在你需要为一个复杂的系统提供一个简单的接口供外界使用时。外观并不特指UI,而是提供给外界使用这一行为上。

外观模式的类图:

下面是一个编译器项目对于外观模式的应用:

外观模式在提供简单的接口的同时,并不隐藏实现的细节,这样,对于想要了解具体实现的客户也比较友好。外观模式除了符合SRP原则外,同时也隔离了调用者和具体的模块实现,符合ISP原则。

Flyweight(享元模式)

享元模式描述了如何共享对象,使得可以细颗粒度地使用它们而无需高昂的代价。享元模式一般会有一个享元对象的复用池,对象本身会有外部状态内部状态两种状态,通过维护这两种状态实现对象的共享。

享元模式类图:

当项目中使用了大量的相同或者相似对象,并且因为这些对象导致一些内存问题,就应该考虑是否可以采用享元模式来优化对象的使用。

Proxy(代理模式)

当一个客户因为某些原因不能直接使用一个类时,可以通过使用这个类的代理间接使用该类。和装饰模式类似,代理类的接口也至少需要和该类保持一致,同时还可以进行扩展。代理模式也是ISP原则的应用。

代理模式类图:

下面是一个Image代理的实例:

在显示image时,会先创建ImageProxy作为一个占位对象,只有当真正调用draw时才会开始创建image实例。这也是一种延迟加载的策略,对于比较消耗资源的对象比较实用。

结构型模式总结

结构型设计模式的目的就是通过各种类和对象的组合,来扩展出一个拥有更多功能的新的类。
Adapter通过对象适配和类适配两种方式将多个类的功能集成到一个类中。Bridge则是通过给抽象接口增加具体的实现类来实现类的组合。Composite将一个类簇对象递归组合为一个集合类,统一了接口的调用。Decorator虽然和Composite有相似的递归结构,但这也就是两种唯一相似之处了,Decorator强调的是特殊问题特殊处理,可扩展性极强,这点上这正好和Composite相反。Facade将复杂对象组合起来,提供了简单的调用接口,单它和Composite又完全不同,Facade组合的类之间可能存在依赖,但不存在类簇的关系。Flyweight强调的是类的复用机制,Proxy则为类的调用提供了新的方式,即加一个中间层,这也是解决计算机科学中所有问题的终极法宝。

以上七种结构型模式,各有各的优势,不足和适合的使用场景。我们在使用前,首先需要熟悉各个模式的优劣和使用场景,才能更好的去应用,当然,走出第一步才是最重要的,只有先用起来,至于是否是最优选择,可以在代码重构中慢慢优化,这样边用边优化,总结出使用心得,最终才可能真正的掌握,到达灵活使用,信手拈来的境界。

行为型设计模式

行为模式涉及到算法和对象间职责的分配。行为模式不仅描述对象或类的模式,还可以描述它们之间的通信模式。这些模式刻画了在运行时难以跟踪的复杂的控制流。模式模式的作用就是将注意力从控制流转移到对象之间的联系方式上来。

Chain of Responsibility(责任链模式)

当多个相关联的类需要处理同一个事件请求,而请求发起者又不能依赖所有可能的接收者时,可以将所有的接收者组成一条链,使请求沿着这条链传递下去。责任链模式往往和上面的Composition模式同时使用,因为Composition模式很容易提供这种链式结构。

责任链类图:

iOS响应链正是这种设计模式的应用,hitTest方法就类似上图中的HandleRequest()方法,而Event对象则是一个Request,通过参数的形式传递给所有的响应者。每个响应者可以在自己内部重写hitTest方法,决定是否需要处理某个事件。

Command(命令模式)

在软件设计中,我们经常需要向某些对象发送请求,但是并想依赖请求的接收者,我们只需在程序运行时指定具体的请求接收者即可,此时,可以使用命令模式来进行设计,使得请求发送者与请求接收者消除彼此之间的耦合,让对象之间的调用关系更加灵活。

命令模式类图:

在实现命令模式时,我们一般会创建一个命令抽象类,然后继承出具体的命令子类,而命令的接收者我们会以参数的形式传递给各命令子类。在调用该命令时,我们通过使用命令子类创建一个Invoker操作类来发送命令,这样就将命令接收者和调用者成功解耦。

电视遥控实例:

编辑软件的redo和undo实现就是Command模式很好的一个应用,会有一个命令列表去维护之前的所有操作,需要进行redo或者undo时,只需要遍历一下这个列表,重新处理对应的Command。

Interpreter(解释器)

如果一种特定类型的问题发生的频率足够高,那么可能就值得将该问题的各个实例表述为一个简单语言中的句子。这样就可以构造一个解释器,该解释器通过解释这些句子来解决该问题。字符串的模式匹配就是一个很好的例子,字符串在进行匹配的时候可能会有多种模式,比如是否重复,是否结束等,我们可以构建一个抽象的语法树,然后为每一种模式实现一种语法的解释算法,这样就产生了一个字符串模式匹配的解释器。

解释器模式类图:

解释器在正则表达式和编译器中都有大量的应用场景。比如编译器中的AST,就是一个语法解释器。而正则中对各种字符串模式的匹配算法,也是一个解释器应用。

Iterator(迭代器)

迭代器主要用来进行列表的遍历,当需要访问列表中的元素而又不想暴露元素的具体内部结构时,就可以使用迭代器来实现。同时,迭代器也解决了访问不同类型对象需要不同判断条件的问题。

迭代器依赖具体的列表实例。针对不同的遍历策略,可以生成不同的迭代器,而无需对具体列表实例进行任何操作。这样就分离了数据和具体的遍历操作。比如我们想要过滤列表则只需要创建一个具体的过滤迭代器。

下图是一个多态迭代器类结构,即每一种列表类型对应一种迭代器模型,迭代器本身不需要再去判断当前聚合类型,这样迭代机制就与具体的聚合类无关。

下图是迭代器的具体创建逻辑,创建过程采用了工厂模式,每一种集合创建一种对应的迭代器。

迭代器的具体控制方式又分为两种,即外部迭代器和内部迭代器。外部迭代器的控制条件需要通过参数传递给迭代器。外部迭代器比内部迭代器更灵活,比如外部迭代器可以很容易实现判断两个具体元素是否相等。

Mediator(中介者) *

如果多个类之间存在各种依赖关系,而且这些类又可能存在各种扩展类,这种情况下如果在每个类中各自处理依赖关系,会导致各类之间的大量强关联,增加代码的复杂度和维护成本,降低了类和模块的可扩展性。这种情况下,可以引入中介者模式来处理类的依赖。

中介者提供了一个中间人,其他各类如果需要相互调用,只需要调用中介者,而不需要调用具体的类,一切都交给中介者来操作。下图是中介者模式中各类之间的依赖关系:

需要注意的是中介者和调用它的类之间是可能存在相互依赖的关系,如果这种相互依赖最终导致了循环引用产生内存问题的话,可能需要想办法打破这种依赖,可以参考DIP原则,下图是一个中介者模式的类图:

中介者模式是解决对象之间相互通信时好的选择,它减少了子类生成,将原本耦合的依赖关系解耦,将多对多的调用关系简化为一对多,并且对各对象的调用进行了抽象和封装,方便维护和扩展。

MEMENTO(备忘录)

备忘录模式旨在对对象实例进行合理的保存,在不过度暴露对象内部实现的前提下,对对象行为进行取消或者恢复。备忘录模式可以理解为一种持久化对象的方式。从出发点来看,备忘录和命令模式有一定的相似性,都能取消和恢复对象的行为,但是命令模式强调的可能是一连串的操作,而且命令模式的优势是将命令的发送者和接受者做了解耦,而备忘录则更强调对象和备忘录的关联关系,即原发器和备忘录的关系。

下图是备忘录模式的类图关系:

Observer(观察者) *

观察者模式提供了一种方式,保证了依赖同一个对象的多个类的一致性,当这个对象的状态改变时,可以很方便通知到这些依赖类。

在面向对象的项目中,我们往往会将模块进行各种分割和封装,这样各个模块在共享数据时就会产生问题,如果每个模块都对数据进行各种强的依赖,则会破坏整个系统封装,扩展和重用性。这个时候就可以引入观察者模式来解决数据同步的问题。

观察者模式类图:

在iOS系统中,观察者模式也是一种比较常用的和推荐的设计模式,因为它很好的解决了各模块之间的数据一致性问题,但是,也因此产生了比较多的问题,最常见的就是各种崩溃,比如观察者的重复删除问题,以及野指针问题,iOS中比较好的解决方案比如FB的KVOController,就是自己去维护一个观察者的弱引用列表,在进行观察者的添加,删除和触发通知时,先进行容错,避免崩溃。

State(状态)

当一个类的行为变化依赖于一种或者多种条件时,这些条件可以称为这个类的各种状态,这种场景也是状态模式的由来。

比较典型的状态模式比如TCP的连接状态,我们先来复习一下TCP都有哪些连接状态。

建立连接时

主叫方的状态有:

SYN_SENT -> ESTABLISHED

被叫方的状态有:

LISTEN -> SYN_RCVD -> ESTABLISHED

断开连接时

主叫方的状态有:

FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT -> CLOSED

被叫方的状态有:

CLOSE_WAIT -> LAST_ACK -> CLOSED

一个TCP连接存在多种状态,当处于不同状态时,又将产生不同的行为,这里状态是tcp连接类的一个实例,tcp连接对外暴露的API,都依赖该状态实例,不同状态产生不同行为,具体类图如下:

除了上面的场景外,状态模式还有一个比较适用的场景是一个操作中有大量的条件判断或者switch分支,这时可以抽象一个状态类,将每一种分支封装为一种具体状态,条件执行语句则在各封装子类中去实现,这样外部只需调用统一的抽象接口即可。

STRATEGY(策略)

当你在处理某种case时,有多种处理方法,这些处理方法可以被称为不同的算法,如果我们将这些算法硬编码到处理类中,该类就失去了扩展性,这些算法也没有了被复用的可能,所以,我们可以抽象出一个策略类,每种算法去实现具体的策略子类,处理类只需要根据情况调用不同的策略子类即可,这就是策略模式。

下面是策略模式的类图结构:

对比上面的状态模式,可以发现,这两个模式的类图结构基本一致,只是应用的场景不同,状态强调的是一种属性状态,而策略强调的是一种处理算法,两者在应用场景上还是有比较明显的差别。

Template Method(模版方法)

在一个类簇中,子类可能需要共享一套API接口和调用逻辑,但是又需要有自己特定的行为,这种场景就可以使用模板方法来实现,可以将API的实现模板写在父类,而具体的逻辑分散到各个子类中去实现。下图为模板方法的类图:

模板方法是一种代码复用的基本技术,它们在面向对象语言和类簇中尤为重要,可以减少大量重复代码,简化代码逻辑。在子类中实现的逻辑,又称为钩子操作。模板方法也是我比较喜欢使用的一种设计模式。

Visitor(访问者)

当我们需要对一系列元素进行一系列操作,而且每一种操作不一样时,我们可以将这些操作抽象为访问者,每一种操作则为一个具体的访问者子类,该子类为每一个元素提供一个操作方法供元素调用。同时,我们还需要将元素也进行抽象,然后生成具体的元素子类,每一个子类根据自身需要接收不同的访问者。

该模式将调用者和被调用者进行了解耦操作,对访问者进行了封装,方便了访问者的扩展。不过元素缺比较难被扩展,因为每扩展一个元素,需要对之前所有访问者进行修改。

行为型设计模式的几个出发点

  1. 对变化的封装(策略,状态,中介者,迭代器)
  2. 使用对象作为参数(命令,备忘录)
  3. 通信时被封装还是被分布(中介者,观察者)
  4. 对发送者和接受者的解耦(命令,观察者,中介者,)