深入理解设计模式

关于本文

本文为《深入理解设计模式》v2022-1.29 亚历山大·什韦茨 (Alexander Shvets) 著 彭力 译版本 的笔记。文章较长,可使用网站右侧的目录栏进行跳转。
该书的中文翻译包含了风格化的内容(即为了解释原文,使用中文环境中才会出现的例子)与此同时中文翻译中包含了类似于“鲁棒性”、“硬编码”这种不易理解的中文词语,建议对比英文版阅读。

由于文中的图片和部分话语为引用,为保障原作者的权益,本文禁止转载,文中图片为原作者所有。

本文是学习过程中的一个总结,使用本文去学习设计模式是远远不够的,本文中缺少例子、代码和一些具体内容,只保留了核心内容。本文也没有经过校对,可能存在错误。在学习具体设计模式前,可以参照本文前半部分中的内容,内容较为详细,在学习每一个设计模式的时候,应该去找一个详细的教程,例如《深入理解设计模式》这本书。

如何学习

很多人会在学习设计模式的时候因为学不懂而弃坑,我也是其中之一,总的来说是学习方法不正确,一直没能入门。在挣扎了数个月后,我在 Google 搜索 how to learn design pattern 的时候找到了 reddit 中的几个帖子,对比了大多数人给出的学习建议后,我找到了这本书和一些教程。

让我从迷茫到入门的是这样一句话:

Get the fucking book

Too many people think of coding first, objects, last.

学习设计模式与以前的计算机学习过程不同,学编程语言,可以在网站的闯关页面、看视频,学算法也是知道下一步怎么做,然后去实现。学设计模式是把电脑关了去学

因此我认为:

对于第一次接触设计模式的人来说,选择这类教程时,不要选择名字中包含诸如:Java 设计模式 C++设计模式 等包括“实战”,“某编程语言”类型的书或视频教程(尤其是某些视频教程)。因为设计模式与编程语言无关,一个设计模式可以使用不同的语言实现,学习设计模式的目的在于学习设计模式的思想,学会使用 UML 类图表达、设计软件结构。编写代码应该是学习设计模式的第二部分。

面向对象的编程方法加上设计模式可用于编写逻辑复杂、代码量大、需要团队协作的项目。对于一个微小的程序。盲目地使用设计模式反而会增加代码的复杂程度。

学习设计模式前应该了解面向对象设计的基本内容,并且关掉代码编辑器,了解设计模式的思路,然后再考虑使用代码去实现。

文中的汽车、飞机、动物等都是一个概念,它们代表的是一个例子,并不是真的在程序中编写这些内容,而是便于理解。

设计模式为使用面向对象设计复杂程序提供了解决方案。

面向对象程序设计

面向对象程序设计(Object-Oriented Programming,OOP)是一种范式,理念是把数据块和与数据块相关的行为封装成名为对象的实体,实体的生成工作则是居于程序员给出的“蓝图”,这些蓝图称为

对象和类 Objects and Classes

  • 实例:假如有一只白色的毛,那么对于这只白猫,白猫是一个对象,也是 Cat 类的一个实例
  • 成员变量:每只猫具有相同的属性,如 name、sex、age、weight、color等
  • 行为(方法):每只猫有相似的行为,会 breath、eat、sleep、meow
  • 类的成员:成员变量和方法
  • 状态:存储在成员变量中的数据
  • 行为:对象中的所有方法定义了行为

对于另一个猫,它拥有与前面这只猫相同的属性(成员变量),但属性值(状态)不同。类是对象结构的蓝图,对象是类的具体实例。

类的层次结构

程序中有多个类,会组成类的层次结构。

对于一个狗,它与猫有许多相似的地方。因此,可以定义一个动物 Animal 基类,来列出它们共有的属性和行为。这个 Animal 类称为超类,继承它的类被称为子类

子类会继承父类的状态和行为(即父类的成员),其只需要定义不同的状态行为

如果有一个相关需求,可以设计、抽取出来一个更通用的生物体 Organism 类,并作为 Animal 和 Plant 超类,这种类组成了类的层次结构。猫类将继承 Organism 和 Animal 类的内容。

子类可以重写继承的方法,可以替换默认行为,也可以编写额外的方法。

面向对象设计的支柱

  • 抽象 Abstraction
  • 封装 Encapsulation
  • 多态 Inheritance
  • 继承 Polymorphism

这四个内容是面向对象设计的支柱。

抽象 Abstraction

程序中的对象不必真实反映其原型,只需要模拟真实对象的特定属性和行为,其它内容可以忽略。

例如飞行模拟程序和航班预定程序可能会包含一个 Airplane 类,但模拟器需要飞行相关的信息,后者需要座位图和哪些作为可被购买

简单来说就是描述具有需要的、关心的成员,而不是详细描述所有的成员。

抽象反映了一种真实世界或现象中特定内容发模型,具有与特定内容(程序使用)相关的详细信息,同时忽略其它内容。

封装 Encapsulation

对于开车而言,只需要按下启动按钮,接线、打火、转动曲轴、喷油等操作,都隐藏在引擎盖下。开车时只需要操作启动开关、方向盘和踏板等。

这些驾驶员操作的装置是汽车对象(实例)实现的接口,它是汽车类对象的共有部分,能够与其它对象(例如操作该车的驾驶员)进行交互。

封装是指一个对象对其它对象隐藏其内部的状态和行为,而仅向程序其它部分暴露有限的接口。

对于要封装(隐藏)的内容

  • 使用 private 修饰,只有类中的方法能访问
  • 使用 protected 修饰,允许父类访问

编程语言中的接口和抽象类,二者都基于抽象和封装的概念。接口机制可以定义对象间的交互协议,接口只关心对象的行为,这也是接口中 不能 声明成员变量的原因之一。

Helicopter、Airplane、DomesticatedGryphon 三个类都实现了 FlyingTransport 接口,限制 Airport 类可与实现了 FlyingTransport 接口的对象交互。也就是说只要对象实现了这个接口,就可以被传递给 Airport 类对象进行交互。

fly 方法对于不同的情况可以进行修改,对于不同的对象,行为可以不同。

继承 Inheritance

继承是根据已有类创建新类的能力。

继承是在一个蓝图的基础上创建蓝图,继承的目的是代码复用。当两个类之间差别不大,可以使一个类继承另一个类。

继承后,子类拥有与父类相同的接口,子类无法隐藏父类的方法、接口,且子类必须实现所有的父类方法。

类似于画图,已经画好一个基本模板(例如图纸框),在以后的图纸中都要画这个基本模板,可以让以后的图纸都继承自该模板,然而改模板已经画在纸上了,无法擦除,而且还需要再模板中的姓名栏填上画图人的名字。

单继承:子类只能继承一个父类,不能同时继承自多个父类

大部分编程语言中对于子类都是单继承的。子类只能有一个父类,但对于接口没有限制,这个子类可以同时实现多个接口。

多态 Polymorphism

将父类中的方法设为抽象(没有定义具体行为),从而要求子类自行实现该方法。

bag = [newnew Cat(), newnew Dog()];
foreach (Animal a : bag)
    a.makeSound()

程序并不知道 Animal 类对象 a 到底是猫还是狗,但程序可以直接调用子类的方法,执行恰当的行为。

多态:程序能自动检测对象所属的实际类,在不知道真实类型的情况下调用其实现的方法的能力。

注:多态机制是覆写的一个体现,分清重载 Overload 与覆写 Override

方法签名:方法(函数)名称 + 参数列表(传递给函数的参数),方法签名 不包括 返回值

重载 Overload 指的是对于方法
public void myExampleFun(int a)
有一个方法签名不同,返回值相同的方法,例如
public void myExampleFun(int a, int b)
或者
public int myExampleFun(int a)

对于 myExampleFun 可以传递给它两个参数,也可以传递给它一个参数,它可以无返回值,也可以有 int 类型的返回值。

行为上类似于 Python 的 range() ,可以给他传递不同数量的参数。

覆写 Override 指的是对于方法
public void yourExampleFun(int a) { print(“abc”) }
有一个方法签名相同,返回值相同的方法,例如
public void yourExampleFun(int a) { print(“ABC”) }

对于 yourExampleFun ,程序可以根据情况执行不同的内容。

例如 对于 C++ 的 << 符号,在不同情况下使用这个符号效果不同。

对象(类)之间的关系

除了继承和实现之外,还有其它的关系。

虽然下文中会提及对象之间的关系,但实际上,编程和 UML 图表示的是类之间的关系。

依赖 Dependency

继承是最基础、最微弱的关系,如果修改一个类,会影响另一个类,那么这两个类之间就存在依赖关系。

在指定方法签名、调用构造器对对象初始化的时候,通过让代码依赖接口或抽象类来降低依赖程度

关联 Association

关联:一个对象使用另一个对象或与另一个对象交互。即一个对象总是拥有访问与其交互的对象的权限。关联是一种依赖。

class Professor is
    field Student student
    method teach(Course c) is
        this.student.remember(c.getKnowledge())

teach 方法接收来自课程 Couse 类的对象作为参数,如果修改 getknowledge() 方法,上面的代码也将发生崩溃。可以说 ProfessorProfessor 类依赖于 Course 类。

Student 类的 student 对象是一个成员变量,如果 student 类的 remember 方法被修改,上面的代码也会发生奔溃。所以它们存在依赖关系。但是,Professor 类中的所有方法都能调用、访问 student 成员变量。

例如再定义一个方法 method exam(Papper p) 在这个方法中,仍可以调用、访问 student ,但是 Course 类只能在 teach 方法中访问,但不能在 exam 方法中访问了。

所以说 Professor 总是拥有访问 Student 的权限,所以 Student 与 Professor 类就不仅仅是依赖关系了,它们还存在关联关系。

有时候还会有双向关联的关系,这也是正常的。

聚合 Aggregation

聚合是一种特殊类型的关联,是一种”一对多“、”多对多“、”整体对部分的关系“。在聚合关系中:一个对象(对象A)“拥有”一组其他对象,(对象A)并扮演着容器或集合的角色。 组件可以独立于容器存在, 也可以同时连接多个容器。

例如一个院系由多个教授组成,院系类与教授类就是聚合关系。对于这个例子,这是一种”一对多”、“整体对部分”的关系。院系类是一个容器,教授类是一个组件。

院系对象是一个容器(集合),它里面包含了诸多(一组)教授对象,教授对象可以独立于院系存在,也可以同时属于多个院系。

在绘制 UML 类图中可以在两端标注数量。

组合 Composition

组合是一种特殊类型的聚合,组合与聚合不同的是,组件仅能作为容器的一部分存在

例如大学由各院系组成,各个院系不能独立存在。

总结

  • 依赖:对类 B 进行修改会影响到类 A 。
  • 关联:对象 A 知道对象 B。类 A 依赖于类 B。
  • 聚合:对象 A 知道对象 B 且由 B 构成。类 A 依赖于类 B。
  • 组合:对象 A 知道对象 B、由 B 构成而且管理着 B 的生命周期。类 A 依赖于类 B。
  • 实现:类 A 定义的方法由接口 B 声明。对象 A 可被视为对象B。类 A 依赖于类 B。
  • 继承: 类 A 继承类 B 的接口和实现, 但是可以对其进行扩展。对象 A 可被视为对象 B。类 A 依赖于类 B

设计模式 Design Pattern 简介

什么是设计模式

设计模式是软件设计的决绝方案,能够根据需求,提供蓝图模板,用于解决代码中出现的设计问题。

在上面的学习过程中,可以看到类之间有如此复杂的关系,对于同样一个目标,不同的代码逻辑、思路使用不同的关联关系实现。当对象、类一旦变多,它们的管理就会出现困难,程序的扩展性、可读性等方面迅速下降。因此,使用设计模式可以解决这种问题。

设计模式和算法都是解决问题的方案,但是算法是明确定义、达成目标的一系列步骤,而设计模式是更高层次的描述,使用同一个设计模式的两个程序实现代码可能不同。模式是的蓝图,算法是步骤。

设计模式是针对软件设计中常见问题的工具箱, 其中的工具就是各种经过实践验证的解决方案。它能指导如何使用面向对象的设计原则来解决各种问题。它定义了一种让你和团队成员能够更高效沟通的通用语言。

模式包含:

  • 意图:这个设计模式解决的是什么问题,用什么方法解决
  • 动机:解释问题,阐明该模式会如何提供欧冠解决方案
  • 结构:展示该模式的各部分和各部分之间的关系
  • 实现:使用不同的的编程语言实现

模式的分类

根据使用分类

不同的设计模式应用场景和复杂程度等方面不同

  • 最基础的模式称为惯用技巧,只能在某一种语言中使用
  • 通用的模式是架构模式,它们可用于整个程序的设计

根据意图分类

  • 创建型模式:提供创建对象的机制,增加代码的灵活性和复用性
  • 结构型模式:把对象和类组装成大的结构,保障结构的灵活和高效
  • 行为模式:负责对象间高效的沟通和职责委派

优秀设计的特征

代码复用

代码复用是减少开发成本的最常用的方式之一。

实际项目中通常需要付出额外的更改,才能利用已有代码在新的程序中工作。

我使用 bash 编写较为复杂的 shell 脚本中碰到了大量的代码复用问题。
用脚本部署某些环境组合时,它们都有下载、解包、安装,亦或是添加软件源来安装。它们都包含公用的部分,但是每个软件的安装方式不完全一样。
根据用户不同的行为,配置文件不同、安装方案不同。
因此,使用 bash 这种语言,想要大量的代码重用会变得非常困难。

Django 这种后端框架、Vue 前端框架、QT 库等也是代码复用的一个实例,它们让开发人员不必从头写起,可以直接使用代码。

扩展性

需求在不断的更改、增加,易于扩展,对于大型程序,在使用面向过程编程时,扩展性往往会受到一定程度的下降。

程序在多个平台能够适用也是程序需要扩展性的原因。

在设计程序架构时,应该尽量选择支持未来改变的方式。

设计原则

封装变化的内容

方法层面的封装

在初学编程阶段,例如

  • 已知1900年1月1日是星期一,打印任意一个月的日历。在这个程序中会用到多次判断平年和闰年的代码。
  • 在学习排序算法时会用到多个swap过程,用于交换两个变量的数据。

此时我们把这些经常用到的代码封装成函数,直接调用这些函数来实现功能。

在面向过程的设计中称为方法层面的封装,这样某一部分代码就被隔离到一个方法中,此外如果一段时间后逻辑发生改变,也可以将该方法移动到单独的类中。

类层面的封装

随着需求的变化,一个普通的方法可能会被修改得越来越复杂,新增的行为还会带来新的成员变量和新的方法,这样下去这个方法或使用方法的类的职责会变得模糊不清。将这些内容放在一个新类中会让程序更加简洁。

书中提到了一个计算税金的例子:

基于方法封装时

方法层面的封装

将 Order 类中所有与税金相关的工作分配给一个专门负责计算税金的对象,Order 类不再去处理相关内容,而是让这个对象去处理

类层面的封装

面向接口开发而不是面向实现

面向接口进行开发, 而不是面向实现; 依赖于抽象类型,而不是具体类。这样做的目的是:无需修改已有的代码就能对类进行扩展。

例如:
在两个类进行调用、合作时,最简单的做法是让一个类依赖于另一个类。但是这样做在修改一个类的代码时,另一个类也要修改。
现在设计一个接口让有需求的类依赖于这个接口,而不是具体的类,这样就会灵活很多。

设计接口的步骤:

  1. 确定一个对象对另一对象的确切需求:它需执行哪些方法
  2. 在一个新的接口或抽象类中描述这些方法
  3. 让被依赖的类实现该接口
  4. 让有需求的类依赖于这个接口, 而不依赖于具体的类

示例:

开发一款软件开发公司模拟器,使用了不同的雇员。

刚开始 Company 类关联于各类,类之间紧密耦合。(Company 类知道各雇员类,并对各雇员类始终具有访问权限)修改任意一个类都要对 Company 进行修改。

现在抽取出所有雇员的共同部分形成一个 Employee 接口,并利用前面提到的多态机制,Company 类通过 Employee 接口于雇员对象交互。

现在,可以看到由于在 Company 类中使用了 new 关键字来新建对象,目前为止 Company 类仍然关联于各个雇员类。除此之外 Company 类还依赖于 Employee 接口,且各个雇员类实现了 Employee 接口。

如果引入新的雇员类型,需要重写 Company 类的大部分内容。现在想办法降低公司类对各雇员类的依赖,也就是优化下面的三个箭头。

声明一个抽象方法来获取雇员,让具体的类用不同方式实现该方法,从而创建需要的雇员。

上图中,各子公司类与 Company 类是关联关系,Company 类始终具有对各子公司类的访问能力,各子公司类去

现在 Company 类已经独立于各雇员类了,对该类进行扩展(如果各雇员类改变),以 Company 类为基类,创建、修改子公司类,与此同时可以对基类中的代码进行复用。对 Company 类进行扩展时,无需修改已有的子公司类中的代码。

实际上这就是工厂方法模式的一个示例。

组合优于继承

继承是代码复用的最简单的方式,如果有两个代码近似的类, 创建一个基类,然后继承它,此时就把相似的代码移动到了子类中。

但是实际上继承会有相当多的问题:

  • 子类不能减少父类的接口,即使这个接口对该子类没用
  • 因为子类中的对象可以作为参数传递给父类,重写方法时,必须保证子类的行为与父类兼容
  • 继承打破了父类的封装,子类与父类有相同的代码,意味着子类可以访问父类的详细内容
  • 子类与父类紧密耦合,任意对父类的修改都会影响子类
  • 继承可能会导致平行继承体系,两个维度上的继承会使类的层次结构迅速膨胀

组合代替继承的一种方法。

  • 继承是“是”的关系,汽车是交通工具
  • 组合是“有”的关系,汽车有一个引擎

这个原则也使用于聚合,即聚合优于继承,一辆车上司机,司机可以开车也可选择步行,司机可以独立于汽车存在。

示例:

为汽车制造商创建一个目录程序,该公司同时生产 汽车 Car 和 卡车 Truck , 车辆可能是电动车 Electric 或 汽油车 Combustion ;所有车型都配备了手动控制 manual control 或 自动驾驶 Autopilot 功能。

使用继承使得层次关系复杂、庞大。使用组合,汽车对象可以将行为委派给其它对象。还可以为汽车对象分配一个不同的引擎来替换。

上面这个例子类似于策略模式。子类实现接口, Transport 类通过调用接口访问子类,形成组合。

继承是:

汽车和卡车是交通工具
汽车和卡车是由各自的电动、汽油引擎组成
各自的(共4种)引擎由是由具有AP功能的和不具有AP功能的组成

组合是:

交通工具有一个引擎接口和一个驾驶接口
引擎接口由各自的引擎类型实现,引擎不可独立存在
驾驶接口由各自的驾驶方式实现,可独立存在

SOLID 原则

SOLID 是让软件设计更易于理解、 更加灵活和更易于维护的五个原则的简称。同使用设计模式一样,这些原则不能盲目地遵守,否则可能会弊大于利。

单一职责原则 Single Responsibility Principle

尽量让每个类只负责软件中的一个功能, 并将该功能封装(隐藏)在该类中。这样修改这个类的原因只有一个。

该原则在于用多个清晰的类去实现目标,让代码具有可读性和扩展性,而不是把减少代码行数。

示例:

一个员工类有两个不同的方法,负责不同的功能,类中包含不同的行为。两个功能的任意一个改变,都要修改员工类。

现在将其中一个(次要功能)拿出来,独立创建一个类,让该类依赖于原先的员工类。这样额外的行为有了自己的类。

开闭原则 Open/Closed Principle

对于扩展, 类应该是“开放” 的; 对于修改, 类则应是“封闭”的。

该原则在于实现新功能时能保持已有代码不变。

现在有一个符合开闭原则的类

  • 如果要对这个类进行扩展,那么可以创建它的子类,新增成员变量并重写它的行为
  • 如果要其它类调用这个类,那么该类是封闭的,不应在调用时进行更改

示例:

原先由一个 Order 类,它的计算运费的程序写在 Order 类中,如果修改或添加一种运输方式,必然对 Order 类要进行修改,现在优化成这样:

Order 类拥有一个 Shipping 接口,各个运输方式类实现了这个接口,如果新建运输方式,无需更改 Order 类。这里还满足了单一职责原则,让每个类只干一件事。

里氏替换原则 Liskov Substitution Principle

基类应当可以替换超类并出现在超类能够出现的任何地方。重写一个方法时,对基类行为进行扩展,而不是将其完全替换。

书中描述了7个方面的内容,我认为有重复,要实现子类能够替换父类需要从以下5方面入手:

1、子类方法的参数类型必须与其超类的参数类型相匹配或更加抽象

  1. 如果一个类 A,有一个方法 feed(Cat c),可以为 Cat 类对象喂食,cat 对象会被传递给该方法
  2. 现在创建了该类的子类 B,重写了 feed 方法,可以给任何动物为食 feed (Animal c)
  3. 如果此时再把 Cat 类对象传递给这个重写了的 feed 方法,feed应能正常工作

上例中因为 Animal 类是 Cat 类的 超类,子类 B 比超类 A 接收参数的范围更大(更抽象),且子类的各个参数与父类匹配。

2、子类方法的返回值类型必须与超类方法的返回值类型或是其子类别相匹配

刚才是接收参数,现在是返回值。

  1. 如果一个类 A,有一个方法 buyCat() 返回一个 Cat 类对象
  2. 如果有一个子类 B,重写 buyCat() 可以返回一个特定类型的猫
  3. 如果返回的内容是一种动物(猫的超类)使用该类的类(客户端)会出现错误

“一种特定类型的猫”是“猫”的子类(范围更小)

3、子类中的方法不应抛出基础方法预期之外的异常类型

对于一个写好了的程序,客户端处理的是已有的异常类型,因此抛出的异常要相同,如果违反了该规则,在编译或运行过程中会出现错误

4、超类的不变量必须保留

超类中的一些不变量是让对象有意义的条件,例如猫有四条腿。实际扩展一个复杂的类时,容易忽略这些不变量,最安全的做法时引入新的成员变量和方法,但这并不总是可行。

5、子类不能修改超类中私有成员变量的值

有一些语言没有对私有成员变量进行保护,因此需要注意。

接口隔离原则 Interface Segregation Principle

尽量缩小接口的范围, 使得客户端的类不必实现其不需要的行为。

简单来说就是把一个大的,定义了很多方法的接口拆开成多个小的接口。与其他原则一样, 可能会过度使用这条原则。 不要进一步划分已经非常具体的接口。创建的接口越多,代码就越复杂。因此要保持平衡。

依赖倒置原则 Dependency Inversion Principle

设计软件时,不同层次的类:

  • 低层次的类:实现基础操作,(例如磁盘操作、 传输网络数据和连接数据库等)
  • 高层次类:包含复杂业务逻辑以指导低层次类执行特定操作

往常设计软件时,会先设计底层的东西,底层确定后,再根据底层设计高层的内容。如果采用这种方式, 业务逻辑类可能会更依赖于低层原语类。

依赖倒置原则建议改变这种依赖方式。

依赖倒置原则通常和开闭原则共同发挥作用: 无需修改已有类就能用不同的业务逻辑类扩展低层次的类。

示例:

高层次的 BudgetReport 类依赖于低层次的 MySQLDatabase 类来存取数据,意味着当数据库发生变化版本升级,都会影响高层次的类。

创建一个描述读写操作的高层接口,使用该接口代替低层次的类。

其结果是原始的依赖关系被倒置: 现在低层次的类依赖于高层次的抽象。

创建型模式

创建型模式提供对象的创建机制,提高灵活性和可复用性。创建型模式包含:

  • 工厂方法
  • 抽象工厂
  • 生成器
  • 原型
  • 单例

工厂方法

改变构造器的位置,父类不直接调用构造函数,而是提供一个创建对象的方法,让子类完成对象的实例化。

问题

开发一款物流管理应用,最初版本只能处理卡车运输,因此大部分代码都在位于名为 Truck 的类中。如果要添加一个轮船类将会十分复杂。卡车类之间,卡车类和其余部分已经存在了耦合关系,想要添加新类会变得困难。

解决方案

使用工厂方法替代直接使用构造器(new 运算符),即在子类中使用 new 来创建对象。超类中的工厂方法获得这个对象。工厂方法返回的对象称为“产品”,调用工厂方法的代码被称为“客户端”。

子类可以重写超类中的方法,创建不同的产品类型,这些产品需要有共同的超类或接口。即每个子类都要以自己的方式实现这个共有的接口,接口相同,才能传递给客户端使用。

客户端不需要知道对象间的区别,但是客户端知道这些对象都可以实现一个接口,客户端可以使用这个接口。

结构

  1. Product 声明接口,Creator 可以调用这个接口,Product 需要实现这个接口
  2. ConcerteProduct 使用自己的方法,并实现 Product 中声明的接口
  3. Creator 含有工厂方法,并声明工厂方法返回的对象类型要满足 Product 接口
  4. ConcreteCreator 重写工厂方法

实现方式

  1. 让所有产品实现同一接口
  2. 在创建者类中添加一个工厂方法,要求返回的类型要实现该接口
  3. 找到对产品构造函数使用的部分,把然们移入工厂方法
  4. 为工厂方法中的每种产品创建一个子类,然后重写工厂方法把创建相关的代码移动到这里
  5. 如果产品类型太多,可以将多个产品放在一个类中,并搭配 switch 语句
  6. 工厂方法中没有代码,可以将其转变成抽象类,如果还有,可以认为是该方法的默认行为

优缺点

优点

  • 使创建者和产品不再紧密耦合
  • 单一职责原则,一个 ConcreteCreator 只干一个事
  • 开闭原则,无需更改客户端代码,可以在程序中加入新的产品类型

缺点

  • 需要引入很多新的子类代码会变得复杂

抽象工厂模式

抽象工厂模式对工厂模式进行改善,能够创建一系列相关的对象。

问题

对于一个表格,一共对应了9个类

椅子沙发桌子
风格A
风格B
风格C

在添加、减少新产品或者是新风格的时候,尽可能少得去更改核心代码。

解决方案

第一步:

为系列中的每件产品声明接口,然后实现这个接口。例如椅子可以被坐,接口中含有被坐下这个方法,那么所有风格的椅子都应该实现被坐下这个接口。

把同一对象的所有变体,放在同一级类的层次结构中,即定义一个椅子接口,所有风格的椅子实现这个接口,在 UML 类图中,所有椅子均位于同一层级。

第二步:

声明抽象工厂,抽象工厂是一个接口,包含一系列产品的构造方法,例如创建椅子、创建沙发、创建桌子,这些方法要求返回抽象产品类型,即产品符合在第一步中声明的类型,如椅子类型、沙发类型、桌子类型

第三步:

根据抽象工厂,创建工厂类,例如:风格A工厂、风格B工厂、风格C工厂。它们只能返回特定类别的产品。如:风格A椅子、风格A沙发、风格A桌子

在应用初始化阶段,选择并创建需要使用的工厂对象,工厂对象一旦被创建,它有会创建自己的产品对象。

现在客户端要一把椅子,它只需要调用椅子接口,就可以使用了,客户端不需要知道是哪种椅子。

结构

  1. Abstract Product 抽象产品声明了一组相关的产品接口
  2. Concrete Product 是各类抽象产品的不同变体的实现,所有变体都要实现相应的抽象产品
  3. Abstract Factory 接口声明了一组创建各种抽象产品的方法
  4. Concrete Factory 实现抽象工厂的构建方法,创建特定变体的所有产品
  5. 客户端通过抽象工厂接口和产品接口来实现与任意具体工厂、产品交互

实现方式

  1. 绘制二维表格,明确类型与变体
  2. 为所有产品声明抽象产品接口,具体产品实现接口
  3. 声明工厂接口,为抽象产品提供构建方法
  4. 为产品的变体创建一个具体的工厂类
  5. 完成初始化代码,对特定的工厂类初始化
  6. 找出存在的对产品构造函数的调用,转化为抽象工厂的调用方法

优缺点

优点:

  • 确保同一工厂生出你的产品匹配
  • 避免客户端与具体产品代码耦合
  • 满足单一职责原则,易于维护
  • 满足开闭原则

缺点:

  • 引入众多接口和类,代码会变得复杂

生成器模式

允许分步创建复杂的对象,使用相同的代码创建不同的对象

问题

有一个复杂对象,需要对诸多成员变量和嵌套对象进行初始化工作,这些初始化代码通常位于一个包含众多参数的构造函数或是散落在客户端各处。

例如,对于创建一个 House 对象,建造一个房子而言:

  • 先间地板和墙
  • 安装房门和窗户
  • 建造屋顶

如果想要一个更明亮的房子,或是房子里还有其它设施(例如,暖气、排水、供电):

最简单的办法是扩展 House 基类,创建一系列子类,但是这样做会使搭配和组合变得相当多。
例如:有暖气、排水,没有供电的房子,或是没有暖气有排水有供电的房子。组合(考虑数学中的排列组合)会相当多,子类会非常复杂。

另一种方法使创建一个超级构造函数,需要传给构造函数一系列参数,通过这些参数来控制房子对象。例如:

House(windows,doors,rooms,hasGarden,hasSwimPool,hasGarage......)

使用上面这个构造函数时:

new House(4,2,3,true,null,true,null,...)

这种构造函数明显过于复杂,只有极少数房子才有游泳池,与游泳池对象相关的参数基本没用,构造函数使用起来一次要传递给它很多参数,非常困难。

解决方案

把构造代码从产品类中分离,放到生成器的独立对象中。

创建对象时,使用生成器对象执行一系列步骤,并且可以选择性的调用特定步骤。

需要创建不同形式的产品时,一些构造步骤会发生改变。可以创建多个不同的生成器。客户端使用接口与生成器交互,获得需要的对象。

主管

主管类定义创建步骤的执行顺序,生成器提供这些步骤的实现。主管类不是必须的,但是可以放入各种例行构造过程中,创建多个相同对象时,不必多次使用相同步骤。此外,对于客户端来说,主管类隐藏了狗仔的细节。

结构

  1. Builder 声明构建产品的通用步骤
  2. Concrete Builders 提供构建过程的不同实现
  3. Products 最终生成的对象,可以不再属于同一类层次结构或接口
  4. Director 定义构造步骤的顺序,便于复用
  5. 客户端将生成器和主管相关联

实现方法

  1. 定义通用步骤,确保它们可以制造所有形式的产品
  2. 在基本生成器接口中声明这些步骤
  3. 创建具体生成器,实现构造步骤
  4. 考虑创建主管类
  5. 客户端生成生成器和主管对象,把生成器传递给主管

优缺点

优点:

  • 分步创建对象,暂缓创建步骤,递归运行创建
  • 生成不同形式的产品,可以复用次昂同的代码
  • 符合单一职责原则,把复杂的构造代码分离出来

缺点:

  • 新增多个类,代码复杂程度增加

原型模式

原型可以复制已有对象。

问题

有一个对象,希望生成一个完全一样的复制品。不使用原型模式的话,会新建一个属于相同类的对象,然后,遍历原对象中的成员变量,并复制到新对象中。

但是有些对象可能拥有私有成员变量,对象以外不可见。复制时,必须知道对象所属的类,代码必须依赖该类。有时候,只知道对象实现的接口,不知道其所属的具体类。因此,从外部复制并不是可行的。

解决方案

为所有支持克隆的对象声明一个克隆方法,该接口能够克隆对象。只要实现了克隆方法,该对象可以从内部读取包括私有变脸在内的成员变量。实现克隆方法时:

  1. 创建一个当前类的对象
  2. 复制原始对象的所有成员变量

原型:支持克隆的对象

当对象有很多成员变量时,创建一系列不同类型的对象,使用不同方法配置。如果需要的对象与预先配置的相同,就直接克隆该原型。因此,直接克隆甚至可以替代子类的构造。

结构

  1. Prototype 接口声明克隆方法,一般该接口就这一个方法
  2. Concrete Prototype 类实现克隆方法
  3. 因此客户端复制实现了原型接口的对象

实现方式

  1. 创建接口,声明克隆方法,如果已有类层次结构,在原型所属类添加该方法
  2. 在原型类中定义一份以该类对象为参数的构造函数,它复制所有成员变量
  3. 克隆方法通常只有一行代码:使用 new 运算符调用原版构造函数
  4. 可以创建一个原型注册表,用于存储常用类型

优缺点

优点:

  • 可以克隆对象,无需与具体类耦合
  • 可以预生成各种原型
  • 方便地生成复杂对象
  • 用继承意外的方式处理复杂对象的配置

缺点:

  • 克隆循环引用的复杂对象非常麻烦

单例模式

单例保证一个类只有一个实例,并提供访问该实例的方法。由于单例模式在一个类中解决了两个问题,因此违背了单一职责原则

问题

1、保证一个类只有一个实例,控制某些共享资源的访问权限。运作方式:创建了一个对象A,过一会儿再创建一个对象,此时获得的是已创建的对象,而不是一个新对象。

普通构造函数无法实现,构造函数总是返回一个新对象。

2、提供一个全局访问节点。全局变量使用方便,但是不安全。单例模式允许程序在程序的任何地方访问这个对象,但是它可以保护该实例不被破坏。

解决方案

  1. 把构造函数设为私有,防止其它对象使用
  2. 建立一个静态方法作为构造函数,该函数调用私有的构造函数来创建对象
  3. 把这个对象保存在静态成员变量中,调用该函数时,总是返回这个缓存对象

结构

  1. Singleton 类声明一个 getInstance 静态方法返回所属类的实例
  2. 构造函数对客户端隐藏,调用 getInstance 是获取对象的唯一方法

实现方式

  1. 添加一个私有静态成员变量保存单例实例
  2. 声明一个公有静态构建方法用于获取单例实例
  3. 静态方法中实现“延时初始化”,首次调用创建新对象,此后返回该实例
  4. 构造函数为私有,静态方法可以调用构造函数,但其它对象不能调用
  5. 修改客户端代码,把对单例的构造函数的使用替换为静态方法调用

优缺点

优点:

  • 保证一个类只有一个实例
  • 获得了一个全局可访问的节点
  • 仅在首次请求时初始化

缺点:

  • 违反单一职责原则
  • 可能掩盖不良设计
  • 多线程环境下需要特殊处理
  • 客户端代码单元测试困难

结构型模式

结构型模式将对象和类组装成较大的结构,同时保持结构的灵活。

适配器模式

适配器使接口不兼容的对象能够相互合作

问题

现有一个应用,收集处理大量的 XML 数据,现在有一个分析函数库,可以分析现有数据,但是它只能接收 JSON 格式数据。

修改该分析函数库来支持 XML,修改依赖于函数库现有代码,并且有时候没有这个函数库的源码,因此无法修改。

解决方案

创建一个适配器,这是一种特殊的对象,转换对象接口,与其它对象交互。

  1. 适配器实现一个现有对象兼容的接口
  2. 现有对象可以使用该接口
  3. 适配器方法被调用后,以兼容的格式和顺序将请求传递给对象

有时候,还可以创建一个双向适配器,实现双向调用。

结构

对象适配器

适配器实现其中一个对象的接口,并对另一个对象进行封装。

  1. 客户端包含当前业务逻辑
  2. Client Interface 描述客户端与其它客户端合作时必须遵循的协议
  3. Service 中有一些功能类,与远些客户端不兼容
  4. Adaoter 同时与客户端和服务端交互,封装了服务对象,接收客户端通过适配器接口发起的调用
  5. 客户端与适配器交互即可。

类适配器

类适配器同时继承两个对象(现有类和服务类)的接口,只能在支持多继承的语言中实现。

实现方式

  1. 有零个类的接口不兼容
  2. 声明客户端接口,描述客户端如何与服务交互
  3. 根据客户端,创建适配器类,所有方法为空
  4. 在适配器类中添加一个成员变量用于保存服务对象
  5. 实现适配器类客户端需要的所有方法,适配器只负责接口转换,把工作委派给服务对象。
  6. 客户端通过接口使用适配器

优缺点

优点:

  • 满足单一职责原则
  • 满足开闭原则

缺点:

  • 整体复杂度增加,需要一系列接口和类

桥接模式

桥接是可以将一个大类,或多个紧密联系的类拆分成抽象和实现两个独立的层次结构。

问题

有一个形状类,扩展出圆和方,一个颜色类扩展出红和蓝。要把然们组合起来要创建四个类才行。

解决方案

问题原因是在两个维度长扩展,处理类继承时是常见的问题。

桥接通过将继承改为组合的方式来解决,抽取其中一个维度,使其称为独立的类层次。将颜色相关的代码抽取到颜色类中,在形状类中添加只想某一颜色对象的引用成员变量。这一成员变量使颜色类和形状类的桥梁,新增颜色不需要该百年形状的类层次,反之亦然。

抽象与实现

桥接定义中有:抽象部分和实现部分两个术语。

抽象(也称接口,不是编程语言中的接口)指的使高层,该层不完成具体工作,它把工作委派给实现部分(也称平台)。实际程序中,GUI 使抽象部分,实现部分是底层代码。

可以在两个方向上扩展

  • 开发不同的GUI
  • 底层支持多个不同的系统API,在多个系统上运行

结构

  1. Abstraction 提供高层控制逻辑,依赖于底层
  2. Implementation 申明通用接口,抽象部分通过这里的接口与实现对象交互
  3. Concrete Implementations 包含特定平台的代码
  4. Refined Abstraction 提供控制逻辑的变体
  5. 客户端,仅关心如何与抽象部分合作

与策略模式不同的是,桥接模式可替换抽象部分中的实现对象,切换不同的实现方法。桥接模式不光是对类进行组织,还提供了沟通方法。

实现方式

  1. 明确独立的维度,可能是:抽象与平台、前端与后端、接口与实现
  2. 了解客户端需求,在抽象类中定义
  3. 确定在所有平台上的业务,在通用实现接口中声明这些业务
  4. 创建实体类,遵循实现部分的接口
  5. 在抽象类中添加实现类型的引用成员变量
  6. 高层逻辑如果有多个变体,可以扩展抽象基类
  7. 客户端将实现对象传递给抽象部分的构造函数

优缺点:

优点:

  • 可以创建与平台无关的类和程序
  • 客户端仅与高层互动,不接触详细信息
  • 满足开闭原则
  • 满足单一职责原则

缺点:

  • 对高内聚的类使用该模式可能会让代码更加复杂

注:桥接、状态、策略非常相似,基于组合模式,但它们解决的是不同问题。

组合模式

将对象组合成树状结构,还能像独立对象一样使用。应用的核心类模型能用树状结构表示,使用组合模式才有价值。

问题

有两类对象,产品和盒子,一个盒子可以包含多个产品,也可以包含多个小盒子,小盒子同样可以包含产品和更小的盒子。

在这两个类的基础上,开发一个订购系统,订单中包含无包装的产品、装满产品的盒子、其它类型盒子,需要计算总价。

对于这种情况需要知道每个产品的详细信息,知道所有产品和盒子的类别,盒子的嵌套层数等等。直接计算不可行。

解决方案

组合模式建议,使用一个通用接口来与产品和盒子交互,并在接口中声明一个计算总价的方法。对于一个产品,直接返回总价格,对于盒子,遍历盒子中的项目价格,然后返回该盒子的总价格,如果有小盒子,遍历其中所有项目。

类似于算法中地递归。该方式无需了解对象的具体类,不用管是普通盒子、产品还是嵌套盒子,只要调用通用接口即可,对象会沿着结构传递下去。

结构

  1. Component 描述了树种简单项目和复杂项目的共有操作
  2. Leaf 是树的基本结构,不包含子项目
  3. Container 又称 Composite 包含盒子节点或其它容器等子项目的单位
    容器不知道子项目的具体类,只能通过接口于子项目交互
  4. 客户端通过接口与所有项目交互,它也可以通过接口与任意一个简单或复杂项目交互

实现方式

  1. 确保核心模型以树状结构表示
  2. 声明适用于简单和复杂元素的组件接口及一系列方法
  3. 创建叶子节点表示简单元素
  4. 创建容器类表示复杂元素,在该类种创建数组或成员变量存储对子元素的引用
    该数组必须同时能存叶子节点和容器
  5. 容器中定义添加和删除子元素的方法

这些操作可能会违反接口隔离原则,但可以让客户端无差别访问所有元素。

优缺点

优点:

  • 利用多态和递归,管理复杂树结构
  • 满足开闭原则

缺点:

  • 对功能差异较大的类,提供公共接口困难

装饰模式

允许通过对象放到特殊对象中,为原对象绑定新行为。

问题

写了一个通知功能库,其它程序用它向用户发送通知。最初版本基于 Notifier 类。类中有:

  • 少数几个成员变量
  • 一个构造函数,邮箱列表通过构造传递给 Notifier
  • 一个 send 方法,该方法接收客户端消息,并把消息按邮箱列表发送出去

后来,增加一个需求,使用 SMS 或 IM 发送消息。

普通做法是扩展 Notifer 类,在新子类种加入额外方法。但是如果需要同时发送多种通知形式的通知,就需要创建一个特殊子类,把这些方法组合在一起。不 同的子类组合数量过多,代码量会迅速膨胀。

解决方案

使用聚合和组合而不是继承,即:一个对象中包含多个其它对象,封装器(Wapper,词根 warp ,类似卷饼的一种食物)也是是装饰模式(Decorator Pattern)的别称。

“装饰器”对象能和其它“目标”对象关联,装饰器拥有一组与目标对象相同方法,它把接收到的请求委派给目标对象,它还可以把接收到的请求在委派给目标对象前后进行处理。

客户端将 Notifer 放入装饰中,形成一个栈结构,实际与客户端交互的是最后一个进入栈的装饰对象。客户端不在意是与原来的 Notifer 对象交互还是与装饰后的对象交互。

结构

  1. Component 声封装器和被封装对象的公用接口
  2. Concrete Component 被封装对象所属的类,定义基础行为
  3. Base Decorator 有一个指向 Concrete Component 的成员变量,该成员变量为 Component 接口类型,这样它就可以引用 Concrete Component 和 decorator
    Base Decorator把所有操作委派给被封装的对象
  4. Concrete Decorators 定义了可添加到 Base Decorator 的行为,Concrete Decorators 重写了 Base Decorator 的方法,并在调用 Base Decorator 之前或之后进行额外行为
  5. 客户端使用多层装饰来封装部件

实现方式

  1. 业务逻辑中可用一个基本组件和多个可选层表示
  2. 找出基本组件和可选层次的通用方法,创建组件接口并声明这些方法
  3. 创建具体组件类,定义基础行为
  4. 创建装饰基类,包含一个在第二步中创建的接口类型的变量,循行是连接具体组件和装饰
  5. 确保所有类实现组件接口
  6. 将装饰基类扩展为具体装饰,具体装饰在调用父类前后执行自身行为
  7. 客户端代码负责创建装饰,组合成需要的形式

优缺点

优点:

  • 无需创建子类即可扩展对象行为
  • 运行时添加或删除对象功能
  • 使用多个装饰对象组合集中行为
  • 符合单一职责原则

缺点:

  • 在封装器中删除特定封装器比较困难
  • 实际行为不受栈顺序影响的装饰使用困难
  • 各层多需要初始化配置代码

外观模式

外观能为程序库、框架或其它复杂类提供简单的接口。

问题

在代码中使用某个复杂的框架中的众多对象,需要对所有对象初始化、管理依赖关系,并按正确顺序执行。

最后程序中的逻辑与第三方库紧密耦合,代码维护困难。

解决方案

外观类提供给活动部件与子系统接口,与直接调用子系统(程序库、框架)而言,外观提供的功能有限,但是包含了客户端关心的内容。

当程序中需要包含几十种不同功能的复杂库整合,但只使用其中少部分功能,使用外观模式会非常方便。

例如,上传视频到某社交网站,需要用到专业的视频转换库,对于这个操作只需要一个视频转换库中的一个转码方法。创建一个类,使用该方法,这个类就是外观。

类似于直接使用 PR 和 ME 导出视频,和使用格式工厂直接转换视频格式。

结构

  1. Facade 提供访问特定子系统功能的便捷方式,了解如何重定向客户端请求
  2. Additional Facade 类可以避免多种不相关的功能污染外观,使其又变成复杂结构
    客户端和其它外观都可以使用 Additional Facade
  3. Complex Subsystem 由数十个不同对象构成。如果要用这些对象,必须深入了解子系统的实现细节, 比如按照正确顺序初始化对象和为其提供正确格式的数据。
  4. 客户端使用外观代替对子系统的直接调用

实现方式

  1. 考虑能否在现有子系统的基础上提供一个更简单的接口
  2. 在一个新的外观类中声明并实现该接口。外观类将调用重定向到子系统
  3. 如果要充分发挥这一模式的优势, 必须确保所有客户端代码仅通过外观来与子系统进行交互
  4. 如果外观变得过于臃肿,可以抽取出一个新的外观类

优缺点

优点:

  • 让代码独立于复杂子系统

缺点:

  • 外观类可能成为与程序中所有类耦合的上帝对象

享元模式

享元摒弃了在每个对象中保存数据的方式,通过共享多个对象所共有的相同状态,可以在有限内存中载入更多对象。

问题

对于一个弹幕游戏(例如车万游戏),每一个粒子(子弹)如果都用独立对象表示,这样的粒子一旦变多,会占满内存,无法新建粒子,程序就崩溃了。

解决方案

对于 Particle 类,color 和 sprite 两个成员变量消耗的内存很多。对于每个粒子:

  • 它们所存储的数据(颜色、演示)几乎完全一样
  • 另一些状态(坐标、移动矢量、速度)在不断变化。

总结出内在状态外在状态两个概念:

  • 内在状态:对象的常量数据,其它对象只能读取,不能修改
  • 外在状态:能被其它对象从“外部”改变

享元建议不在对象中存储外在状态,而是传递给依赖它的方法,程序在对象中保存内在状态,以方便重用。

容器对象(本例中的 Game)会把粒子存在 particles 的成员变量里,为了能将外在状态移动到这个类中,需要:

  • 创建数组成员变量来存储每个粒子的外在状态。
  • 创建另一个数组存储指向代表粒子的特定享元的引用

数组要保持同步,这样就能使用同一索引来获取某个粒子的所有数据。消耗内存最多的成员变量移动到了享元对象中,现在一个大的享元对象被多个小对象复用,而无需存储多个大对象的数据。

由于享元对象的状态不能被修改,因此享元类的状态只能 由构造函数进行一次性初始化,不能对其它对象公开其设置器或公有成员变量。

为了能方便地调用享元,可以创建一个工厂方法来管理享元对象的缓存池。工厂方法负责从客户端接收享元对象的内在状态作为参数,如果能找到所需的享元,就返回给客户端,如果内找到,就创建一个享元加入缓存池。

把这个方法放入到享元容器中,或者新建工厂类、创建静态工厂方法并将其放入实际享元类中。

结构

  1. 享元模式是一种优化,要确保程序中存在大量类似对象同时占用内存消耗问题。
  2. Flyweigh 类包含原始对象中部分能在多个对象中共享的状态,享元中村的状态是内在状态,传递给享元的状态称为外在状态
  3. Context 类包含原始对象中各不相同的外在状态,Context 与 Flyweight 组成原始对象的所有状态
  4. 通常情况下,原始对象的行为保留在享元中,调用享元方法必须提供部分外在状态作为参数。
    也可以把行为移动到 Context 中,炼乳享元作为数据对象
  5. 客户端负责计算、存储享元的外在状态
  6. Flyweight Factory 管理享元缓存池

实现方式

  1. 拆分内在与外在状态
  2. 保留类中表示内在状态的成员变量,设为不可修改
  3. 找到使用外在状态成员变量的方法,对它们新建一个参数,代替成员变量
  4. 有选择地创建工厂类来管理享元缓存池
  5. 客户端负责存储和计算外在状态的数值,外在状态和引用享元的成员变量可以移动到单独的情景类中

优缺点

优点:

  • 节省大量内存

缺点:

  • 牺牲速度换内存,调用享元方法需要重新计算情景数据
  • 拆分一个实体的状态,代码变复杂

代理模式

提供对象的替代品,控制对于原对象的访问,允许在请求提交给对象前后进行处理。

问题

有一个消耗导量资源的巨型对象,偶尔需要使用它。一般会采用延迟初始化,即在需要使用时初始化。没用一次就要初始化一次,代码重复。

解决方案

代理模式建议新建一个与原服务对象接口相同的代理类,客户端使用代理对象。代理类接收到请求后,创建实际服务对象,将工作委派给它。即:代理假扮成服务

如果需要在类的业务逻辑前后执行一些工作,无需修改核心类就能通过台历完成这项工作,代理实现的接口与原类相同,因此可以传递给任意一各客户端。

结构

  1. Service Interface 声明了服务接口,代理需要遵循该接口
  2. Service 提供了使用的业务逻辑
  3. Proxy 包含一个指向服务对象的引用成员变量,代理完成任务后会将请求传递给服务对象
    通常代理会管理服务的整个生命周期
  4. 客户端通过接口与服务或代理交互,可在一切需要服务对象的代码中使用代理

实现方式

  1. 如果没有现成的服务接口,需要创建一个接口实现代理服务和服务对象的交互
    服务并不是总有接口,因此可能需要对服务的所有客户端修改
    备选方法是让代理继承服务类
  2. 创建代理类,包含一个存储指向服务的引用的成员变量
    代理对整个生命周期进行管理
  3. 根据需求,代理在完成任务后将工作委派给服务对象
  4. 考虑新建一个方法来判断客户端使用的是代理还是实际服务
  5. 考虑为服务对象实现延迟初始化

优缺点

优点:

  • 在客户端无察觉的情况下控制服务对象
  • 可对服务对象的声明周期进行管理
  • 即使服务对象为准备好,代理也能正常工作
  • 满足开闭原则

缺点:

  • 代码变得复杂
  • 服务相应延迟

小结

  • 适配器为对象提供不同接口
  • 代理为对象提供相同接口
  • 装饰为对象提供加强接口

外观模式与代理都缓存了一个复杂实体对象,但不同的是:代理与其缓存的对象遵循同一接口,代理可与服务对象互换。

代理和装饰都基于组合,但代理管理服务对象的声明周期,装饰由客户端控制对象的生命周期

行为模式

行为模式负责对象间的高效沟通和职责委派

责任链模式

请求沿着处理者链进行发送。每个处理这均可对请求进行处理,或将其传递给链上的下个处理者。

一个订单系统,接收包含用户凭据的请求,用户需要登陆认证,才能创建订单,才外拥有管理权限的用户有所有订单的完全访问权限。

检查需要依次进行,如果用户凭据不正确导致认证失败,就不用后序检查了。后来:

  1. 刚开始,有人发现直接将原始数据传递给订购系统存在安全隐患,增加了额外的验证步骤
  2. 有人注意到系统无法抵御暴力密码破解方式的攻击,增加了一个检查步骤来过滤来自同一 ip 的请求
  3. 对包含同样数据的重复请求返回缓存中的结果,增加了一个检查步骤,确保只有没有满足条件的缓存结果时请求才能通过并被发送给系统

每次新增一个功能会使代码变得更加臃肿,修改某个检查步骤有时会影响其他的检查步骤,复用代码困难。维护成本也会激增。

解决方案

责任链将特定行为转换为独立对象,这个对象被称为处理者。每个检查步骤都可被抽取成仅有单个方法的类,并执行检查操作。请求和数据被作为参数传递给方法。

责任链建议将这些处理者连成一条链,链上的每个处理这有一个成员变量来保存对下一个处理者的引用。处理者除了处理请求,还负责把责任沿着链传递。

最重要的:处理者还可以决定不再传递给后序处理者,或不沿着链传递,这可以高效地取消、跳过后序处理步骤。

有时,处理者接收到请求后,可以自行决定能否对其处理,如果能够处理,处理者就不再传递请求。

结构

  1. Handler 声明处理者的通用接口,该接口通常只有一个方法处理请求,有时还会包含一个设置下个处理者的方法
  2. Base Handler 将所有处理者公用的样本代码放在其中
  3. Concrete Handlers接收到请求后,决定是否进行处理,是否沿着链传递
  4. 客户端根据程序逻辑一次性或动态生成链,客户端可以决定把请求发给链上的任意一处理者。

实现方式

  1. 声明处理者接口,描述处理方法的签名,确定客户端如何将请求数据传递给方法。
  2. 根据处理者接口创建抽象处理者基类
  3. 创建具体处理者子类并实现处理方法,处理者在接收到请求后需要决定:
    • 是否处理请求
    • 是否将该请求沿着链传递
  4. 客户端自行组装链,或者从其它对象出获得预先处理好的链。使用工厂类一根据配置创建链
  5. 客户端可以触发链中的任意一个处理者
  6. 客户端要准备好:
    • 链中可能只有单个链接
    • 部分请求无法到链尾
    • 请求可能到链尾都没被处理

优缺点

优点:

  • 可控制处理顺序
  • 满足单一这则原则
  • 满足开闭原则

缺点:

  • 部分请求未被处理

命令模式

把请求转化未对象,根据请求,将方法参数化、延迟请求或加入队列,实现可撤销操作。

问题

写一个 GUI 程序,创建一个包含多个按钮的工具栏,创建了大量按钮子类,负责实现不同的功能。对于复制、黏贴功能,复制按钮、菜单栏和快捷键都要起作用,需要把代码复制进多个类中,或者让菜单依赖于按钮。

解决方案

软件分层,一层负责用户图像界面,一层负责业务逻辑。一个 GUI 对象传递一些参数来调用一个业务逻辑对象。即一个对象发送请求给另一个对象。

命令模式建议 GUI 不直接提交这些请求,而是把所有细节(请求调用的对象、方法名称、参数列表等)抽取出来,形成命令类,该类中仅包含一个用于触发请求的方法。

命令对象负责链接不同的 GUI 和业务逻辑对象,此后 GUI 无需再关心业务逻辑。命令对象自行处理所有细节。

然后让所有命令实现形同接口,该接口只有一个没有任何参数的方法。命令成为了减少 GUI 和业务逻辑层之间耦合的中间层。

结构

  1. Sender 也称 Invoker 负责对请求进行初始化,包含一个成员变量来存储对于命令对象的引用
    发送者并不负责创建命令对象
  2. Command 接口通常指声明一个执行命令的方法
  3. Concrete Commands 会实现各种类型的请求,它将调用委派给一个业务逻辑对象
  4. Receiver 包含业务逻辑,几乎任何对象都可以作为接收者,他们呢完成自己的工作
  5. 客户端创建配置具体命令对象,需要将包括接收者实体在内的所有请求参数传递给命令的构造函数

实现方式

  1. 声明仅有一个执行方法的命令接口
  2. 抽取请求,实现命令接口的具体命令类
    类中包含一组成员变量,保存对实际接收者的引用,这些变量使用构造函数初始化
  3. 找到担任发送者职责的类,再类中添加保存命令的成员变量
    发送者只能通过命令接口与其命令进行交互,发送者不创建命令对象
  4. 修改发送者使其执行命令
  5. 客户端必须按照以下顺序来初始化对象
    1. 创建接收者
    2. 创建命令,如有需要,关联至接收者
    3. 创建发送者并于特定命令关联

优缺点

优点:

  • 满足单一职责原则
  • 满足开闭原则
  • 可以实现撤销和恢复功能
  • 实现操作的延迟执行
  • 将一组简单命令组合成一个复杂命令

缺点:

  • 代码会变得更加复杂

迭代器模式

不暴漏集合底层表现形式的情况下遍历集合中所有的元素

问题

集合是编程中常见的数据类型,是一组对象的容器。集合可以是列表、栈、树、图等。如果集合基于树或者列表,则需要用到深度优先或者广度优先,后续可能又需要一种新的方法。不断向集合中添加遍历算法会模糊集合的功能,此外有些算法只适用于特定应用。

由于集合提供不同元素的访问方式,代码会与特定集合类耦合。

解决方案

迭代器模式的主要思想是将集合的遍历行为抽取为单独的迭代器对象。

迭代器可以实现多种遍历算法,多个迭代器对象可同时遍历一个集合。迭代器还封装了遍历操作的所有操作的细节,例如当前位置和末尾剩余元素的数量。

迭代器提供一个获取集合元素的方法,客户端不断调用,直到它不返回任何内容时,迭代器就遍历了所有元素。

迭代器要实现相同的接口。客户端代码可以兼容任意类型集合或遍历算法。新增一种方法,新增一个迭代器即可。

结构

  1. Iterator 声明遍历集合所需要的操作
  2. Concrete Iterators 实现遍历集合的一种特定算法
  3. Collection 声明一个或多个方法来返回一个实现了第一步中定义的接口的迭代器
  4. Concrete Collections 会在客户端请求迭代器时返回一个特定的具体迭代器类实体。
  5. 客户端通过集合和迭代器的接口与两者交互,客户端一般从集合中获取迭代器。

实现方式

  1. 声明迭代器接口提供至少一个方法来获取集合中的下个元素
  2. 声明集合接口并描述一个获取迭代器的方法,返回值时迭代器接口
  3. 为希望使用迭代器进行遍历的集合实现具体迭代器类
  4. 在集合类中实现集合接口
  5. 检查客户端代码,用迭代器替代直接对集合的遍历

优缺点

优点:

  • 满足单一职责原则
  • 满足开闭原则
  • 并行遍历同一集合
  • 可暂停遍历,在需要时继续

缺点:

  • 程序只与简单的集合进行交互,不应使用迭代器
  • 对于某些特殊集合,使用迭代器可能比直接遍历的效率低

中介者模式

中介者能减少对象之间混乱无序的依赖关系,使它们通过一个中介者对象进行交互。

问题

有一个创建和修改客户资料的对话框,由文本框、复选框、按钮等组成。有些元素会直接互动,例如点中复选框后,显示一个隐藏文本框。元素间存在关联,对某个元素的修改可能会影响其它元素。

如果在表单元素代码中实现业务逻辑,程序很难复用这些代码,

解决方案

中介者模式建议停止组件之间的交流,让它们独立。让组件调用中介者,中介者重定向调用行为。最终组件只依赖一个中介者类。例如飞行员驾驶飞机,它们不会相互商量谁先降落谁后降落,而是由塔台进行管理。

结构

  1. Component 包含业务逻辑的类,可以连接到多个不同的中介者
  2. Mediator 声明组件的交流方法,只包含一个通知方法,组件将该
  3. Concrete Mediator 封装了多种组件间的关系,保存对组件的引用,对其进行管理
  4. 组件不知道其它组件的情况,它只能通知中介者

实现方式

  1. 找到一组紧密耦合的,需要独立的类
  2. 声明中介者接口并描述中介者和各种组件之间所需的交流接口
  3. 实现具体中介者类,该类可从自行保存其下所有组件的引用
  4. 还可以让中介者控制组件是创建和销毁,和工厂类似
  5. 组件必须保存对于中介者对象的引用
  6. 修改组件代码, 使其调用中介者的通知方法

优缺点

优点:

  • 满足单一职责原则
  • 满足开闭原则
  • 减轻应用中多个组件的耦合
  • 可以方便地复用组件

缺点:

  • 一段时间后,中介者会演化为上帝对象

备忘录模式

允许在不暴露对象实现细节的情况下保存和恢复对象之前的状态。

问题

一个编辑器,要实现保存对象的状态,如果需要撤销,就恢复到之前的状态。

最简单的办法是复制这个对象,但是私有变量没法从外部复制。快照中要包含许多冠以编辑器的信息,例如光标位置、文字内容、格式等,于是一个快照是一个容器,包含了这些内容。需要把这个容器存在

为了能让其它对象能保存、读取快照,右需要把这个变量设为公有。它们暴漏了编辑器的状态,其它类对快照类产生依赖。

私有变量无法复制,公有变量会暴露所有细节。

解决方案

备忘录将快照的工作委派给状态的拥有者原发器(Originator)对象,这样其它对象就不需要从外部复制编辑器状态了。编辑器类拥有状态的访问权,可以自行生成快照。

备忘录模式建议将对象状态的副本存在备忘录(Memento)对象中,除了创建备忘录的对象,其它对象不能访问备忘录的内容。其它对象只能使用受限制的接口和备忘录交互,它们可以获取快照的元数据(快照名字、创建时间等)但不能获取快照中对象的状态。

允许将备忘录保存在负责人(Caretakers)对象中,由于负责人仅能通过受限接口与备忘录互动,无法修改备忘录内部状态。对于文字编辑器,创建一个历史类作为负责人。

结构

嵌套类的实现

  1. Originator 类可以创建、恢复自身快照
  2. Memento 是 Originator 快照状态的值对象
  3. Caretaker 仅知道何时、为何创建、恢复快照
  4. 备忘录类被嵌套在原发器中

实现方式

  1. 确定担任原发器角色的类
  2. 创建备忘录类,声明对应原发器成员变量的备忘录成员变量
  3. 设置备忘录只能通过构造器一次性接收出局
  4. 允许使用嵌套类的语言,直接把备忘录嵌套在原发器里
  5. 在原发器中添加一个创建备忘录的方法
  6. 其它对象要知道如何请求、存储、使用备忘录对容器操作
  7. 负责人与原发器之间的连接可以移动到备忘录类中

优缺点

优点:

  • 不破坏封装的情况下创建快照
  • 让负责人维护历史记录,简化原发器

缺点:

  • 客户端如果创建太多备忘录,程序将消耗大量内存
  • 负责人必须完整跟踪原发器的声明周期,才能销毁启用的备忘录
  • 大部分编程语言不能确保备忘录的状态不被修改

观察者模式

观察者允许定义一种订阅机制,可在对象时间发生时通知多个对象。

问题

两个对象:顾客、商店。顾客每天去商店看商品是否到货,如果未到货顾客会空手而归。每次新产品到货时, 商店可以向所有顾客发送邮件,但是对此商品不感兴趣的对象不需要知道,会把邮件视为垃圾邮件。

要么要让顾客不断检查、要么让商店浪费资源去通知所有顾客。

解决方案

拥有一些值得关注的状态的对象被称为目标,他要将自生状态的改变通知给其它对象,所以也称为发布者(Publisher)希望关注发布者变化的其它对象称为订阅者(subscribers)。

观察者模式建议天机订阅机制,让每个对象都能订阅或取消订阅。该机制包括:

  1. 用于存储订阅者对象的列表
  2. 添加、删除订阅者,即维护该列表的公有方法

发生事件时,发布者要遍历订阅列表,并调用通知方法。实际应用中,可能有几十个不同的订阅者订阅一个发布者,因此订阅者都必须实现同样的接口,发布者通过该接口与订阅者交互。

如果应用中有多个不同类型的发布者,希望订阅者兼容发布者,那么也要让所有发布者遵循同样的接口。

结构

  1. Publisher 向其它对象发送事件,它维护一个订阅列表
  2. 事件发生时,Publisher 遍历列表,调用每个订阅者对象的通知方法
  3. Subscriber 声明通知接口,一般只包含一个 update 方法
  4. Concrete Subscribers 可以执行一些操作回应发布者,它们实现了同样的接口
  5. Publisher 通常会把一些其它数据作为参数传递,发布者也将自生作为参数
  6. 客户端分别创建 Publisher 对象和 Subscriber 对象,然后为订阅者注册发布者更新

实现方式

  1. 拆分发布者和订阅者
  2. 声明订阅者接口,有一个 update 方法
  3. 声明发布者接口并定义维护订阅者列表的方法
  4. 确定存放实际订阅列表的位置并实现订阅方法
    可以考虑组合的方式:将订阅逻辑放入一个独立的对象,然后让所有实际订阅者使用该对象
  5. 创建具体发布者类
  6. 在具体订阅者类中实现通知更新的方法
  7. 客户端必须生成所需的全部订阅者,并添加到订阅列表

优缺点

优点:

  • 满足开闭原则
  • 可以在运行时建立对象间的联系

缺点:

  • 通知顺序时随机的

状态模式

状态模式能在对象内部状态变化时改变行为。

问题

有限状态机,程序任意时刻都会处在集中有限的状态中。例如一个文章,可以处在草稿、审阅、发布三种状态中的一种。状态机由 if 或 switch 实现,说白了就是一组switch 或者 if…elseif…else 语句实现的功能。

只使用条件语句程序会变得越来越复杂,维护艰难。

解决方案

状态模式建议为对象的所有可能状态新建一个类,把对应的行为放在这些类里面。

原始对象被称为上下文(context),只保存表示\当前状态的对象\的引用,把与状态相关的工作委派给这个对象。需要切换状态时,把当前对象换为另一个对象。所有状态类都必须遵循同样的接口

结构

  1. Context 保存具体状态对象的引用,把工作派给该对象
  2. State 接口声明状态的方法,这些方法能被其他具体状态理解
  3. Concrete States 自行实现特定状态的方法,可存储对 Context 的反向引用
  4. Context 和 Concrete States 都可以设置 Context 引用的下个状态

实现方式

  1. 确定上下文
  2. 声明状态接口
  3. 为实际状态创建一个类,实现状态接口
  4. 在上下文类中添加一个状态接口类型的引用成员变量,以及一个修改该成员遍历的设置器
  5. 检查上下文的方法,把条件语句换成状态方法
  6. 为切换上下文状态,需要创建某个状态实例,并传递给上下文

优缺点

优点:

  • 满足单一职责原则
  • 满足开闭原则
  • 消除臃肿的条件语句

缺点:

  • 如果状态机只有很少几个状态,或不怎么改变状态,最好不要用此模式

策略模式

定义一系列算法,放入不同的类,算法对象可以相互转换

问题

对于一个地图导航程序:

  • 刚开始只有驾车导航
  • 后来有了公共交通导航
  • 然后是步行导航
  • 骑行导航
  • ……

每次添加新的路线规划算法后,类的体积增加一倍。对某个算法的修改会影响整个类,团队成员在写一个超级大的类。

解决方案

策略模式建议把每个功能放入到一组称为策略的独立的类中。上下文包含一个成员变量用来引用不同的策略。与状态模式不同的是:上下文不能选择需要的算法,也不知道选哪个策略,它只会通过接口与各种策略交互。因此,上下文可独立于具体策略。

结构

  1. Context 维护具体策略的引用
  2. Strategy 是所有 Concrete Strategies 的通用接口,声明的是用于执行策略的方法
  3. Concrete Strategies 算法不同变体的具体实现
  4. Context 需要运行算法时, 它会在其已连接的策略对象上调用执行方法
    它不知道自己用到策略和执行方式
  5. 客户端创建一个特定策略对象传递给 Context

实现方式

  1. 找到修改频率较高的算法
  2. 声明算法变体的通用策略接口
  3. 把变体梵高各自的类中,这些类需要实现策略接口
  4. 在上下文类中添加一个成员变量用于保存对于策略对象的引用,提供一个设置器共客户端修改要执行的策略
  5. 客户端必须将上下文类与相应策略进行关联

优缺点:

优点:

  • 运行时可切换执行的算法
  • 算法的实现和使用短发的代码隔离
  • 使用组合代替继承
  • 满足开闭原则

缺点:

  • 如果算法不怎么改变,使用该模式只会让程序过于复杂
  • 客户端必须知道什么时候选什么策略
  • 现代编程语言支持函数类型功能,不需要使用策略模式

模板方法模式

在超类中定义一个算法的扩建,允许子类在不修改结构的情况下重写算法的特定步骤。模板方法基于继承机制。

问题

程序从各种格式(csv、xls、pdf等)中找到有用的数据并以统一的格式返回给用户。尽管处理数据格式的代码完全不同,但是都需要对它们进行读取、分析、处理等。

客户端用了需要条件语句根据不同的对象类型选择处理过程,如果处理数据的类拥有相同的接口或基类,可以利用多态机制来调用函数。

解决方案

模板方法模式建议将算法分解为一些列步骤,然后将这些步骤改写为方法,最后在“模板方法”中依次调用实现这些方法。

这些分解的步骤可以是抽象的,也可以有默认的实现。客户端需要自行提供子类,并实现抽象的步骤或者重写一些步骤。

上例中,对于不同数据格式,打开、关闭、解析数据的代码不同。但是分析数据、生成报告等非常相似。因此把这些相似的步骤抽取到基类中。

包含两种类型的步骤:

  • 抽象步骤必须由各个子类来实现
  • 可选步骤由一些默认实现,可能需要重写

hook 步骤是内容为空的可选步骤,即使不重写 hook,模板方法也能正常工作。

  1. AbstractClass 声明算法步骤和调用它们的模板方法。
  2. ConcreteClass 可以重写所有步骤,但不能重写模板方法

实现方式

  1. 分析目标算法,分解为多个步骤,找到通用的步骤
  2. 创建抽象基类并声明一个模板方法和代表算法步骤的一系列抽象方法
    模板方法可用 final 修饰,以防子类重写
  3. 虽然所有步骤都可以设置为抽象类型,但是增加默认实现可以让子类不再一一实现
  4. 考虑在算法的关键步骤间增加 hook
  5. 为每个算法变体创建一个具体子类,必须实现抽象步骤,也可以重写可选步骤

优缺点

优点:

  • 允许客户端重写大型算法中的特定部分,减小部分修改的影响
  • 将重复的代码提取到超类中

缺点:

  • 部分客户端可能会收到算法框架的限制
  • 子类可能会违背里氏替换原则
  • 模板方法的步骤越多,维护就越困难

访问者模式

能将算法与坐拥对象隔离。

问题

已有多个类,为它们每一个增加一个相同的功能,例如把一种文件格式转换成另一种,或是转换成种文件格式。

修改原有的类可能会引入潜在的缺陷。

解决方案

访问者模式建议将新行为放入一个名为 访问者 的独立的类中。原始对象作为参数被传递给访问者,让方法能访问对象包含的必要数据。

访问者类可以定义一组方法,用于接收不同类型的参数。但由于在访问者无法提前知道原始对象所属的类,即使是支持重载的语言也无法确定到底应该调用哪个方法。因此不得不用一系列复杂的条件语句。

访问者提出了双分派技巧,即不要让客户端或者访问者去决定使用哪些方法,而是让传递给访问者的原始对象告诉访问者调用什么方法。

虽然修改了原来的类,但是改动很小。再抽取出访问者的通用接口,原先的类可以使用这个接口与任何访问者对象交互。

结构

  1. Visitor 接口声明一些访问者方法,对于支持重载的语言,可以利用重载机制,即方法名相同,参数不同
  2. Concrete Visitor 为不同的 Concrete Element 实现不同版本
  3. Element 声明一个方法来“接收”访问者,参数类型为 Visitor 接口类型
  4. Concrete Element 实现接收方法,根据具体元素基类调用相应的访问者的方法
  5. 客户端不知道 Concrete Element 的所属类,Concrete Element 通过接口与客户端的其它对象交互

实现方式

  1. 访问者接口依据不同的具体元素类声明一组对应的“访问”方法
  2. 声明元素接口,或在已有的元素层次接口中增加“接收”方法
  3. 在具体元素类中实现接收方法,接收访问者对象作为参数
  4. 元素类只能通过访问者接口与访问者进行交互,访问者也知道了具体元素的类
  5. 创建具体访问者,实现访问者方法
    当访问者需要访问元素的私有成员变量时:
    • 破坏封装,把这些变量或方法设为公有
    • 对于支持嵌套类的编程语言,可以把访问者类嵌套到元素里
  6. 客户端创建访问者对象,通过“接收”方法将访问者传递给元素

优缺点

优点:

  • 满足开闭原则
  • 满足单一职责原则
  • 访问者可以在不同对象交互时收集一些信息
    例如遍历复杂对象结构并在结构的每个对象应用访问者时,这些信息可能会有帮助

缺点:

  • 在元素层次结构中添加、删除一个类时,要更新所有的访问者
  • 访问者与某个元素交互时,可能需要打破元素的封装

结束

实际上还有许多设计模式,《深入理解设计模式》在我读下来是一本入门性质的书,作者也说到希望这本书是学习设计模式和获得程序设计能力的起点。

这本书非常的优秀,例子简单易懂,相比于看视频传授式地学习,读书能进行理性地思考,对不同设计模式有自己的理解。

这本书应该读两遍:

  • 第一遍是关掉代码编辑器甚至关掉电脑,总结出类似本文的粗读,尝试着去了解不同的设计模式
  • 第二遍是对于不同设计模式,尝试使用文中的例子,用一种或者多种编程语言实现

书中还有速查表等资源也是非常优秀,在忘记某一种设计模式的具体结构时可以迅速找到。

也希望在学习设计模式时迷茫的各位开始了解这些模式。本文对于学习设计模式是远远不够的,必须需要参照一本具体的书去学习,本文只是对粗读过程的一个总结。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇
隐藏
变装