通过我这次在 Java 预科班的“回炉重造”(不得不说这两个月时间的预科内容是真的十足!),勾起了我对设计模式的一些兴趣及思考,紧接着就安排自己记录一下自己曾经模糊的一些概念和知识。
结合课程和一些书籍资料,在本文中,我们将重点介绍 Singleton、Prototype、Builder设计模式。
单例模式
这种设计模式是最著名的设计模式之一,这种类型的设计模式属于创建型模式,提供了一种创建对象的最佳方式。
“四人帮”(GoF) 1994年,有四位作者:Erich Gamma,Richard Helm,Ralph Johnson和John Vlissides发表了一本题为《设计模式 - 可重用的面向对象软件元素》的图书,该书在软件开发中开创了设计模式的概念。
在该书中描述了五个设计模式
Singeton
Builder
Prototype
Abstract Factory
Factory pattern
单例设计模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。提供了一种访问唯一的对象的方式,可以直接访问不需要实例化该类的对象。
正如 GoF 书中这么一句话所说:
“确保一个类只有一个实例,并提供对其的全局访问点。”
因此,将一个类设为单例有两个要求:
- 具有独特的实例
- 可以从任何地方访问
在此 UML 图中,Singleton 类包含3个内容:
- 类属性(实例):此属性包含单例类的唯一实例。
- 一个公共的方法
getInstance():它提供了获取 Singleton 的唯一实例的唯一方法。该方法是类方法(而不是实例方法),可以在任意地方调用。 - 私有构造方法
Singleton():可以防止任何人从任何地方使用构造方法实例化 Singleton。
在 Singleton 类内的 singleton 实例可以是:
- 预初始化的(这意味着它在有人调用
getInstance()之前就被实例化) - 延迟初始化(这意味着它在第一次调用
getInstance()时被实例化)
先来做一下简单的总结
单例模式
TODO:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
WHAT:一个全局是用的类被频繁地创建与销毁。
WHEN:业务原因;避免无状态类的多个实例以节省系统资源的时候。
P.S. 不应该使用单例在不同对象之间共享变量 / 数据,因为它会产生非常紧密的耦合。
HOW:判断系统是否已经有这个单例,如果有则返回,如果没有则创建。
关键代码:构造方法是私有的。
Java 实现
以下时使用预先实例化的方法在 Java 中创建单例的一中非常简单的方法。
public class SimpleSingleton{
private static final SimpleSingleton INSTANCE = new SimpleSingleton();
private SimpleSingleton(){ }
public static SimpleSingleton getinstance(){
return INSTANCE;
}
}
使用这种方式,当类加载器加载类时,仅创建一次单例实例。如果在代码中从未使用过该类,则不会实例化该实例(因为 JVM 的类加载器根本不会加载该实例),因此浪费内存。
不过,如果仅在真正使用单例(延迟初始化)时才需要创建单例,则这是在多线程环境中进行的一种方法,这部分涉及到线程的一致性。
public class TouchySingleton {
private static volatile TouchySingleton instance;
private TouchySingleton() { }
public static TouchySingleton getInstance() {
if (instance == null) {
synchronized (TouchySingleton.class) {
if (instance == null) {
instance = new TouchySingleton();
}
}
}
return instance;
}
}
此单例中包含一个同步锁,以避免两个线程同时调用 getInstance() 来创建两个实例。由于该锁的成本很高,因此首先要进行不带锁的测试然后进行带锁的测试(这是经过双重检查的锁),以便在实例已存在时不使用该锁。
调用者甚至可能没有意识到他们一直都在使用同一对象。
其它特殊性是,实例必须易变,以确保实例在创建时在不同处理器内核上的状态相同。
⚖️ 利 弊
✅ 可以确定一个类只有一个实例。
✅ 将获得对该实例的全局访问点。
✅ 仅在首次请求时才初始化单例对象,减少了内存的开销,尤其是频繁的创建和销毁实例。
❎ 违反单一责任原则。
❎ 例如当程序的各个组件彼此之间了解太多时,单例模式可能掩盖错误的设计。
❎ 该模式在多线程环境中需要特殊处理,以便多个线程不会多次创建一个单例对象。
❎ 可能很难对 Singleton 的客户端代码进行单元测试,因为许多测试框架在生成模拟对象时都依赖于继承。
❎ 由于单例类的构造函数是私有的,并且在大多数语言中都不可能覆盖静态方法,因此您将需要考虑一种创造性的方式来模拟单例。
原型模式
原型模式(Prototype Pattern)是用于创建重复的对象,同时又能保证性能。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
问题
假设我们有一个对象,并且想要创建它的精确副本,会怎么做呢?首先,必须创建一个相同类的新对象,然后必须遍历原始对象的所有字段并将其值复制到新对象。
很好!但是很有可能掉入了一个陷阱——并非所有的对象都可以通过这种方式进行复制,因为对象的某些字段可能是私有的,并且在对象本身外部不可见。
这种方式还有一个问题。由于必须知道对象的类才能创建新的副本,因此我们的代码将依赖于该类。有时,例如方法中的参数接收遵循某个接口的任何对象时,仅知道该对象遵循的接口,而不了解其具体类。
解决方案
原型模式将克隆过程委派给要克隆的实际对象。该模式为所有支持克隆的对象声明一个公共的接口。该接口使得我们可以克隆对象,而无需将代码耦合到该对象的类。通常这样的接口仅包含一个 clone 方法。
该 clone 方法的实现在所有类中都非常相似。该方法创建当前类的对象,并将就对象的所有字段值都带入到新对象。甚至可以复制私有字段,因为大多数编程语言都允许对象访问属于同一类的其他对象的私有字段。
支持克隆的对象称为_原型。_当对象具有数十个字段和数百种可能的配置时,克隆它们可以作为子类的替代方法。
预先构造的原型可以替代子类。
工作原理大致是这样的:创建一组以各种方式配置的对象。当需要一个已配置的对象时,只需克隆一个原型,而不是从头开始构造一个新对象。
真实类比
在现实生活中,原型用于开始大规模生产产品之前执行各种的测试。但是,在这种情况下,原型并不参与任何实际生产,而是扮演被动角色。
细胞的分裂。
由于工业原型并不能真正复制自身,因此与该模式更相似的是有丝细胞的分裂过程(高中生物课…)。有丝分裂后,形成一对相同的细胞。原始单元充当原型,并在创建副本中发挥积极作用。
结构体
- 该原型接口声明的克隆方法。在大多数情况下,这是一种
clone的方法。 **ConcretePrototype**类实现的克隆方法,除了将原始对象的数据复制到克隆外,此方法还可以处理克隆过程的一些极端情况,这些情况与克隆链接的对象,揭开递归依赖性等有关。- 该
**客户端**可以产生下面的原型接口的任何对象的副本。
原型注册表实现
该原型注册表提供了一种简单的方法来访问频繁使用的原型。它存储了一组准备复制的预建对象。最简单的原型注册表示 name -> prototype 哈希映射。但是,如果需要比简单名称更好的搜索条件,则可以构建功能强大的注册表。
适用性
- 当代码不应依赖于需要复制的具体对象时,请使用 Prototype 原型模式。
当代码与通过第三方接口从第三方代码传递给对象一起使用时,这种情况经常发生。这些对象的具体类是未知的,即使我们愿意也不能依赖它们。
原型模式为客户端代码提供了一个通用接口,用于处理所有支持克隆的对象。此接口使客户端代码独立于其克隆的对象的具体类。
-
当要减少仅在初始化各自对象的方式上有所不同的子类时,请使用该模式。有人可能已经创建了这些子类,从而能够创建具有特定配置的对象。
原型模式使您可以使用以各种方式配置的一组预构建对象作为原型。客户端无需实例化与某些配置匹配的子类,而是可以简单地寻找合适的原型并将其克隆。
如何使用
-
创建原型接口并 clone 在其中声明方法。或仅将方法添加到现有类层次结构的所有类中(如果有的话)。
-
原型类必须定义将该类的对象作为参数接受的替代构造函数。构造函数必须将类中定义的所有字段的值从传递的对象复制到新创建的实例中。如果要更改子类,则必须调用父构造函数,以让父类处理其私有字段的克隆。(使用方法的重载)
-
克隆方法通常仅由一行组成:使用 new 构造函数的原型版本运行运算符。请注意,每个类都必须显式重写克隆方法,并使用其自己的类名和 new 运算符。否则,克隆方法可能会产生父类的对象。
-
(可选)创建集中式原型注册表,以存储常用原型的目录。可以将注册表实现为新的工厂类,也可以使用静态方法将其放入基本原型类中以获取原型。此方法应基于客户端代码传递给该方法的搜索条件来搜索原型。条件可以是简单的字符串标签或复杂的搜索参数集。找到适当的原型后,注册表应将其克隆并将副本返回给客户端。
最后,将对子类的构造函数的直接调用替换为对原型注册表的factory方法的调用。
⚖️ 利 弊
✅ 可以克隆对象而无需耦合到它们的具体类。
✅ 可以摆脱重复的初始化代码,而倾向于克隆预构建的原型。
✅ 可以更方便地生产复杂的对象。
✅ 处理复杂对象的配置预设时,您可以使用继承的替代方法。
❎ 克隆具有循环引用的复杂对象可能非常难处理。
建造者模式
建造者模式(Builder Pattern)使用多个简单的对象一步一步构建成一个复杂的对象。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
问题
最简单的解决方案是扩展 House 基类并创建一系列子类以覆盖参数的所有组合。但是最终我们将获得大量子类。任何新参数(例如门廊样式)都将需要进一步扩展此层次结构。
在大多数情况下,大多数参数都不会被使用,从而使构造函数的调用十分不简洁。
例如,只有一小部分房屋有游泳池,因此与游泳池相关的参数将是十分之九的无效。
解决方案
生成器模式能够分步骤创建复杂对象。 生成器不允许其他对象访问正在创建中的产品。
该模式将对象构造分为一组步骤(buildWalls, buildDoors, buildWindows等)。若要创建对象,都需要在生成器对象上执行一系列这些操作。重点在于无需调用所有的步骤,而只需调用创建特定对象配置所需的那些步骤即可。
例如,房子的墙壁可以用木头建造,但是城堡的墙壁必须用石头建造。
例如,假设有一个建造者用木头和玻璃建造所有东西,第二个建造者用石头和铁建造所有东西,而第三个使用黄金和钻石建造。通过执行相同的步骤,将从第一个建造者那里得到一栋普通房屋,从第二个建造者那里得到一座小城堡,从第三个建造者那里获得一座宫殿。但是,只有在调用构建步骤的客户端代码能够使用公共接口与建造者进行交互的情况下,这才起作用。
导向器(主管)
导向器就像导演或主管一样,我们可以在构建产品的创建步骤的一系列调用提取到一个单独的名为 Director 的类中,该类中定义了执行构建步骤的顺序,而生成器提供了这些步骤的实现。
严格来说,在程序中并不一定需要主管类,我们始终可以直接从客户端代码以特定顺序调用创建步骤。但是 Director 类可能是放置各种构造例程的好地方,因为可以在程序中反复使用它们。
此外,Director 类完全从客户代码中隐藏产品构造的详细信息。客户只需要将一个生成器与一个主管类相关联,然后使用主管类来构造产品,就能从生成器处获得构造结果了。
结构体
- 建造者/生成器(Builder) 接口声明适用于所有类型的生成器产品建设步骤。
- 具体生成器(Concrete Builders) 提供了施工步骤的不同实施方案。具体的建筑商可能会生产不遵循通用接口的产品。
- **产品(Products)**是最终的对象。由不同构建者构建的产品不必属于相同的类层次结构或接口。
- 主管(Director) 类定义了调用构造步骤的顺序,这样就可以创建和产品的再利用特定配置的顺序。
- **客户端(Client)**必须将生成器对象之一与 Director 相关联。 通常,通过 Director 构造函数的参数只完成一次。然后,Director 使用该生成器对象进行所有进一步的构造。但是,当客户端将导向器对象给 Director 的生产方法时,有另一种方法。在这种情况下,每次与 Director 一起制作东西时,都可以使用其它导向器。
适用性
🤨 使用构造者模式可避免“重叠构造函数(telescopic constructor)”的出现。
假设有一个带有十个可选参数的构造函数,调用这样的函数很不方便。因此,将重载构造函数并新建几个只有较少参数的简化版。但这些构造函数仍然需要调用主构造函数,传递一些默认值来替代省略掉的参数。
class Pizza {
Pizza(int size){...}
Pizza(int size, boolean chess){...}
Pizza(int size, boolean chess, boolean peeperoni){...}
// ...
}
通过 Builder 模式,可以仅使用真正需要的步骤逐步构建对象。实施该模式之后,我们就不必再将数十个参数填充到构造函数中了。
如果希望代码能够创建某些产品的不同表示形式(例如 石头和木质房屋),应使用 Builder 模式。
构造者模式让我们可以分步骤生成对象, 而且允许仅使用必须的步骤。 应用该模式后, 再也不需要将几十个参数塞进构造函数里了。
🤨 当希望使用代码创建不同形式的产品 (例如石头或木头房屋) 时, 可使用构造者器模式。
如果需要创建的各种形式的产品, 它们的制造过程相似且仅有细节上的差异, 此时可使用构造者模式。
基本生成器接口中定义了所有可能的制造步骤, 具体生成器将实现这些步骤来制造特定形式的产品。 同时, 主管类将负责管理制造步骤的顺序。
🤨 使用构造者构造组合树或其它复杂对象。
构造者模式让我们能分步骤构造产品。可以延迟执行某些步骤而不会影响最终产品。甚至可以递归调用这些步骤,在创建对象树时非常方便。
生成器在执行制造步骤时,不能对外发布未完成的产品。这可以避免客户端代码获取不完整结果对象的情况。
如何实施
-
确保可以明确定义用于构建所有可用产品表示形式的通用构建步骤。否则,将无法继续用该模式。
-
在基本生成器接口中声明这些步骤。
-
为每个产品表示形式创建一个具体的生成器类,并实施其构造步骤
-
可以创建一个类似 Director 的类,它可以使用同一生成器对象来封装多种构造产品的方式。
-
客户端代码会同时创建生成器和主管对象。 构造开始前, 客户端必须将生成器对象传递给主管对象。 通常情况下, 客户端只需调用主管类构造函数一次即可。 主管类使用生成器对象完成后续所有制造任务。 还有另一种方式, 那就是客户端可以将生成器对象直接传递给主管类的制造方法。
-
只有在所有产品都遵循相同接口的情况下, 构造结果可以直接通过主管类获取。 否则, 客户端应当通过生成器获取构造结果。
⚖️ 利 弊
✅ 可以逐步构造对象,推迟构造步骤或递归运行步骤。
✅ 在构造产品的各种表示形式时,可以重复使用相同的构造代码。
✅ 单一责任原则。可以讲复杂的构造代码与产品的业务逻辑隔离开。
❎ 由于该模式需要创建多个新类,因此代码的整体复杂性就增加了。