理解设计模式:空对象
经典的设计模式有23种,详见原著《设计模式:可复用面向对象软件的元素》。这些模式为软件开发中经常遇到的特定问题提供了解决方案。
在本文中,我将解释什么是空对象模式,以及如何以及何时应用它。该模式并未收录在经典的模式书籍中,但它最早发表于《程序设计模式语言》,并被广泛用于避免复杂性。
空对象模式:基本思想
在面向对象编程中,空对象是指没有引用值或具有定义的中性(“空”)行为的对象。空对象设计模式描述了此类对象的用途及其行为(或缺乏行为)。——维基百科
这种模式的主要特点是可以避免代码的复杂性。在大多数语言(例如 Java、C# 或 JavaScript)中,引用可能为空。根据我们的业务逻辑,在调用任何方法之前,可能需要检查代码以确保它们不为空,因为方法通常无法在空引用上调用。
总而言之,空对象模式允许我们通过使用对象而不是原始类型来避免条件复杂性。该模式的UML图如下:
AbstractObject 类是一个抽象类,它定义了 RealObject 和“空”或“默认”对象(NullObject)中必须实现的各种操作。RealObject 会为每个真实对象执行相应的操作,而 NullObject 则不会执行任何操作,或者可能执行您希望在该对象中执行的默认操作。
空对象模式:何时使用
-
您需要动态、透明地向各个对象添加职责,也就是说,不影响其他对象。
-
您需要添加可以随时撤销的职责。
空对象模式:优点
空对象模式有几个优点,总结如下几点:
-
它定义了由真实对象和空对象组成的类层次结构。
-
当不希望对象执行任何操作时,可以使用空对象代替真实对象。
-
由于避免了条件复杂性,客户端代码更加简洁。客户端统一使用真实和空的协作者。
空对象模式——示例 1:赛亚人的世界(问题)
现在我将向您展示如何使用 JavaScript/TypeScript 实现此模式。在应用该模式之前,了解您正在尝试解决的问题是很有意思的。接下来,我们将为我们的示例提供背景。假设我们有一个名为 Saiyan 的类,它将允许我们建模我们亲爱的Saiyan的属性和方法。此类实现了一个 ISaiyan 接口,该接口明确确定了每个对象必须满足的特征才能成为真正的Saiyan。一个名为 SaiyanFactory 的工厂用于创建Saiyan对象。此类抽象了我们的 Saiyan 来自哪里,可以从 RAM 生成,数据库中的查询或用于制造新对象的复杂算法。
作为开发者,我们遇到的问题出现在充当客户端并使用我们工厂的类中。在下面的客户端代码中,我们调用了 getSaiyan 方法来获取多个Saiyan,具体来说,我们创建了Vegeta、Bob、Son Goku和Laura。我知道读者知道,前面列表中的 Saiyan 只有Vegeta和Son Goku;因此,Bob和Laura都无法被制造成 Saiyan 类型的对象。
我们始终要检查工厂返回的对象是否为空对象,因为我们不确定工厂是否始终返回 Saiyan 类型的对象。
最终代码存在不必要的条件复杂性,因为每个找到的对象上都有重复的 if-else 代码片段。我知道这段代码可以用函数抽象出来,但它仍然会保留在代码中。
因此,我们得到以下 UML 图。
ISayian
相关的代码Saiyan
如下:
export interface ISaiyan {
name: string;
power: number;
}
/****/
import { ISaiyan } from './saiyan.interface';
export class Saiyan {
protected name: string;
protected power: number;
constructor({ name, power }: ISaiyan) {
this.name = name;
this.power = power;
}
getName(): string {
return this.name;
}
public toString(): string {
return `${this.name} - ${this.power}`;
}
}
与工厂(数据库查找模拟)相关的代码如下:
import { Saiyan } from './saiyan.class';
export class SaiyanFactory {
public saiyans = [
{ name: 'Son Goku', power: 1000 },
{ name: 'Son Gohan', power: 800 },
{ name: 'Vegeta', power: 950 },
];
public getSaiyan(name: string): Saiyan | null {
// Mock Database find
for (const saiyan of this.saiyans) {
if (saiyan.name === name) {
return new Saiyan(saiyan);
}
}
return null;
}
}
最后,与客户端关联的代码由于工厂的空对象而导致条件复杂性呈指数级增长。
import { SaiyanFactory } from './saiyan-factory.class';
const saiyanFactory = new SaiyanFactory();
const saiyan1 = saiyanFactory.getSaiyan('Vegeta');
const saiyan2 = saiyanFactory.getSaiyan('Bob');
const saiyan3 = saiyanFactory.getSaiyan('Son Goku');
const saiyan4 = saiyanFactory.getSaiyan('Laura');
console.log('Saiyan');
if (saiyan1 !== null) {
console.log(saiyan1.toString());
} else {
console.log('Not Available in Customer Database');
}
if (saiyan2 !== null) {
console.log(saiyan2.toString());
} else {
console.log('Not Available in Customer Database');
}
if (saiyan3 !== null) {
console.log(saiyan3.toString());
} else {
console.log('Not Available in Customer Database');
}
if (saiyan4 !== null) {
console.log(saiyan4.toString());
} else {
console.log('Not Available in Customer Database');
}
空对象模式 — 示例 1:赛亚人的世界(解决方案)
解决方案是使用空对象模式。使用此模式的新 UML 图如下所示:
让我们从最终结果开始,也就是应用该模式后我们想要获得的结果。如果你观察客户端代码,你会发现 Saiyan 的四个请求的工厂被保留了下来。它们存储在变量中,这有助于我们避免在对每个 Saiyan 执行操作之前验证对象是否为空。在我们的示例中,我们使用 toString 方法只是为了说明如何安排一个返回字符串的方法。
因此,我们消除了客户端的复杂性,这得益于我们内部类结构的细微调整。工厂不再仅仅使用一个 Saiyan 类来生成新的 Saiyan,而是从该 Saiyan 类创建一个简单的继承(刚性组合),从而产生两个新类RealSaiyan和NullSaiyan,将 Saiyan 类转换为抽象类。
Saiyan 类现在定义了所有派生的 Saiyan 类必须实现的方法,在知识库中找到的 Saiyan 的逻辑将在RealSaiyan类中实现,而未找到的对象(null)的逻辑或者即使我们想要的默认行为将在NullSaiyan类中实现。
这样,即使他们没有将客户端从不适用的复杂性中解放出来,也总是会有这种行为。
我们现在来看看通过实现此模式生成的代码:
import { SaiyanFactory } from './saiyan-factory.class';
const saiyanFactory = new SaiyanFactory();
const saiyan1 = saiyanFactory.getSaiyan('Vegeta');
const saiyan2 = saiyanFactory.getSaiyan('Bob');
const saiyan3 = saiyanFactory.getSaiyan('Son Goku');
const saiyan4 = saiyanFactory.getSaiyan('Laura');
console.log('Saiyan');
console.log(saiyan1.toString());
console.log(saiyan2.toString());
console.log(saiyan3.toString());
console.log(saiyan4.toString());
与工厂相关的代码返回两种对象,如下所示:
import { AbstractSaiyan } from './saiyan.class';
import { NullSaiyan } from './null-saiyan.class';
import { RealSaiyan } from './real-saiyan.class';
export class SaiyanFactory {
public saiyans = [
{ name: 'Son Goku', power: 1000 },
{ name: 'Son Gohan', power: 800 },
{ name: 'Vegeta', power: 950 },
];
public getSaiyan(name: string): AbstractSaiyan {
for (const saiyan of this.saiyans) {
if (saiyan.name === name) {
return new RealSaiyan(saiyan);
}
}
return new NullSaiyan();
}
}
与之相关的代码AbstractSaiyan
如下:
export abstract class AbstractSaiyan {
protected name: string;
protected power: number;
public abstract getName(): string;
public abstract toString(): string;
}
最后,与每个具体类相关的代码如下:
import { AbstractSaiyan } from './saiyan.class';
import { Saiyan } from './saiyan.interface';
export class RealSaiyan extends AbstractSaiyan {
constructor({ name, power }: Saiyan) {
super();
this.name = name;
this.power = power;
}
getName(): string {
return this.name;
}
toString(): string {
return `${this.name} - ${this.power}`;
}
}
import { AbstractSaiyan } from './saiyan.class';
export class NullSaiyan extends AbstractSaiyan {
public getName(): string {
return 'Not Available in Saiyan Database';
}
toString(): string {
return 'Not Available in Saiyan Database';
}
}
我创建了几个 npm 脚本,这些脚本在应用 null-ojbect 模式后运行此处显示的代码示例。
npm run example1-problem
npm run example1-solution-1
结论
空对象模式可以避免项目中的条件复杂性。
此模式允许您配置当不存在对象时的默认行为,从而无需坚持检查对象是否为空。
此模式使用简单的继承来解决出现的问题。然而,此模式被归类为本文所研究的另一个模式的特例:策略模式。
因此,可以说这种模式使用严格的组合(继承)来解决一个可以用组合解决的问题,但会导致比其解决的问题本身更复杂的问题。这是一个很好的例子,说明作为开发人员,我们拥有的每个“工具”都必须在正确的时间使用,而我们行业中最重要的事情是了解所有工具以及何时使用它们。
最重要的不是像我展示的那样去实现模式,而是能够识别这个特定模式能够解决的问题,以及何时应该或不应该实现该模式。这一点至关重要,因为具体实现会根据你使用的编程语言而有所不同。