Preface
本文是对《Head First设计模式》的归纳整理。
本文概述
原书为了加深读者的理解和记忆,使用了程序设计案例分析的方式层层递进地介绍设计模式,非常适合入门设计模式。不过,该书更侧重使用案例建立对设计模式的初步理解,而非系统地、完备地介绍设计模式概念,容易造成对设计模式的认知高度依赖书中具体案例的情况(理解了书中的案例,却没有完全理解设计模式本身),且比较不便于后续查阅和复习。
因此本文旨在对书中内容进行重新归纳梳理。主要的工作是:总结书中介绍的设计模式,结合书中程序设计案例进行说明并使用其他设计模式资料作为补充,以便建立对设计模式更为系统的认知。
本书使用JAVA构建例子(原书代码),由于我较常用C++,因此在学习过程中使用C++重新实现了一部分典型示例代码,代码可在我的Github仓库获取。
书籍简介
该书并非传统意义上的教材,并没有使用引入概念、解释概念的教学方式。相反,其致力于使用通俗易懂、简单而典型的案例进行引入,引导读者思考如何优化程序设计,并最终引入对应的设计模式。
该书非常适合已基本了解OOP而急需了解程序设计方面最佳实践的程序员。我愿称之为设计模式入门必读书籍。
设计模式实际上有很多,经典的四人帮(GoF)基础设计模式、架构模式、游戏设计模式等等,本书并没有覆盖设计模式的方方面面,而是聚焦于GoF模式中最核心最常用的部分。
以本书为引子,想必能顺利啃下市面上绝大部分的设计模式书籍了。
OO设计原则
书中提到设计原则,可谓是贯穿设计模式的主线,所有设计模式都或多或少的基于这些设计原则进行考量。理解这些设计原则有助于从根本上理解设计模式。
这些设计原则散布在原书中的各个部分,本章将其提取出来放在一起,并作必要解释。
封装变化的部分
识别应用中变化的方面,把它们和不变的方面分开
把会变化的部分取出并封装,从而可在后续修过或扩展该部分,而不会影响其他不需要变化的部分
Interface Oriented Programming - 面向接口编程
针对接口编程,而不是针对实现编程
“针对接口编程”的真正含义是“针对超类(基类)编程”,或者说,变量所声明的类型应该是基类,通常为抽象类或接口,这样,分配给这些变量的对象可以是基类的任何具体实现,这意味着类声明时不必知道实际的对象类型,使得我们能够在运行时分配具体的实现对象
具体而言,可将类的一些行为放在分离的类中,实现特定行为接口的类。这样,进行那些行为的类不需要知道行为的任何实现细节。
注:这一点通常依赖于OOP的多态特性,比如使用虚继承、用基类指针指向派生类对象
Composite Reuse Principle - 组合复用原则(组合优于继承)
优先使用组合而非继承
Has-A can be better than Is-A
组合与继承的使用,通常都是为了达到代码复用的目的。
以继承实现复用,有时候并不怎么便于维护。而使用组合往往有助于解决问题。
具体来说,我们考察下列情况:
假设,Derived类通过继承Base类获得其中的行为,而每当有新的Derived类加入时,程序员不得不对其进行检查,必要时还需要在Derived类中重新实现这些行为以覆盖Base类的行为。这种情况下,非但不能达到复用的目的,还会使得程序员耗费更多精力完成新的实现。
而在组合关系下,假设,A类中包含(拥有)Behavior类,当A类需要进行Behavior类中定义的行为时,相当于是将事情转交由Behavior类进行完成,而A类并不需要知晓Behavior类完成行为的细节。而当有新的一个B类加入时,它也可以通过组合其所需的Behavior类实现相应行为,这种情况下,相比使用继承,组合关系下的类可以只知道其可以这样调用行为,而行为的具体执行交由其组合类完成,无需在本类中实现新方法、覆盖基类行为。
注:侯捷老师的《面向对象高级编程》课程中也简要提及过如上所述的类之间的关系,如果你是一位对OOP还不甚了解的C++程序员,抑或是想要复习一下OOP相关内容,我都十分建议你去学习一下侯捷老师的这套课程
本书中出现的组合关系,实际上更像是委托关系。在C++中,委托关系就是类中包含指向行为接口类的指针,这样,当该类需要进行那些行为时,可以通过指针将进行行为的工作“委托”给接口类,从而不需要知道其行为的具体实现。
注:我不太了解JAVA,只知道JAVA不使用指针,所以我并不确定这部分的组合和委托在JAVA、C#之类的完全面向对象的编程语言中是否是一回事。据我了解,在C++中组合通常指一个类中包含另一个类的对象,而委托则是一个类中包含指向另一个类的指针。前者是“我中有你”,是实实在在的拥有(Has-A);而后者则更像是“当我需要你为我做事的时候,我可以通过指针找到你,并将事情委托给你”
松耦合设计
尽量做到交互的对象之间的松耦合设计。
松耦合设计允许我们建造能够应对变化的有弹性的OO系统,因为对象之间的互相依赖降到最低。当两个对象松耦合时,它们可以交互,但是通常对彼此所知甚少。
前述的优先使用组合就是一种典型的实现松耦合的方法,相比继承关系中的紧密耦合,组合关系使得类间的依赖降低,使得我们可以较容易地添加或移除其中的组合类,类的改变较少相互影响,同时我们还能够较为独立地复用每一个类。
Open-Closed Principle - 开放-关闭原则
类应该对扩展开放,但对修改关闭。
开放-关闭原则允许类容易扩展以容纳新的行为,而不用修改已有代码。这样的设计可以弹性应对改变,有足够弹性接受新的功能来应对改变的需求。
注意,对于程序设计的每一部分都花费时间严格遵循开放-关闭原则是奢侈的,甚至是浪费的。因为让OO设计有弹性,对扩展开放,又不修改已有代码,这需要大量时间和精力。同时,遵循开放-关闭原则往往会引入新的抽象层次,这会增加代码的复杂度。
Dependence Inversion Principle - 依赖倒置原则
依赖抽象,不依赖具体类。
这个原则看起来很像“针对接口编程”,但是更强调抽象。
依赖倒置原则说明,高层组件不应该依赖于底层组件,并且两者都应该依赖于抽象。
假设有一用户类A,其中的行为依赖于一系列具体类来定义,设这些具体类为B1,B2,B3,B4,…,此时A类作为高层组件依赖于这些作为低层组件的具体类。这种情况下,根据依赖倒置原则,应该新增一层抽象类B,所有上述具体类派生自这个抽象类,而用户类A仅需要调用抽象类B。此时用户类A不再依赖于具体类,而是依赖于抽象类B,而B1,B2,…等这些具体类也依赖于抽象类B。
从类图上看,原本用户类向下依赖于各具体的低层组件,更改后用户类仅依赖于具体类的抽象,而具体类则反过来向上依赖于抽象类,看上去像是依赖关系被倒置了,依赖倒置原则也得名于此。
Least Knowledge Principle - 最少知识原则
又称Law of Demeter - 迪米特法则
最少知识原则要求设计系统时,对于任何对象,都要注意它所交互的类的数量,以及它和这些类如何交互。以减少类之间的依赖。
最少知识原则提供了以下指南:对于任何对象,从该对象的任何方法,只调用属于以下范围的方法:
- 对象自身。
- 作为参数传给方法的对象。
- 该方法创建或实例化的任何对象。
- 对象的任何组件。
但是,最少知识原则会导致越来越多的“包装”出现,这会造成系统复杂度的提升,导致运行性能下降。
Hollywood Principle - 好莱坞原则
Don’t call us, we’ll call you.
Call是双关,即可形象地指演员只能等演艺公司来打电话联系他们,而不能主动联系演艺公司,又可以指函数的调用。
当高层组件和低层组件相互依赖,甚至连横向组件又于前两者互相依赖,“依赖腐烂”便发生了,此时没人能轻易理解系统是怎么设计的。
好莱坞原则可以有效防止上述情况。基于这种原则,低层组件被允许挂钩进系统,而由高层组件决定何时需要它们以及怎样利用它们。也就是说高层组件对待底层组件的态度就是Don’t call us, we’ll call you.
以模板方法模式为例,定义了模板方法的抽象类是高层组件,而其具体实现某些步骤的子类是低层组件。高层组件只有在模板方法中需要子类实现的某个方法时,才会调用子类,而子类则不会直接调用抽象类。
Single Responsibility Principle - 单一责任原则
一个类应该只有一个变化的原因。
一个类的责任就是它变化的原因,当一个类需要承担多个责任时候,它便有了更多变化的原因,更有可能不得不被修改,因此也更容易导致问题的出现。(变化的可能性增加往往意味着问题出现的可能性增加)
于是,应该尽可能地只将一个责任分配给一个类而且仅一个类。
以迭代器模式为例,它便是通过迭代器将管理数据结构和遍历数据的责任拆分开来。
OO设计模式
本章正式进入设计模式的内容,对于书中出现的常见设计模式,逐一对其定义、特点、案例、优缺点等进行简要归纳。
Strategy Pattern - 策略模式
定义
策略模式定义了一个算法族,分别封装起来,使得它们之间可以相互变换。策略让算法的变化独立于使用它的客户。
策略模式属于行为型模式,它通过对算法进行封装,把使用算法的责任和算法的实现分割开来,并委托给给不同的对象对这些算法进行管理。策略模式通过将算法与使用算法的代码解耦,提供了动态选择不同算法的方法。
策略模式中的核心部分包括:上下文(Context), 抽象策略(Abstract Strategy), 具体策略(Concrete Strategy)。其中,环境维护对策略对象的引用,抽象策略定义策略对象的公共接口,而具体策略实现这些接口。
案例
以书中SimUDuck应用来说,其中FlyBehavior和QuackBehavior便是被封装起来的算法族(行为),是抽象类,在例子中是抽象策略。
而诸如FlyWithWings, FlyNoWay的类继承前述的抽象类并具体实现其接口,它们是具体策略。
Duck类作为上下文维护着指向FlyBehavior和QuackBehavior类型的指针,通过使指针指向具体策略类来替换Duck的行为,而无需改变自身的设计。
完整的UML如下:
Pros & Cons
Pros:
- 算法/行为可以自由切换;
- 避免使用多重条件判断;
- 扩展性良好
Cons:
- 算法/行为接口类会增多;
- 所有算法/行为接口类需要对外暴露
Observer Pattern - 观察者模式
定义
观察者模式是一种行为型设计模式,它定义了对象之间的一对多依赖。当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新。
观察者模式中,被订阅的对象称为主题(Subject),它是具有状态的对象,并维护者一个**观察者(Observer)**列表。
观察者是订阅主题的对象,是接受主题通知的对象,观察者需要实现一个更新(Update)方法,当收到主题的通知时候,调用更新方法进行响应。
具体主题(Concrete Subject) 是主题接口的具体实现类,维护者观察者列表,并在状态发生变化时通知观察者。
具体观察者(Concrete Observer) 是观察者接口的具体实现类,实现了更新方法,并在收到具体主题通知时调用自己的更新方法进行响应。
注:通常不应该期望主题的通知按照一定次序到达观察者
案例
以书中WeatherStation案例为例,其中的Subject为抽象主题。而WeatherData类是对抽象主题接口的实现,是具体主题。
Observer是抽象观察者,而具体Display类(如CurrentConditionsDisplay, StatisticDisplay等)是对抽象观察者接口的实现,是具体观察者。
当WeatherData有新数据时(即状态发生改变),则依次通知其维护的Observer List中的所有观察者,收到通知的观察者调用各自实现的Update方法进行更新。
观察者对象通常维护着对主题对象的引用,这为将来可能发生的观察者取消订阅提供便利。
注:个人认为本例中可以设计为DisplayElement为继承Observer接口的接口,在此基础上加入抽象display方法,这样DisplayElement便成为了所有Display类型的接口。而所有具体的Display类都直接继承自DisplayElement,具体实现各自的update和display方法。这样类关系可能更加整洁一些
Pros & Cons
Pros:
- 主题与观察者之间是抽象耦合的,是一种松耦合
- 建立了一套触发机制
注:抽象耦合指的是类之间的依赖关系是通过抽象的接口实现的
Cons:
- 主题需要依次通知所有观察者,如果一个主题有很多直接和间接的观察者,通知到所有观察者的过程会比较耗时
- 如果主题和观察者之间有循环依赖,观察目标会触发它们的循环调用,导致系统崩溃
- 观察者模式没有相应的机制让观察者知道所观察的目标对象是怎样变化的,观察者仅仅知道观察目标发生了变化。
要点
- 观察者模式定义对象之间的一对多关系。
- 主题使用通用接口更新观察者。
- 任何具体类型的观察者都可以参与该模式,只要它们实现观察者接口。
- 观察者是松耦合的,主题只知道它们实现观察者接口,对其他细节并不知情。
- 使用该模式时,可以从主题“拉”数据。
- 观察者模式和出版/订阅模式相关,后者用于更复杂的主题或多消息类型的情形。
Decorator Pattern - 装饰器模式
定义
装饰器模式动态地将额外责任附件到对象上。对于扩展功能,装饰器提供子类化之外的弹性替代方案。
装饰器模式属于结构型模式。
装饰器模式通过将对象包装在装饰器类中,以便动态地修改其行为,添加新的功能,同时不改变其结构。这种模式创建了一个装饰类,用来包装原有的类,并在保持类方法签名完整性的前提下,提供了额外的功能。
装饰器模式通过嵌套包装多个装饰器对象,可以实现多层次的功能增强。每个具体装饰器类都可以选择性地增加新的功能,同时保持对象接口的一致性。
组件(Component) 定义原始对象的公共接口或抽象类。
具体组件(Concrete Component) 是被装饰的原始对象,定义了需要添加新功能的对象。
装饰器(Decorator) 继承自抽象组件,并通过组合的方式包含抽象组件对象(可以是具体组件、也可以是其他装饰器)。其定义了与抽象组件相同的接口,因此装饰器中也可以持有其他装饰器对象。
具体装饰器(Concrete Decorator) 实现了抽象装饰器的接口,负责向抽象组件添加新功能。添加新功能的方式通常是:在调用被装饰的对象的方法之前或之类执行其自己附加的操作。
案例
书中StarBuzz Coffe的案例以咖啡连锁店下单系统为例。在购买咖啡时,需要选择基础饮料,然后选择调料(如低因咖啡加双份摩卡加奶泡),计算订单价格的方式就是基础饮料价格加上调料的附加价格。
首先,我们不可能为所有可能的饮料搭配情况定义一个类。
如果在抽象饮料类(Beverage)中加入代表是否添加某种调料的boolean值,然后通过cost方法计算出订单价格,这样是否可行呢?
看似合理,但是并不合适。
首先,这种方法没法表示添加双倍调料。当然,这尚且可以通过将boolean改为integer来解决。
但是,如果调料价格改变或添加新的可选调料,都会迫使我们改变cost方法;而且,不是所有饮料都能够添加所有调料(总不会在茶里打奶泡吧);再者,如果有新饮料加入…我不想再说下去了…
总之,通过上面的叙述,想必已经非常明了,那显然是针对实现编程的设计,与我们追求的设计原则相悖。
于是,引入装饰器模式。其类图如下:
其中,定义抽象饮料类Beverage就是装饰器模式中的组件。各种基础饮料(HouseBlend, DarkRoast, etc.)继承自Beverage,它们是具体组件,也就是需要被调料装饰的原始对象。调料装饰器类CondimentDecorator是装饰器,而各类调料(Milk, Mocha, etc.)继承自CondimentDecorator,它们就是具体装饰器。
Beverage中包含decription成员,也就是品名,同时包含virtual的cost方法获取价格。基础饮料通过继承获得其属性,而其中的cost方法获取其各自的基础饮料价格。
CondimentDecorator继承自Beverage,同时通过组合持有一个Beverage对象,也就是要被该调料装饰的饮料。而继承自CondimentDecorator的具体调料装饰器则具体实现的各自的cost方法,(递归)获得其要装饰的饮料的价格并且加上该调料的价格。
于是一杯DarkRoast加摩卡加奶泡,其获取价格的过程可表示如下:
Pros & Cons
Pros:
- 装饰类和被装饰类可以独立发展,不相互耦合
- 装饰器模式是继承的一个替代
- 可以动态扩展一个实现类的功能
Cons:
- 装饰器的过度使用会导致设计中有很多小对象(每次需要一点扩展都包装一下,装饰器就太多了)
- 多层装饰比较复杂
- (我自己想的,不太确定)可能有少量空间浪费?装饰器表示的类型可能不需要用到其持有的被装饰对象的所有数据成员,但是也必须分配存储这些成员。如前面StarBuzz案例,Beverage中有一个description成员,抽象装饰器继承自Beverage,其中又包含一个Beverage类型的成员,其getDescription方法获取被装饰的Beverage的description加上该装饰器附加的名称,那么装饰器类自己继承自Beverage的description成员就没有用。(其实这点我觉得可以通过设计优化,但是还是把自己的思考姑且留在此处)
要点
- 继承是扩展形式之一,但未必是达到弹性设计的最佳方式。
- 设计中应该允许行为可以被扩展,而无需修改已有代码。
- 组合和委托经常可以用来在运行时添加新行为。(可以在运行时改变对象行为是组合和委托的一大优势,策略模式就是一个典型的例子)
- 装饰器模式提供了子类化扩展行为的替代品。
- 装饰器模式涉及一群装饰器类,这些类用来包装具体组件。
- 装饰器类反映了它们所装饰的组件类型(它们和所装饰的组件类型相同,都经过了继承或接口实现)
- 装饰器通过在对组件的方法调用之前或之后添加功能来改变组件的行为。
- 可以用任意数目的装饰器包装一个组件。当然,多层装饰会较复杂。
- 装饰器一般对组件的客户是透明的,除非客户依赖于组件的具体类型。
- 装饰器会导致设计中出现出现许多小对象,过度使用会让代码变得复杂。
注:上面提到的“透明”(Transparent)在计算机领域和我们日常生活中的直观理解并不相同。日常生活语境中通常认为A对B透明的意思是,A中的具体内容对B完全公开、无任何遮掩;然而在计算机领域正好相反,A对B透明指的是A对B隐藏了其具体内容、具体实现,对于B来说A是一层抽象,使得B可以不必关心A中具体做了什么事情、是如何做的
Factory Pattern - 工厂模式
工厂模式属于创建型模式,它在创建对象时提供了一种封装机制,将实际创建对象的代码与使用代码分离。
注:工厂模式通常包括了三种细分的模式,简单工厂、工厂方法、抽象工厂,三种方法相似却又各不相同,在初学时容易造成的困扰。本节我将尝试结合自己的理解分别说明三种工厂模式,力求说清楚三者的区别
学习工厂模式的过程中,应该建立一个认知,即工厂模式主要是通过封装对象创建的具体细节,使得类的用户不需要关心不同对象的创建,而只需用统一的方式使用对象。这里的封装就是将不同的对象创建方法从用户代码中拿走,另行包装起来,从整体程序层面看并不能实质上减少什么步骤,该做的事情依然要做,只不过这些该做的事情并非都为用户所关心的,因此将其封装起来移至别处。
学习设计模式时,应该时常思考如此设计对用户的意义。各类资料案例通常是包括了类设计代码和用户代码,我们能够看到所有东西,因此往往觉得这么设计好像也没有提供多少方便、没有省去多少麻烦。然而,如果从类的用户角度看,这些设计往往帮助其略去了不必关心的、容易出错的细节。以这样的视角来审视设计模式,或许能有更好的理解。
注:我在学习工厂模式的过程中,出现这样的困惑:创建这些对象的方法实际上并没有什么变化,我们该写多少代码还是要写多少代码,甚至需要在不同类中反复横跳,这么做的意义到底是什么。但是当我完成所有类的设计,真正作为用户使用这些设计时,才明白这为用户的使用提供了多么大的方便。如果你也出现了类似的困惑,不妨看看我前两段的论述,兴许能给到你一些启发。
Simple Factory - 简单工厂模式
严格来说,简单工厂并非一个设计模式,而是一种编程习惯。不少开发者将这种习惯当作工厂模式,这其实并不正确。
由于简单工厂并非一种设计模式,我在书中和其他资料里也并未找到严格的定义,因此在本节我也不给简单工厂妄下定义了,直接上案例。后面结合工厂方法模式一起看,简单工厂到底是什么也不言自明了。
案例
书中的PizzaStore案例为开设披萨店,制作披萨。
披萨店由PizzaStore类定义,其中由一个orderPizza方法。在接到顾客的订单后调用orderPizza方法,在其中创建出抽象披萨类型(Pizza Class)的具体对象(CheesePizza, VeggiePizza, etc.),然后对其依次进行准备、烘焙、切片、装盒的操作。
通常,对于披萨店来说,披萨的准备、烘焙、切片和装盒是固定操作,对于任何披萨都是如此。而披萨对象的创建,是披萨店对象所不关心的。试想一下,我们开一家披萨店,并不关心那些配料是怎么做出来的,我们只想要它们被送到我们店里,然后我们用既有的固定制作方法为顾客完成披萨的制作。
于是,我们的PizzaStore类作为Pizza类的用户,不需要包含披萨“创建”的方法。因此将这些部分提取出来封装成一个只关心如何创建披萨的对象,这个对象就是简单工厂(SimplePizzaFactory),披萨的创建由其中的createPizza方法完成。
案例的类图如下:
可见,PizzaStore将披萨的创建委托给了SimplePizzaFactory,而其自身则无需关心创建披萨的具体细节。
Pros & Cons
Pros:
- 完成了用户不关心的对象创建方法的封装,用户只需要针对其获得的对象进行固有的操作即可,而可能发生变化的对象创建的部分被封装起来,符合“封装变化的部分”原则
Cons:
- 不符合“开放-关闭原则”,当有新的对象加入时,需要更改对象创建方法(加入更多的if-else语句判断具体创建何种对象),严格来说这是在更改已有的代码,而非进行扩展
Factory Method - 工厂方法模式
上述的简单工厂的确达到了其实现封装的初衷,但是不可谓一个合格的设计模式,而工厂方法模式则从“开放-关闭原则”入手对简单工厂进行改进,它才是真正的工厂模式。
注:这里对简单工厂和工厂方法做这样的对比解释或许会造成一点歧义,因为原书中在工厂方法模式的具体工厂里依然保留了简单工厂中对具体披萨产品if-else的判断,这是有其道理的,具体的说明放在后面案例分析部分
定义
工厂方法模式定义了一个创建对象的接口,但由子类决定要实例化哪个类。工厂方法让类把实例化推迟到其子类。
工厂方法模式相当于在简单工厂的基础上又加入了一层抽象。简单工厂只有一个具体工厂类负责对象的创建,而工厂方法模式则在其上加入了抽象工厂类,定义不同的具体工厂类分别负责生产不同的产品。当有新的产品加入时,简单工厂必须在其唯一的具体工厂类中加入新的if-else语句,来适应新对象的创建,这是进行修改;而工厂方法模式则可以从抽象工厂派生出新的专门负责生产新产品的具体工厂类,这样就不需要改变原有的类的设计,这是进行扩展。
注:这里所说的由子类决定实例化哪个类,并非真的由子类决定,而是该模式允许用户根据需要来决定使用哪个子类来创建其对应的产品。
工厂方法模式的类图如下:
抽象产品(Abstract Product,上图Product) 定义了产品的共同接口或抽象类。它可以是具体产品类的父类或接口,规定了产品对象的共同方法。
具体产品(Concrete Product) 实现了抽象产品接口,定义了具体产品的特定行为和属性。
抽象工厂(Abstract Factory,上图Creator) 声明了创建产品的抽象方法,可以是接口或抽象类。它可以有多个方法用于创建不同类型的产品。
具体工厂(Concrete Factory,上图ConcreteCreator) 实现了抽象工厂接口,负责实际创建具体产品的对象。
案例
书中设置了一个情形:当披萨店的经营模式需要变成连锁加盟的形式。这意味着有更多的PizzaStore需要加入,而这些披萨店由于开在不同地区(案例中的New York和Chicago),需要适应不同地区的饮食习惯。
首先我们检查一下,如果使用先前的简单工厂模式应该怎么做。显然,我们需要在SimplePizzaFactory中加入更多if-else语句,分别创建诸如NYCheesePizza, ChicagoCheesePizza等新的具体对象。也就是说我们不得不更改原有的工厂类的代码。
而使用工厂方法模式则是这样实现的:将原先的PizzaStore更改为抽象类作为其中的抽象工厂,并由其派生出NYStylePizzaStore, ChicagoStylePizzaStore两个具体工厂。这两个具体工厂分别负责创建各自对应的具体对象。
类图如下:
A Deeper Look
注:这部分是我学习工厂模式时的一点困惑和相应的思考。
原书本案例的代码设计中,在两个具体工厂中依然使用if-else语句判断创建何种具体对象。这似乎和前面所述的由简单工厂转向工厂方法模式的目的相悖。
我认为对于这部分的设计其实并不冲突,我总结出来的原因如下:
- 首先,相比可能随时新增的不同地区的加盟店,披萨店中的品类相对固定,甚至很可能是一成不变的,无非是CheesePizza,VeggiePizza,ClamPizza, PepperonPizza这几种。于是这部分可以暂且当作是不会发生变化的部分,根据“封装变化的部分”原则,这部分可以考虑不进行进一步的封装。
- 再者,在本案例中,具体PizzaStore根据顾客的订单来创建具体Pizza对象。其实在用户代码中直接创建顾客想要的Pizza然后将其传入对应的PizzaStore完成具体Pizza的创建,如创建CheesePizza传入NYPizzaStore完成NYStyleCheesePizza的创建。但是这种设计显然不够直观,且不符合人的感性认知(顾客必然是先去一家PizzaStore然后点其中的具体Pizza产品,而不可能选中某一类Pizza产品然后找某一家店按照他们店里特有的风格去做出Pizza)。
- 书中有提到,设计原则是进行程序设计时应该尽量遵循的指南,但绝非任何时候都应该严格遵循的铁律,任何程序都有一定程度上违反这些原则的地方。
私以为程序设计是稳定和效率的综合权衡,程序设计的最佳实践更多是给予我们在综合考虑稳定和效率的情况下,更合理地行事的一种参考。但是任何最佳实践都不是放之四海而皆准的铁律,也不存在绝对正确的优异的行事方法。追求绝对的完美不会造就完美的程序,只会让人写不出代码。毕竟,没有人会在写“Hello World”时也考虑设计模式。
正如书中所说,学习设计模式最终是将这些指南融会贯通,在设计时藏在大脑深处。这样,就算在违反这些原则时,我们也会知道自己正在违反原则,并且会有一个好的理由支持我们这么做。
Pros & Cons
Pros:
- 用户只需知道类名称就可以完成对象的创建。
- 扩展性高,当需要增加产品时,只需要再派生出其对应的工厂类就可以。
- 产品的具体实现对用户透明,用户只关心产品的接口。
Cons:
- 每次增加一个产品时,都需要增加一个具体类和对象实现工厂,使得系统中类的个数成倍增加,在一定程度上增加了系统的复杂度,同时也增加了系统具体类的依赖。
Abstract Factory Pattern - 抽象工厂模式
定义
抽象工厂模式提供一个接口来创建相关或依赖对象的家族,而不需要指定具体类。
工厂方法模式是为创建一个产品提供一个抽象接口,而抽象工厂模式则是为创建产品家族提供一个抽象接口。
使用工厂方法模式时,需要为每一种产品扩展一个新的具体工厂类,当产品种类众多时,具体类的数量会成倍增长。而抽象工厂模式使用分组的思想,每一个分组中包括一整套产品家族,而每一个具体工厂负责生产对应的一整套产品。
抽象工厂模式常可用于每一类大产品中包括一些子产品的情况,这些大产品就是前面所说的分组,其中的子产品或相同或不同,每个具体工厂负责生产一组产品中所有的子产品。
抽象工厂模式的类图如下:
工厂方法模式常常潜伏在抽象工厂模式中,抽象工厂模式定义了一个接口,这个接口创建一组产品,接口中的每一个方法对应创建组内的一个具体产品。抽象工厂的子类即具体工厂负责实现接口中的各个方法,使用工厂方法实现这些方法是自然而然的。
案例
书中的案例进一步提出这样的情形:我们需要为连锁加盟披萨店统一原料以保证披萨的质量。一旦引入了各类原料,我们生产的最小单位便不再是各类披萨,而是其中的各类原料,由这些原料以不同的方式组合构成各类披萨。
首先,试想一下使用前面所说的工厂方法模式实现这个构想。我们需要为每种原料创建一个具体工厂,将生产出的原料组合为具体的披萨。可想而知,我们需要为每一种披萨生成好几个具体工厂,子类数量将成倍增长。
如果从抽象的层面考虑,各不相同披萨无非就是面团、酱料、芝士、蔬菜等原料产品构成的产品家族。原料工厂实际上是用同样的方式生产这些原料,只不过具体的原料产品因地区而有所不同。
于是,我们定义一个原料工厂的接口PizzaIngredientFactory,该接口创建所有原料,也就是抽象工厂。而New York和Chicago的具体的原料工厂派生自抽象工厂,以处理地区差异。
然后,我们将对应地区的具体原料工厂传给该地区的每个具体披萨,当创建这些披萨时,这些具体披萨类从它们对应的具体原料工厂获得该披萨所需的原料,完成披萨的创建。
通过这样的方式,我们依然保持了一个地区的加盟店对应一个具体工厂,只不过该具体工厂内生产的是该地区披萨对应的一整个原料家族,而非单个披萨对象。
使用抽象工厂模式实现PizzaStore的类图如下:
Pros & Cons
Pros:
- 当一个产品族中的多个对象被设计成一起工作时,它能保证客户端始终只使用同一个产品族中的对象。
注:以PizzaStore为例,因为我们保证每个地区对应一个具体工厂,其中的具体原料工厂生产该地区披萨所需的一整个原料家族,因此不会出现类似误将NY的Cheese和Chicago的Clam组合再一起的情况。而如果使用工厂方法模式,针对每一个原料生成具体工厂,则无法保证这一点。
Cons:
- 产品族扩展非常困难。如果要在一个产品家族中增加一个新产品,不仅需要在抽象工厂里加入新接口,还需要在具体工厂中加入新的接口实现。
要点
- 所有工厂都封装对象的创建。
- 简单工厂虽称不上真正的设计模式,但依然不失为将用户从具体类解耦的简单方法。
- 工厂方法模式使用继承,将对象创建委托给了子类;抽象工厂模式使用组合,对象创建在工厂接口暴露的方法中实现。
- 工厂模式通过减少对具体类的依赖,促进了松耦合。
- 工厂方法的意图:允许一个类延迟实例化到其子类;抽象工厂的意图:创建相关对象家族,而不必依赖其具体类。
Singleton Pattern - 单例模式
定义
单例模式确保一个类只有一个实例,并提供一个全局访问点。
单例模式涉及一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
单例模式属于创建型模式。
单例模式类图非常简单:
案例
书中案例以巧克力锅炉为例,用单例模式简单模拟单个锅炉的工作流程。
ChocolateBoiler类中将其构造函数设为私有,该类持有一个静态的实例对象的引用(uniqueInstance),并提供一个静态的获取实例的方法(getInstance)。用户代码中通过类名调用getInstance,而不需要具体对象。getInstance方法首先检查该类中uniqueInstance是否为空,若为空则调用其构造函数初始化该实例,最后将其返回给用户。
单例模式需要注意线程安全。案例的代码如果在多线程下执行,可能出现两个同时调用getInstance的情况,线程1检查uniqueInstance为空,而线程2可能在线程1完成uniqueInstance的构造之前也进行了nullptr的判断,最后将导致用户拥有两个该类的实例。
处理上述的多线程问题,通常有三种方式:
- 如果getInstance方法对性能的要求并不高,那么可以直接强制同步每个线程。当然,这种形式会迫使每个线程运行至此时都进行同步,即便我们清楚地知道,一次调用之后实例就已经存在,后续不会再构造新的实例。因此这种方法的性能较差。
- 转为急切创建(eagerly create)实例,而不用延迟创建(lazily create)。如果我们可以确定应用总是会创建并使用这个实例,或者单例的创建和运行时的负担不重,那么可以选择在应用初始化时就创建实例。具体来说可以在单例类内直接初始化实例。这也就是我们常说的饿汉式,而在需要用到实例时才创建就是懒汉式。
- 双重检查(double check)加锁。getInstance方法中首先检查实例是否存在,如果不存在则进入线程同步区块,进入该区块的线程进行强制同步。而当实例已经创建,后续调用getInstance方法就不会再进入同步区块,因此性能较高。
Pros & Cons
Pros:
- 提供了对唯一实例的受控访问
- 内存中只有一个实例,减少了内存的开销。
Cons:
- 没有接口,不能继承,因此扩展比较困难。
- 与“单一职责原则”冲突,一个类应该只关心内部逻辑。
要点
- 单例模式确保应用中一个类最多只有一个实例。
- 单例模式提供访问此实例的全局点。
- 单例中构造函数设为private。
- 单例模式应注意线程安全。
Command Pattern - 命令模式
定义
命令模式是一种数据驱动的设计模式,属于行为型模式。
命令模式把请求封装为对象,以便用不同的请求、队列或者日志请求来参数化其他对象,并支持可撤销的操作。请求以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。
命令模式的类图如下:
命令模式中的主要角色如下:
命令(Command) 定义了命令接口,通常包含一个execute 方法,用于调用具体的操作。
具体命令(ConcreteCommand) 实现命令接口,负责执行具体的操作。它通常包含了对接收者的引用,通过调用接收者的方法来完成请求的处理。
接收者(Receiver) 知道如何执行与请求相关的操作,是实际执行命令的对象。
调用者(Invoker) 发送命令的对象,它包含了一个命令对象并能触发命令的执行。调用者并不直接处理请求,而是通过将请求传递给命令对象来实现。
客户端(Client) 创建具体命令对象并设置其接收者,将命令对象交给调用者执行。
案例
书中命令模式的案例为家具自动化遥控器。该遥控器包含七组On-Off按键以及一个全局撤销按键Undo。我们需要实现的功能为:用遥控器的按键控制特定家电的开关以及其他操作(如风扇挡位切换),Undo按键撤销上一个操作,将家电状态返回至上一个操作执行前的状态。
案例中首先定义具体的家电类(Light, CeilingFan, etc.),并在其中实现其各自的具体操作(如Light类的on, off, dim, getLevel, etc.)。
Command类定义了命令接口,其中包括抽象execute和undo方法。具体命令类实现Command接口,其中包含了对应家电类型的引用,通过实现execute和undo方法分别定义该命令需要调用的家电类的方法和其对应的撤销操作。
除此之外,每个具体的命令类中还需记录上一个状态,在execute方法中,执行对应家电的操作前,先将执行操作前的状态记录下来。如Light的命令对象调用execute方法,执行具体操作前需要记录执行操作前亮度;CeilingFan的命令对象需要记录执行操作前的风扇挡位,以便对应的undo操作恢复上一步的状态。
RemoteControlWithUndo类定义遥控器,其中用两个Command类型的数组分别维护七个on按键和七个off按键。undoCommand维护上一个执行的操作,也就是当按下undo时需要撤销的操作。
setCommand方法可以传入具体Command类型,将该命令绑定到一组按键上。onButtonWasPushed和offButtonWasPushed方法接受一个int型参数,表示按下对应槽位(0-6)的on或off按键,方法中调用其对应命令对象的execute方法,并将undoCommand设为该命令,以便undoButtonWasPushed方法调用时撤销对应的操作。
注:C++在运行时获取基类指针指向的对象的动态类型比较麻烦,我使用C++的实现中为了方便遥控器类RemoteControl获取按键绑定的命令信息,为Command接口增加了一个抽象的getCommandType方法,具体命令类中实现该方法以获取对应的类名
该案例的类图如下,其中省略了一些具体家电类,以及遥控器类的undo方法,但是其结构是一致的
注:可以另外定义宏命令,一次性调用多个命令,其undo方法则是以相反的顺序调用其中命令的undo
Pros & Cons
Pros:
- 降低了系统耦合度。
- 新的命令可以很容易添加到系统中去。
Cons:
- 可能会导致某些系统有过多的具体命令类。
要点
- 命令模式把做出请求的对象从知道如何执行请求的对象解耦。
- 命令对象处在解耦的中心,封装接收者以及一个(或一组)动作。
- 调用者通过调用命令对象的execute()做出请求。
- 调用者可以用命令参数化,甚至可以在运行时动态地进行。
- 通过undo方法将对象重建到最后一次执行execute前的状态。
- 宏命令是命令模式的一种简单延伸。
- 在实践中也有命令对象自己实现请求,而不委托给接受者的设计。
- 命令也可以用来实现日志和事务系统。
Adapter Pattern - 适配器模式
定义
适配器模式将一个类接口转换成客户期望的另一个接口。适配器让原本接口不兼容的类可以合作。
适配器模式属于结构型模式
对象适配器类图如下:
其中,适配器将被适配的对象的接口转换成客户的目标接口。
除了对象适配器,适配器模式还有一种实现方式——类适配器(只有编程语言支持多继承才能实现),其类图如下:
类适配器同时继承自目标接口和被适配的类。
对象适配器 vs. 类适配器:
- 对象适配器除了适配一个被适配类,还可以适配其任何的子类;而类适配器则针对一个特定的类进行适配,不能同时适配其子类,但是它不需要重新实现整个被适配的类,且在必要是可以覆盖被适配类的行为。
- 对象适配器只继承自接口,接到接口的调用后将其委托给其中的被适配类;而类适配器同时继承自两个类,将一个类的调用转接到另一个类。
- 在需要相互适配的两个类都可能成为接口和被适配对象的情况下,对象适配器需要定义两种情况下的两个适配器类,而类适配器只需要一个适配器类。
案例
书中的案例是使用适配器使得客户可以使用Duck接口调用Turkey方法,反之亦然。以TurkeyAdapter为例,其本身是Duck接口的一个实现,其中维护一个对Turkey对象的引用,并实现了quack和fly方法。接到Duck接口quack方法的调用时,委托给其中Turkey对象,执行其中的gobble方法;接口Duck接口fly方法的调用时,委托给Turkey对象,执行其中的fly方法。
案例较为简单,此处不再展开。
Pros & Cons
Pros:
- 可以让任何两个没有关联的类一起运行。
- 提高了类的复用。
- 增加了类的透明度。
Cons:
- 过多地使用适配器,会让系统非常零乱,不易整体进行把握。
- 类适配器需要编程语言对多继承的支持,如JAVA就无法实现类适配器。
要点
- 当需要使用一个已有的类,而其接口不符合要求,则可以使用适配器。
- 适配器改变接口以符合客户的期望。
- 适配器包装一个对象以改变其接口;装饰器包装一个对象以添加新的行为和责任;外观包装一群对象以简化其接口。
Facade Pattern - 外观模式
定义
外观模式为子系统中的一组接口提供了一个统一的接口。外观定义了一个更高级别的接口,使得子系统更容易使用。
外观模式属于结构型模式,它向现有的系统添加一个接口,来隐藏系统的复杂性。
类图如下:
案例
书中HomeTheater案例定义了外观类HomeTheaterFacade,其中维护了家庭影院所需的各类设备对象的引用。客户直接调用外观HomeTheaterFacade的方法实现观看电影、结束放映等操作,而HomeTheaterFacade将完成这些操作所需要依次完成的一系列操作委托给其对应的对象。使得客户调用的接口极大简化。
案例较为简单,此处不再展开。
Pros & Cons
Pros:
- 减少系统相互依赖,符合最少知识原则。
Cons:
- 不符合开放-关闭原则,如果需要修改会很麻烦,继承重写都不合适。
要点
- 当需要简化并统一一个大接口或一个复杂的接口集,则可以使用外观。
- 外观将客户从一个复杂子系统解耦。
- 可以为一个子系统实现多于一个的外观。
Template Method Pattern - 模板方法模式
定义
模板方法模式在一个方法中定义一个算法的骨架(也就是模板),而把一些步骤延迟到子类。模板方法可以使得子类在不改变算法结构的情况下,重新定义算法中的某些步骤。
模板方法模式属于行为型模式,主要为了避免子类重复实现通用的方法的情况,将这些通用方法抽象出来在抽象类实现,而子类则可以按需重写某些方法。
案例
以书中制作咖啡和茶为例。
其中制作咖啡的步骤为:
- 把水煮沸;
- 用沸水冲泡咖啡;
- 把咖啡倒进杯子;
- 加糖和奶。
而制作茶的步骤为: - 把水煮沸;
- 用沸水浸泡茶叶;
- 把茶倒进杯子;
- 加柠檬。
可见两者的第1、3步骤是一样的,这两个步骤可以作为通用方法(boilWater, pourInCup)放入抽象类中,而剩下的两个不相同的步骤可以交由茶和咖啡的子类自己实现。在抽象类中将上述四个步骤包装成一整个方法(prepareRecipe),这就是我们的模板方法。
案例的类图如下:
我们注意到,第4步加调料应交由客户进行选择而非必需的步骤,因此我们加入一个返回bool值的方法(customerWantsCondiments)用以表示客户选择是否加调料。这个方法并非一定需要子类实现,比如说我们有产品中必定加调料或必定不加,此时就不需要客户的选择,这种情况下就不需要子类重新实现。这种可选的方法,有个形象的名字叫做Hook
Pros & Cons
Pros:
- 将通用方法抽出,便于维护;
- 行为由父类控制,子类实现。
Cons:
- 当方法步骤需要不同实现时不得不增加子类。
要点
- 模板方法定义算法的步骤,将步骤的实现延迟到子类;
- 模板方法的抽象类可以定义具体方法、抽象方法(在子类中具体实现)和Hook;
- Hook也是一种方法,在抽象类中可以不做任何事或只做缺省的事,而子类可以重写覆盖它;
- 模板方法通常为final,以防子类篡改;
- 策略模式和模板方法模式都封装算法,前者通过组合,后者通过继承;
- 工厂方法是模板方法的一个特例。
Iterator Pattern - 迭代器模式
定义
迭代器模式提供一种方法,可以访问一个聚合(Aggregate)对象中的元素而又不暴露其底层表示。
迭代器模式属于行为型模式,主要用于应对不同聚合有不同的遍历方式的情况,相当于在聚合对象与使用它的用户间增加了一层抽象,也就是迭代器,将在元素间游走访问的责任交给迭代器,这样对于用户而言只需要对迭代器进行统一的操作,而无需知晓不同聚合对象的不同遍历方式。
案例
注:原书使用JAVA,这里我选择以我实现的C++版本的来说
书中两个餐厅合并的案例,(方便起见,以餐厅A、B来代称),餐厅A的菜单使用vector记录各项目,餐厅B的菜单使用C风格的数组记录各项目,Waitress类则负责打印菜单。
如果直接合并两者,那么作为菜单类的用户,Waitress类打印菜单需要分别对两个餐厅的菜单以不同方式进行遍历,也就是说此时Waitress同时承担着管理菜单内部结构和遍历菜单两项职责。换言之,当菜单的结构发生变化(比如再多合并一家店,从而需要多合并一份菜单),那么Waitress类需要改变(增加以对应方式遍历新增菜单的操作);而某家店菜单的遍历方式发生改变,此时Waitress类同样也需要改变。总而言之,这不符合单一责任原则。
根据迭代器模式,我们将菜单抽象成类(抽象聚合),餐厅A、B的菜单(具体聚合)分别继承自抽象菜单。创建一个抽象迭代器类,餐厅A、B分别有各自派生自抽象迭代器的具体迭代器类,这两个具体迭代器分别负责以对应的方式遍历对应的菜单。
此时以对应方式遍历的职责交给了迭代器,而Waitress类只依赖于抽象菜单和抽象迭代器,其职责则是只剩下管理菜单,即Waitress只需要用一样的方式使用迭代器而不需要关系具体是以怎样的方式遍历,而当新的菜单加入,Waitress类只需加入新的菜单及其对应的迭代器即可。
类图如下:
Pros & Cons
Pros:
- 可以以统一的方式遍历聚合对象,且无需暴露其底层表示;
- 增加新的聚合类和迭代器类都很方便。
Cons:
- 迭代器模式将存储数据和遍历数据的职责分离,因此增加新的聚合类需要对应增加新的迭代器类,类的个数成对增加,一定程度上增加了系统的复杂性。
要点
- 迭代器允许访问聚合的元素,而不暴露其内部结构;
- 迭代器将遍历聚合的责任取出并封装;
- 对于支持遍历数据的操作方面,迭代器减轻了聚合的责任。
Composite Pattern - 组合模式
定义
组合模式,又叫部分整体模式。它将对象组合成树形结构来表现部分——整体层次结构,使得用户可以统一处理单一对象和对象组合。
组合模式属于结构型模式,主要用于应对用户希望模糊单一元素和组合元素的概念,用统一的方式处理它们的情况。
组合模式类图如下:
其中主要有以下核心角色:
**组件(Component)**为所有对象的接口,声明了用于管理和访问子组件的方法,这些方法可以实现缺省的行为;
**叶子节点(Leaf)**实现了组件接口,它不含子节点,因此表示的是单一对象;
**复合节点(Composite)**实现了组件接口,它可以包含子节点,表示的是复合对象。
**客户(Client)**通过组件接口一致地使用该组合结构,而不需要区分叶子节点和复合节点。
案例
书中由菜单合并的案例更近一步。由迭代器模式中的案例可知,当需要增加新菜单时,必须进入Waitress类添加更多代码,同时还需为新菜单实现对应的的迭代器,这某种程度上也是在违反“开放-关闭原则”。除此之外,合并几个菜单,Waitress在打印时就必须调用几次printMenu。
因此在本案例中,我们希望构建一个只用一次printMenu就可以打印全部菜单的结构,同时对于新增的菜单,我们希望将其当作该结构的子结构(即全部菜单下的子菜单)来对待,无论是一个菜单项,还是一个子菜单,都只需要一个统一的方法来完成打印。
根据组合模式,我们设计一个MenuComponent作为MenuItem和Menu的接口,里面包含了诸如print, add等方法的缺省实现。MenuItem和Menu继承自MenuComponent,覆盖实现其对应需要的方法,尤其是print方法。
该设计的类图如下:
于是,我们构建出了树形结构的菜单,其中每个菜单项或子菜单都是一个节点,我们可以统一地print每一个节点,如果是叶子节点,则print出其菜单项信息;如果是复合节点,则(递归地)print该子菜单中的子节点。
菜单的树形结构如下:
Pros & Cons
Pros:
- 可以一致地调用高层和低层模块,调用简单;
- 节点的增删比较方便。
Cons:
- 组合模式允许组件接口同时管理树形结构和操作叶子节点,这个特性在为用户带来透明性(一致地调用,无需关心实现细节)的同时,也违反了单一责任原则;
注:组合模式是典型的折中。尽管程序的设计受到设计模式的指导,但是人民往往会在仔细观察设计模式对程序设计的影响后,选择性地违反一些原则来换取某些方面的便利。还是那句话,将设计模式藏在脑海中,从而当我们违反一些设计原则时,能够知道自己正在违反原则,并且能有一个好的理由支持我们这么做。
要点
- 组合模式允许客户统一地处理对象组合和单一对象;
- 组合结构内的任意对象称为组件,组件可以是其他组合或叶子;
- 实现组合模式有许多设计上的折衷,需要平衡透明和安全。
State Pattern - 状态模式
定义
状态模式允许对象在内部状态改变时改变其行为,对象看起来好像改变了它的类。
状态模式属于行为型模式,主要用于对象的行为取决于其状态的情况,在这种情况下,朴素的实现方法需要在类中包含大量的用于状态转移的条件语句,因此可以将状态对应的行为委托给状态类(类似策略模式)。
状态模式的类图如下:
案例
书中的案例为一个设计一个糖果机程序,该糖果机有五个状态:未投币(NoQuarterState)、已投币(HasQuarterState)、已售出(SoldState)、胜利(WinnerState)、已售罄(SoldOutState)。上述五个状态都派生自基类State,分别重写其中的方法。
糖果机中维护着五个状态对象的指针,而五个状态对象也维护着其所属糖果机对象的指针。
注1:这里是一个交叉引用,原书使用JAVA实现代码不会出现编译问题,而使用C++实现则应当注意将具体状态类中需要委托其所属糖果机对象完成的方法进行类外定义。这是因为,JAVA编译时会一次性读进去所有东西,而C++编译时则是见一行读一行,我们不能调用未定义的类的方法,因此必须保证上述具体状态和糖果机这一对交叉引用的类先有一个定义完成。将需要使用糖果机类方法的具体状态类方法进行类外定义则是保证具体状态类先完成定义,那么在定义糖果机类的时候就可以拿到完整的具体状态类,当编译到对具体状态类方法的调用时,再去寻找类外的定义。
注2:Context和State并非必须像该案例一样相互引用。本例是因为状态转移是在具体状态类中进行调用的,因为状态转移要视糖果机对象当前的糖果数量而定,是一个相对动态的状态转移;而当状态转移情况比较固定时,则可以将状态转移放在Context中进行调用,而这种情况下很多时候具体状态类可以不需要维护Context的引用,此时具体状态类可以在Context之外定义从而可以被多个Context共享。
糖果机对象运行时调用insertQuarter, ejectQuarter, turnCrank等方法,并将其委托给当前的状态对象,当前的状态对象进行相应的方法调用,并根据糖果机对象剩余糖果数量完成状态转移。
糖果机运行时状态转移的示例如下:
Pros & Cons
Pros:
- 将所有与状态有关的行为封装成类,可以通过派生新类来增加新的状态,只需要改变对象状态即可改变对象的行为;
- 可以让多个Context共享对象。
Cons:
- 会增加系统类和对象的个数;
- 状态模式的结构与实现都较为复杂,如果使用不当将导致程序结构和代码的混乱(从注1的交叉引用就可见一斑);
- 对“开放-关闭闭原则”的支持并不太好,因为增加新的状态类需要修改那些负责状态转换的源代码,否则无法切换到新增状态。
要点
- 状态模式允许一个对象基于内部状态拥有许多不同的行为;
- 和过程式状态机不同,状态模式用真正的类代表每个状态;
- 通过将状态封装成类,将后续可能需要进行改变局部化;
- 状态模式和策略模式类图相同,但意图不同;
Proxy Pattern - 代理模式
注:我的代码仓库中并未用C++重新实现代理模式的案例。
定义
代理模式为另一个对象提供一个替身或占位符来控制对这个对象的访问。
代理模式属于结构型模式,主要用于不希望用户直接访问对象的情况,比如,要访问的对象在远程的机器上。
代理模式的种类很多,主要包括:远程代理、虚拟代理、Copy-on-Write 代理、保护(Protect or Access)代理、Cache代理、防火墙(Firewall)代理、同步化(Synchronization)代理、智能引用(Smart Reference)代理。
代理模式的类图如下:
案例
远程代理
书中远程代理的案例是为状态模式案例的糖果机设计监视器,使得我们可以在远程调用糖果机的方法并返回我们需要的信息。
简而言之,我们在客户端创建了实际糖果机对象的代理,用户调用代理的方法,代理则通过网络将实际的调用转发至远程的对象,调用的结果也通过网络返回至代理,经由代理处理后展示给用户。用户仿佛直接与远程的对象进行交互。
远程代理的方法调用流程示意图如下:
虚拟代理
书中虚拟代理的案例是设计显示音乐专辑封面的应用。专辑封面的显示需要先通过网络获取图像,但是网络状况会影响读取的时间,为了在加载图像的过程中也能显示一些信息,我们使用虚拟代理在本地“扮演”图像,在加载完成之前调用虚拟代理的方法显示信息,而加载完成后虚拟代理则将相应的调用委托给加载完成的Icon。
该案例的类图如下:
保护代理
保护代理可以使代理只提供部分接口供客户调用,其类图如下:
Pros & Cons
Pros:
- 对象职责清晰;
- 扩展性较高。
Cons:
- 在客户端和真实主题之间增加了代理对象,因此可能导致请求的处理速度变慢;
- 会导致设计中类和对象的数量增加。
要点
- 代理模式为另一个对象提供代表,以便控制客户对对象的访问;
- 远程代理管理客户和远程对象的交互;
- 虚拟代理控制访问实例化开销大的对象;
- 保护代理基于调用者控制对对象方法的访问;
- 装饰器模式为对象加上行为,而代理模式则是控制访问。
附录
设计模式分类
- 创建型模式:这类设计模式提供在创建对象的同时隐藏创建逻辑的方式,而不使用new运算符直接实例化对象。这使得程序在判断针对某个给定实例需要创建哪些对象时更加灵活。
- 结构型模式:这类模式关注对象之间的组合和关系,旨在解决如何构建灵活且可复用的类和对象结构。
- 行为型模式:这些模式关注对象之间的通信和交互,旨在解决对象之间的责任分配和算法的封装。
23种设计模式分类如下:(其中加粗的为本书着重解释的设计模式)
模式类型&描述 | 具体模式 |
---|---|
创建型模式 | 工厂模式(Factory Pattern) 抽象工厂模式(Abstract Factory Pattern) 单例模式(Singleton Pattern) 生成器模式(Builder Pattern) 原型模式(Prototype Pattern) |
结构型模式 | 适配器模式(Adapter Pattern) 桥接模式(Bridge Pattern) 组合模式(Composite Pattern) 装饰器模式(Decorator Pattern) 外观模式(Facade Pattern) 蝇量模式(Flyweight Pattern) 代理模式(Proxy Pattern) |
行为型模式 | 责任链模式(Chain of Responsibility Pattern) 命令模式(Command Pattern) 解释器模式(Interpreter Pattern) 迭代器模式(Iterator Pattern) 中介者模式(Mediator Pattern) 备忘录模式(Memento Pattern) 观察者模式(Observer Pattern) 状态模式(State Pattern) 策略模式(Strategy Pattern) 模板方法模式(Template Method Pattern) 访问者模式(Visitor Pattern) |