设计模式学习笔记

最近接到导师布置的一个横向任务,要利用前沿的密码学算法实现一个可搜索加密+授权加密的桌面程序。虽然密码学算法已经心里有数,但是这个程序的功能稍微有点复杂,程序设计的难度也有所提高,所以在写的时候,往往没有头绪,不知道使用怎样的设计思路来编写代码。经过一番资料的搜索后,发现了*《设计模式》这本书,这本书被广大同行所盛誉。然而图书馆的所有《设计模式》已经被借走了,于是我就找到《Head First设计模式》*,虽然此书难免无法像《设计模式》一样严谨,但是却大大降低了理解的难度。所以本篇文章就基于《Head First设计模式》这本书,记录一下设计模式的理论学习。如果平时的项目中有利用的相关的设计模式,可能会考虑长期更新此篇博客,或新开一篇,记录一下设计模式的实践部分。

说到这,还没对**设计模式(Design Patterns)**下一个定义,书上好像也没有,这里就讲一下鄙人粗浅的理解,大概就是在面向对象语言中根据使用场景,合理地安排类、借口之间的封装、继承、多态关系,使得程序开发和运行的效率达到较高。

设计模式(design pattern)是对中普遍存在(反复出现)的各种问题,所提出的解决方案。

——来自Wikipedia

UML类图

本篇博客用到了大量UML类图,我也是刚刚学习UML工具,所以顺便地做一下类图的学习笔记,以免今后遗忘。

本文中的类图,有一部分是来源于互联网,但大部分是自己画的。在这过程中我尝试了许多工具,如Visio、Vscode、draw.io等,所以图像的风格比较多变,最后发现draw.io比较好用,于是定下来使用draw.io

单例模式示意图

关系

单向关联

一个最简单的箭头表示单向关联,简单地说就是表示has-a的关系。

关联关系

依赖关系

依赖关系表示一种动态的关系,表明一个类在运行时可能会使用到另一个类,与关联关系不同的是,它是一种临时关系。

依赖关系

实现关系

实现关系是用于表达对接口或抽象类的视线,用一个三角箭头+虚线表示。

实现关系

继承关系

继承关系用一个三角箭头+实线表示。

继承关系

聚合关系

聚合关系表示一种一对多的关系,带菱形箭头的一边是“多”的关系。与组合关系不同的是,整体和部分不是强依赖的,即使整体不存在了,部分仍然存在;例如, 部门撤销了,人员不会消失,他们依然存在。

聚合关系

组合关系

组合关系是一种强依赖的特殊聚合关系,如果整体不存在了,则部分也不存在了;例如, 公司不存在了,部门也将不存在了。

组合关系

创建模式

简单工厂模式Simple Factory

简单工厂模式非常好理解,主要的抽象是由产品类提供的。从产品类中分化出产品A、B、C等,而工厂会根据输入的参数来决定返回何种产品,具体地说,就是输入一个字符串,在工厂的创建方法中使用if语句逐个判断,决定返回何类产品。

工厂方法模式Factory Method

工厂方法模式是对简单工厂模式的进一步抽象,简单工厂模式返回何种产品是由输入的参数决定的,这是一种硬编码行为;而工厂方法模式将返回对象的决定权交给工厂类,每个工厂类只会返回特定的产品。

工厂方法模式示意图

模式分析

  • 隐藏细节:向客户隐藏了哪种产品将被实例化这一细节,用户只需要关心所需产品对应的工厂,甚至连返回的具体产品类都不需要关注。
  • 可扩展性强:当需要增加新的产品时,只需要增加具体的工厂类和产品类即可,无须修改客户端。

抽象工厂Abstract Method

抽象工厂UML类图

抽象工厂与工厂方法很类似,但是抽象工厂可以用于产生一系列的产品,强调的是产品的系列产品族

模式分析

  • 分离具体的类:将客户与类的实现分离,客户只需要与工厂交互,即可产生不同的产品。
  • 易于交换产品系列:只需要改变具体的工厂即可改变不同的配置,例如:只需要使用A工厂就可以产生A风格的窗口组件,在需要B风格时的组件,改变为B工厂即可。
  • 有利用产品的一致性:一个应用程序一次只能使用同一个系列的对象,有利于产品一致性。
  • 难以支持新的产品:抽象工厂确定了可以被创建的产品集合,支持新的产品(例如C类产品)就需要扩展工厂接口,这意味着所有的工厂子类都要改变。

建造者Builder

Builder

建造者模式似乎和策略模式有些相似,都是定义好调用顺序,而将具体调用的函数决定权交给运行时的对象。不同的是,建造者模式更强调返回的产品,而策略模式强调的是对数据的处理。

模式分析

  • 将构造代码和表示代码分开:举例说明,当用户需要构建电脑这样复杂而陌生的对象时,很难将所有的部件都正确构造,此时我们使用建造者模式,将指挥者Director交给用户,用户只需要关注Director的方法即可,无须关注具体细节。
  • 对构造过程进行更精细的控制:指挥者会按顺序构造产品,只有产品完成时才会返回产品。

单例模式Singleton

在一些场景下,只允许拥有一个实例变量,例如:在进行加密时,全局只能有一个参数类,否则加解密会出现问题;在操作系统中,也只能拥有一个文件系统。在这些场景中,使用单例模式十分合适。

单例模式示意图

在单例模式中,主要思路是将类的构造函数和实例对象声明为私有,想要获得实例,必须通过getInstance方法。而getInstance方法正是用于管理“单例”的:

1
2
3
4
5
6
7
8
Singleton* Singleton::getInstance(){
if (instance == NULL)
{
instance = new Singleton();
}

return instance;
}

构造型模式

适配器模式Adapter

适配器模式在思维上很简单,类似插座的转接,由于每个国家对插座的标准制定的不一样,所以用户在使用另一个国家的插座时便不得不使用这样的一个转接口来进行转接。在非面向对象的语言中,可能也存在着这样的设计思想,就是把第三方库或调用方式复杂的内容重新封装后暴露给用户。

适配器模式

适配器模式可以分为对象适配器和类适配器,具体的继承和组合关系如下图。

对象适配器

类适配器模式

两种适配器模式的区别,仅是Adapter以什么方式通知Adaptee的区别。

模式分析

  • 增加了类的透明性和复用性,将具体的实现封装在适配者类中,对于客户端类来说是透明的,而且提高了适配者的复用性。
  • 将目标类和适配者类解耦,通过引入一个适配器类来重用现有的适配者类,而无须修改原有代码。

辨析类适配器模式和对象适配器模式

对象适配器模式可以组合多个被适配者,使用起来功能更强大,也更简单;类适配器可以重写被适配者的方法,使用起来更加灵活。对象适配器很难置换适配者的方法,需要通过继承自原适配者来实现,操作起来较麻烦;而类适配器对象无法在不支持多继承的语言中实现。

外观模式Facade

外观模式和适配器模式十分相似,都是用于封装。但不同的是,适配器模式强调的是对旧接口或者第三方接口的改变,使其适应新的接口;而外观模式强调的是对复杂系统的整合,使得客户只能接触到简单的系统。

外观模式.drawio

外观模式的UML类图看起来还是很简单的,所见即所得。

模式分析

外观模式虽然简单,但是在程序设计中却十分重要。首先,在软件系统中将一个大型系统分割为多个小型系统有利于使系统之间的通信和相互依赖达到最小,遵循的是单一职责原则。其次,外观模式可以降低客户和内部复杂系统的耦合性。最后,外观模式也是迪米特原则的体现,即最小知识原则。

但是,外观模式也有一定的缺点,例如,如果要增加新的子系统,则需要对外观类进行修改。

桥接模式Bridge

问题场景

理解桥接模式首先需要了解桥接模式为了解决什么样的问题。设想这样一种情况:汽车分有许多种类,包含SUV、轿车、跑车等,而这些种类又有许多种品牌,例如奔驰、宝马、奥迪等。实现这样的一个场景,最容易想到的方法就是设计一个抽象父类Car,接下来由三个抽象的子类奔驰、宝马、奥迪继承,每个抽象子类再由SUV、轿车、跑车继承,形成一种树形结构。这样的设计模式在汽车种类、品牌种类少的情况下还可以使用,然而当品牌和汽车种类变多时,就会发生子类爆炸的情况,最多需要实现mnm*n个子类,维护变得十分困难。

桥接方式是使用了类似笛卡尔积的方式来实现这种场景,既然汽车可以从具体的种类和抽象的品牌上分出来两个维度,那我们将种类设计为抽象类,品牌设计为接口,就可以较为简单地实现上述场景。

桥接模式.drawio

关于设计时将哪个维度设计为接口,哪个维度设计为抽象类,根据ChatGPT的建议,将变化频繁的维度设计为接口更加合适,并且如果有一个维度需要共享一些数据,则将这个维度设计为抽象类。在实际设计中,右边的接口也可以设计为抽象类。

模式分析

桥接模式可以看成是多继承(特别是两层继承)的一种替代,可以大大减少类的数量,并且只需要较少的修改就可以增加一系列的类。但是桥接模式的缺点也很明显,桥接模式的理解和设计的难度较大,需要程序员去主动“发现”两个维度,对程序员的要求较高。

装饰器模式Decorator

装饰器模式某种意义上缓解了继承的滥用。众所周知,在面向对象语言中,一个类的所有父类,在编译时就已经确定了。或者更具体地说,如果使用继承来描述类之间的关系,那么继承链在编译时就确定了,运行时不能进行修改,很大地限制了灵活性和可扩展性。

装饰器模式下的关键对象,可以分为组件(Component)装饰者(Decorator)。下面是装饰器模式的示意图(个人任务,打叉的那条线不要画更容易理解)。之后的例子中可以看到,装饰者在使用上和组件没有差异。参考书中用于阐述装饰器模式的例子是咖啡,为了更好地本土化,本文采用奶茶来理解。

装饰器模式示意图

组件可以理解为只包含茶底的奶茶,装饰者可以理解为加了小料后的奶茶。可以看到,Decorator类中会有一个实例变量记录被装饰者,所以Decorator类中可以包含所有Component类的子类,包括Decorator类自己。由此,装饰者就可以互相嵌套,并且突破了继承关系的次序限制。

在调用最外层的装饰者的方法时,会逐层调用相应的方法,并根据每一层装饰者的特性对上一层的数据进行处理。

在为装饰者添加了合适的构造函数时,还可以用非常简单的语法来嵌套装饰者。

1
MilkTea my_favorite = new Sugar(new Milk(new Tea(new Traditional)));

典型实践

java的IO包中的输入输出流就是用装饰器模式来设计的。以输入流模式为例,其父类是InputStream,在实际的编程中,我们可以根据场景来实例化不同的Stream,而不需要管手上原有的Stream到底是什么。

Java中的装饰器模式

享元模式Flyweight

享元模式的应用场景似乎比较少,这里就简单介绍一下。享元模式的思想是,如果程序中用到大量重复的对象,就不再重复创建新的对象,而是使用享元工厂来获取。享元工厂会维护一个Hash表,当发现当前需要创建的对象在内存中已经存在时,就直接返回这个对象。实际上,“重复的对象”并不是非要完全相同,在设计时可以分离出对象的不可变的内部状态和可变的外部状态,将内部状态作为享元来使用。

典型实践

享元模式在Java标准库中大量使用,对Integer进行封装时,没有必要对所有的Integer都创建一个对象进行包装,而是将-127~128之间的Integer提前创建好对象放置在内存中,需要这个范围内的包装时,就直接返回内存中的实例。

另一个典型场景就是各种编辑器软件,例如,一个文档中,若同样的图片出现多次,就没有必要为其创建多个相同的对象,而是将图片的数据作为内部对象储存,将图片的外在格式(如大小、位置)等信息作为外部对象。

行为型模式

观察者模式Observer

观察者模式可以类比成对报纸、杂志的订阅,当报社有新一期的报纸出版时,就会向所有订阅该报纸的用户发送通知,而未订阅报纸的用户则不会接收到通知。所以观察者模式的两个关键对象就是出版者订阅者,或者更学术地,主题(Subject)观察者(Observer)

观察者模式的思路十分简单,从编程语言的角度,主题对象中会有一个存储所有观察者的数据结构,观察者也会记录自己所订阅的主题,当观察者开始新的订阅或取消订阅时,主题对象就修改自己的数据结构。而当主题对象的某事件(通常是数据变化)触发时,就会逐个通知所有订阅的观察者。通常,观察者需要实现某个接口函数(如:update(),以便让观察者调用。

观察者模式又可以细分为两种,“推”和“拉”。

“推”

在这种模式下,主题是主动的。主题会调用观察者的update(param ...)来将所有数据通过参数来传递给观察者。这种方法可以一次性传递所有数据,但是在不同观察者所需的数据不同时,会向观察者一些不需要的信息。

“拉”

这种模式是由观察者主动的,主题调用观察者的update()方法通知观察者后,观察者还需要调用主题的Getter来主动地获取数据,这种模式灵活性较高。

命令模式

当我们需要向一个对象发送命令或请求,但是不知道命令的接收者是谁(要等到运行时才能知道)时,就可以使用命令模式。命令模式实际上就是将请求的信息封装成一个对象,将其与发送者、接收者解耦,同时,也将发送者与接收者解耦。一个比较好理解的例子就是遥控器。

命令模式

模式分析

  • 每一个命令都是一个操作,并且有明确的接收者和发送者。
  • 命令模式使得每个命令都成为对象,使得命令可以被存储和传递,在一些编辑器软件中也使得用户可以进行撤销和重做。
  • 命令模式还可以将多个命令组合起来形成一个宏命令,或者是命令队列。

中介者模式Mediator

当系统结构复杂时,对象之间可能会存在着大量的相互引用,这样会使得系统的耦合性很强。举个例子,就是网络聊天室。如果希望用户之间可以互相发消息,则需要每个用户都保留着对其他用户的引用,这样需要维护大量的用户状态,使得系统十分复杂。所以,我们非常自然地想到引入一个中介者来代为管理,这些用户则被称为同事类

在中介者模式中,同事类只知道中介者的存在,知道如何想向中介者发送通知或改变状态,而不知道其他同事的存在。这样的模式就大大降低了同事对象之间的耦合度。

中介者模式

中介者模式的UML类图如上,实际上,在简单的系统中,两个抽像类的存在都不是必要的。

模式分析

中介者模式可以简化对象之间的交互,将各个同事解耦,在特定情况下可以大大降低各个同事类之间的耦合程度。但是,中介者类包含了同事之间的交互细节,可能会导致中介者类非常复杂,使得系统难以维护。

状态模式State

状态模式大致的思路就是将状态设计为抽象类,并作为一个成员变量放到上下文对象中。这样一来,在修改状态的同时也可以修改上下文的行为。

状态模式.drawio

模式分析

状态模式封装了转换规则,在切换状态时便可以自动改变上下文对象的相应字段和行为(可以将行为放在状态中),减少了状态之间切换的复杂度。这样的设计还将状态转换逻辑与状态对象合为一体,而不是一个巨大的条件语句块。

策略模式Strategy

策略模式理解起来很简单,只要结合一个实例即可,那就是排序算法。在C++、Java的标准库中,都提供了排序算法,用户可以向其传入参数来决定元素之间的对比策略,这里用到的就是策略模式。更宽泛地说,策略模式将程序执行过程中的控制流封装成了对象,以方便替换,就像搭积木一样。

策略模式

设计模式总结

在设计面向对象程序时,经常会纠结“这个方法放到哪个类中”“如何使用类的继承和组合关系”这样类似的问题,设计模式就是解决这类问题的范式。合理地运用设计模式可以降低程序之间的耦合性,简化开发难度。然而,正如《Head First 设计模式》中提到的那样,设计模式的运用不是必要的。当开发一个简单的系统时,不必要地使用设计模式可能会让代码变得很复杂。这也是在学习设计模式时,看到代码总会想“这么做难道不是更复杂了?”的原因,因为简单的系统中,直接使用最自然的思路就是最好的。

所以学习设计模式,并不是要把所有设计模式学通,然后想方设法在程序中应用它。而是学习设计模式是如何通过继承和组合等关系来解决问题,如何降低程序的耦合性,如何设计出优雅的程序。希望通过这段时间的学习,对我的程序开发能力有所帮助。


设计模式学习笔记
http://zhouhf.top/2024/09/08/Design_Patterns/
作者
周洪锋
发布于
2024年9月8日
许可协议