在软件工程中,创建型模式是解决对象创建问题的设计模式,试图根据实际情况使用合适的方式创建对象。基本的对象创建方式可能会导致设计上的问题,或增加设计的复杂度。创建型模式通过以某种方式控制对象的创建来解决问题。
创建型设计模式有 4 种:单例模式、工厂模式、建造者模式、原型模式。
单例模式(Singleton)
单例模式:一个类仅能创建一个实例。
作用:
- 保证项目中只有唯一的一个实例,比如我们的一个项目中一般只有一个配置对象。
- 处理资源访问冲突。比如一个打印日志到一个指定文件的类,就应该使用单例。否则多线程情况下,可能会创建多个对象,互相覆盖数据。对于多线程的情况,可以通过锁来解决访问问题。
Typescript 实现
以 ID 生成器为例,经典实现为:
class IDGenerator {
private id = 0
private static instance: IDGenerator = new IDGenerator()
private constructor() {}
static getInstance() {
return this.instance
}
getID() {
return this.id++
}
}
// 使用
const idGenerater = IDGenerator.getInstance()
const id = idGenerater.getID()
我们将构造器设为 private,这样我们就无法在类外部使用 new IDGenerator()
来关键对象。取而代之地是提供一个私有的实例好的 IDGenerator 静态对象属性,并提供一个静态方法来返回这个对象。这样我们就可以保证每次调用 IDGenerator.getInstance()
拿到的对象都是同一个对象。
前面的实现在类创建时就实例好了单例对象(Eager Initialization)。如果单例使用频率很低,甚至可能完全不被使用,我们可能会希望在真正被使用时才进行初始化操作,节省一些内存。对此,我们可以在第一次调用 getInstance()
时再实例化(Lazy Initialization)。其实这也是前端比较深入人心的懒加载思想。实现如下:
class IDGenerator {
private id = 0
private static instance: IDGenerator
private constructor() {}
static getInstance() {
if (!this.instance) {
IDGenerator.instance = new IDGenerator()
}
return this.instance
}
getID() {
return this.id++
}
}
额外需要说明的是,在支持多线程的语言中,在初始化时可能出现并发冲突的问题,需要通过加锁的方式来解决。这里就不展开讲了。
但 TypeScript(JavaScript)不是面向对象语言,我们通常是直接创建一个对象或函数来实现单例模式的。
const getID = (() => {
let id = 0
return () => {
return id++
}
})()
// 使用
const id = getID()
工厂模式(Factory)
在面向类编程中,工厂模式是用来解决不通过指定具体的类来创建对象问题的一种创建型设计模式。它是通过工厂方法来创建对象的,另外还需要通过接口或基类并让具体类来实现或继承它,而不是直接调用具体类的构造器。
工厂模式分为三种:简单工厂、工厂方法和抽象工厂。
简单工厂(Simple Factory)
假设我们现在需要给一些图形的操作封装成类,并实现一些的方法。为此,我们编写了 Shape 接口和实现它的 Rect、Circle 两个类,实现如下:
interface Shape {
draw(): void
}
class Rect implements Shape {
draw() {
console.log('draw rect')
}
}
class Circle implements Shape {
draw() {
console.log('draw circle')
}
}
我们需要根据用户的输入,来选择对应的类进行实例化。如传入 'rect' 字符串,则返回一个引用类型为 Shape 的 Rect 实例。实现如下:
function drawShape(type: string) {
let shape: Shape
if (name === 'rect') {
shape = new Rect()
} else if (name === 'circle') {
shape = new Circle()
} else if (name === 'path') {
shape = new Path()
} else {
throw new Error('invalid shape type!')
}
shape.draw()
}
可以看到,其中冗长的 if 语句用于实现选择使用哪种类来进行实例化。根据 单一职责原则,我们完全可以将这部分抽离出来作为一个独立的方法,而这个方法,就是我们要讲的 简单工厂类的实现:
class ShapeFactory {
static createShape(type: string): Shape {
let shape: Shape = null
if (type === 'rect') {
shape = new Rect()
} else if (type === 'circle') {
shape = new Circle()
}
return shape
}
}
这种写法是面向类的语言(比如 Java)的经典写法。对于面向类的语言,是无法直接创建对象和函数的,必须先声明类,然后用类来创建出对象,所以这些语言只能使用类和类的静态方法来模拟一个有方法的对象。
JavaScript 可以直接创建对象和函数,而不必依托于类。所以直接声明一个函数就完事了:
function createShape(type: string): Shape {
let shape: Shape = null
if (type === 'rect') {
shape = new Rect()
} else if (type === 'circle') {
shape = new Circle()
}
return shape
}
工厂方法(Factory Method)
工厂方法模式,是简单工厂模式的进一步细化。简单工厂模式中,各种具体形状类的创建都是通过唯一一个工厂类来进行对应的实例化。而工厂方法模式,则是通过具体的对应工厂类来创建的。如 Rect 类的实例,会通过 RectShapeFactory 工厂类来创建,其他的具体类也有对应的具体工厂类来创建。
TypeScript 实现
interface Shape {
draw(): void
}
class Rect implements Shape {
draw() {
console.log('draw rect')
}
}
class Circle implements Shape {
draw() {
console.log('draw circle')
}
}
// 工厂方法
interface ShapeFactory {
createShape(): Shape
}
class RectShapeFactory implements ShapeFactory {
createShape(): Shape {
return new Rect()
}
}
class CircleShapeFactory implements ShapeFactory {
createShape(): Shape {
return new Circle()
}
}
// 工厂的工厂
class ShapeFactoryFactory {
static createShape(type: string): ShapeFactory {
let shape: ShapeFactory = null
if (type === 'rect') {
shape = new RectShapeFactory()
} else if (type === 'circle') {
shape = new CircleShapeFactory()
}
return shape
}
}
const factory: ShapeFactory = ShapeFactoryFactory.createShape('rect')
const shape = factory.createShape()
shape.draw()
可以看到,相比简单工厂模式,工厂方法模式把每一个 if 判断及其后的创建对象过程移到了对应的不同的具体工厂类中,让具体形状类对应的工厂类进行创建。每当我们要添加一个新的类(如新的 polygon 形状类)时,也要对应添加其对应的工厂类(如 polygonFactory 类)。即有多少个具体类,我们就要有多少个具体工厂类。
另外,和简单工厂一样,为了能够快速地获得具体的工厂类,我们要对工厂类(RectShapeFactory 和 CircleShapeFactory 类)也提供一个简单工厂(ShapeFactoryFactory 类),即生产工厂的工厂。
值得一提的是,工厂方法中,这些工厂类往往创建一次实例就够了,没有必要每次调用 ShapeFactoryFactory.createShape(str)
时都实例化新对象。所以我们可以对工厂的工厂类做缓存处理,在类创建时,提前实例化好工厂类,在调用 createShape 静态方法时,直接返回缓存好的对象:
class ShapeFactoryMap {
static map = {
rect: new RectShapeFactory(),
circle: new CircleShapeFactory(),
} as const
static createShape(type: keyof typeof ShapeFactoryMap.map): ShapeFactory {
const shape: ShapeFactory = ShapeFactoryMap.map[type]
return shape
}
}
这里用了 TypeScript 的特殊写法。使用 as const
,并配合 keyof typeof ShapeFactoryMap.map
,可以得到 "rect" | "circle"
的联合类型。这样我们如果调用 createShape 方法时传入其他字符串的话,就会编译出错。因为篇幅原因,具体推导过程这里就不提了。
当然如果你不是很理解 ts 的这些类型推导也没关系。我们在这里也可以像普通的面向类语言一样,直接给 type 设为 string
类型,不过这样你就要尽可能保证传入的字符串是哈希表里含有的键,否则会拿到 undefined
。
从代码实现上,工厂方法模式相比简单工厂模式要更麻烦,更为繁琐。那么我们什么时候应该用简单工厂模式,什么时候用工厂方法模式呢?
- 如果要创建的子类很少且创建过程并不复杂,用简单工厂模式要更好,也不容易写错。
- 如果实例化的操作很复杂,涉及到其他类的创建和关联之类的,用工厂方法模式。遵循单一职责原则,将复杂的构建过程解耦还是有必要的。这时候如果你用简单工厂模式,将所有创建逻辑放在单独一个类里,会导致这个类异常复杂,不易维护。
- 另外要注意的是,工厂方法模式相比简单工厂模式,通常会使用哈希表缓存实例化的工厂对象,可以避免冗长的 if 分支逻辑,这算是工厂方法模式的一个小小的优点。
同样,JavaScript 因为可以直接创建对象和函数,所以上面的代码中一些工厂类,完全可以改写为对象或函数。具体如何改造你可以思考一下,这里就不具体讲解了。
抽象工厂(Abstract Factory)
抽象工厂模式适用的场景非常特殊,很少会用到。抽象工厂模式是工厂方法模式的加强版,工厂方法模式只是创建一个对象,而抽象工厂模式则是 创建一组对象。
假设现在我们要做一款桌面端 UI 框架,支持在 MacOS 和 Windows 上创建弹窗 dialog
和按钮 button
这两种组件,涉及到的类有 MacButton
, WinButton
, MacDialog
, WinDialog
。
如果我们用工厂方法模式的方法来实现,有几个组件类我们就要写几个工厂类。考虑到未来必然要添加更多的组件,那到时候对应的工厂类就非常多,维护起来将会很麻烦。比如我们要加一个 Header 组件,我们就要创建 4 个类 MacHeader、WinHeader、MacHeaderFactory、WinHeaderFactory,非常繁琐,如果我们以后还想支持更多的系统,如 Linux、IOS、Android,那这时候就会发生 组合爆炸,类将会非常多非常难维护。
这时候我们可以用抽象工厂模式来解决这个问题。
// Dialog
interface Dialog {
open(): void
}
class MacDialog implements Dialog {
open() {
console.log('open Mac Dialog')
}
}
class WinDialog implements Dialog {
open() {
console.log('open Windows Dialog')
}
}
// Button
interface Button {
draw(): void
}
class MacButton implements Button {
draw() {
console.log('draw Mac button')
}
}
class WinButton implements Button {
draw() {
console.log('draw Windows button')
}
}
// 抽象工厂
interface SystemFactory {
createDialog(): Dialog
createButton(): Button
}
class MacFactory implements SystemFactory {
createDialog() {
return new MacDialog()
}
createButton() {
return new MacButton()
}
}
class WinFactory implements SystemFactory {
createDialog() {
return new WinDialog()
}
createButton() {
return new WinButton()
}
}
// 使用
const factory: SystemFactory = new MacFactory() // 这里没用创建工厂的工厂
const button: Button = factory.createButton()
const dialog: Dialog = factory.createDialog()
button.draw()
dialog.open()
可以看到,我们将 createButton
和 createDialog
方法都集中到一个类里,让一个工厂可以创建一组的多个不同类型的对象,得以解决组合爆炸的问题。
另外,我们也要像工厂方法模式一样,再写一个创建工厂的工厂,来根据传入的字符串,来返回对应的 SystemFactory 类,代码和前面一样,这里就不写了。
建造者模式(Builder)
建造模式,或称生成器模式,是一种对象的构建模式。建造者模式并不复杂,就是 将类的创建过程从类中抽离出来。
作用:
- 将构建对象的过程独立于该对象的组成部分,比如我们经常在类的构造函数中进行参数的校验组合,当这个构造的过程过于复杂时,我们可以考虑把这个过程抽离出来,放到构造类中。
- 类的构造方法如果参数过多,调用时有传错位置的风险。虽然可以通过 setXX 方法来缓解,但会让类的方法过多,且会让对象会在一定阶段处于 “不稳定” 的状态(一些属性没有设置),此时进行一些需要用到一些没设置的属性的操作,就会出现意想不到的问题。比如忘了设置宽高就去调用方法去绘制一个矩形。对此可以使用建造者模式来解决。
TypeScript 实现
下面我们以创建一个矩形对象为例。我们给 Rect 类写了一个对应建造类 RectBuilder,来处理传入的一些参数,然后用于初始化返回一个 rect。rect 初始化需要的是 id, 左上角位置 x, y, 宽高 width, height。但我们希望能够通过提供中心位置来计算出左上角位置,来作为 rect 的初始属性。要解决这个问题,我们可以使用 重载,但为了讲解,这里便尝试使用建造者模式来实现这个需求。
class Rect {
constructor(private id: string, private x: number, private y: number, private width: number, private height: number) {}
draw() {
console.log(`draw rect with id ${this.id}, pos (${this.x}, ${this.y}), size ${this.width}x${this.height}`)
}
}
class RectBuilder {
private id: string = ''
private x: number
private y: number
private cx: number
private cy: number
private width: number
private height: number
setPos(x: number, y: number) {
this.x = x
this.y = y
return this
}
setCenterPos(cx: number, cy: number) {
this.cx = cx
this.cy = cy
return this
}
setSize(width: number, height: number) {
this.width = width
this.height = height
return this
}
setID(id: string) {
this.id = id
return this
}
build(): Rect {
if (this.id === '') {
throw new Error('不能设置空字符串')
}
if (this.width === undefined) {
throw new Error('未设置宽高')
}
let x: number, y: number
if (this.cx !== undefined) {
x = this.cx - this.width / 2
y = this.cy - this.height / 2
} else if (this.x === undefined) {
throw new Error('未设置位置或中心位置')
} else {
x = this.x
y = this.y
}
return new Rect(this.id, x, y, this.width, this.height)
}
}
// 使用
const rect: Rect = new RectBuilder()
.setID('rect-5')
.setPos(0, 0)
.setCenterPos(100, 100) // 此时前面设置的 x,y 被覆盖。
.setSize(30, 50)
.build()
rect.draw()
还有一个方法可以解决参数过多过复杂的问题,就是传入一个复杂的配置对象参数。如 ORM 库 Sequelize 的构造函数就可以传入一个可以有很多属性的 options 对象来进行初始化。算是一个不错的方案。
原型模式(Prototype)
原型模式:“复制”一个已经存在的实例来返回新的实例,而不是新建实例。被复制的实例就是我们所称的“原型”,这个原型是可定制的。
请务必和 JavaScript 特有的 原型继承 区分开来,两者是两种完全不同东西。JavaScript 的原型继承本质其实是委托,当当前对象中找不到属性时,会试图去原型链上的对象上去查找同名属性。原理上,JavaScript 的原型模式更像是设计模式中的 享元模式。
作用:适合对一些需要进行复杂算法或非常耗时(比如读取文件)才能得到的对象的拷贝。
原型模式非常简单,本质就是拷贝。拷贝方式分为 浅拷贝 和 深拷贝。浅拷贝只是简单地拷贝一层属性,如果拷贝后的属性的值为引用类型,修改该引用指向对象的内容,是会修改原型对象的。而深拷贝则是递归遍历引用类型的指向的对象,拷贝出新的对象,这样修改新对象就不会对原型对象产生影响,但缺点是比较耗性能。
浅拷贝,可以使用 Object.assgin()
方法:
class Apple {
address = { city: 'Tokyo' }
constructor(public color: string) {}
}
// 浅拷贝
const redOne = new Apple('red')
const redTwo: Apple = Object.assign({}, redOne)
redTwo.address.city = 'NewYork'
console.log(redOne.address.city) // 输出为 NewYork,修改了源对象
深拷贝,可以用 序列化再反序列化 或 手动遍历处理 的方式。
class Apple {
address = { city: 'Tokyo' }
constructor(public color: string) {}
}
// 深拷贝(序列化反序列化实现)
const greenOne = new Apple('green')
const greenTwo: Apple = JSON.parse(JSON.stringify(greenOne))
greenTwo.address.city = 'Beijing'
console.log(greenOne.address.city) // 输出为 Tokyo,没有修改源对象