设计模式综述

简介

设计模式是在软件工程多年的发展中,经过前人不断挖坑填埋总结而来。
一般认为有 23 种,其他多是基于这 23 种延伸。
在面向对象编程语言中,
设计模式一般遵循 SOLID 原则
Acronym | EN | CN | DESC
– | – | – | –
S |Single Responsibility Principle | 单一职责原则 |一个程序只做好一件事,复杂功能拆开
O | Open-closed Principle| 开放/封闭原则 | 对扩展开放,对修改封闭,增加需求时,扩展新代码,而非修改已有代码
L |Liskov Substitution Principle | 里氏替换原则 |子类能覆盖父类,父类能出现的地方子类就能出现
I | Interface Segregation Principle| 接口隔离原则 |接口职责单一,防止接入不使用的接口与方法
D |Dependency Inversion Principle | 依赖倒转原则 | 面向接口编程,依赖于抽象而不依赖于具体,使用方只关注接口而不关注具体类的实现

设计模式原则

Single Responsibility Principle

A class should have one and only one reason to change, meaning that a class should have only one job.

思考如下一个例子:
计算给定的基本图形的面积总和,以圆形正方形为例。
形状

const circle = (radius) => {
const proto = {
type: "Circle",
//code
};
return Object.assign(Object.create(proto), { radius });
};
const square = (length) => {
const proto = {
type: "Square",
//code
};
return Object.assign(Object.create(proto), { length });
};

计算面积

const areaCalculator = (shapes) => {
const proto = {
sum() {
return shapes.reduce((p, n) => {
if (n.type == "Circle") {
return Math.PI * n.radius * n.radius + p;
}
if (n.type == "Square") {
return n.length * n.length + p;
}
}, 0);
// logic to sum
},
output() {
return `
<h1>
Sum of the areas of provided shapes:
${this.sum()}
</h1>
`;
},
};
return Object.assign(Object.create(proto), {
shapes,
});
};

使用

const shapes = [circle(2), square(5), square(6)];
const areas = areaCalculator(shapes);
console.log(areas.output());

上述方式违反单 SRP 原则。
在计算面积并输出的过程中,存在一个问题:
计算面积工厂产出的产品只提供 HTML 一种格式的输出,如果后续需求是输出 JSON 或者 XML?
原因在于计算面积工厂承担过多的职责,在计算面积的同时,需要承担输出。
将输出单独用一个工厂,并简化计算面积工厂,进行优化

计算面积

const areaCalculator = (shapes) => {
const proto = {
sum() {
return shapes.reduce((p, n) => {
if (n.type == "Circle") {
return Math.PI * n.radius * n.radius + p;
}
if (n.type == "Square") {
return n.length * n.length + p;
}
}, 0);
// logic to sum
},
};
return Object.assign(Object.create(proto), {
shapes,
});
};

输出面积

const sumCalculatorOutputter = (sum) => {
const proto = {
HTML() {
return `
<h1>
Sum of the areas of provided shapes:
${sum}
</h1>
`;
},
JSON() {
return JSON.stringify({
sum,
});
},
};
return Object.assign(Object.create(proto), { sum });
};

使用

const shapes = [circle(2), square(5), square(6)];
const areas = areaCalculator(shapes);
const output = sumCalculatorOutputter(areas.sum());
console.log(output.JSON());
console.log(output.HTML());

完整代码示例

如果将 sumCalculatorOutputter 中 HTML 与 JSON 放在 areaCalculator 中,看起好像也没什么大问题。
考虑如下情况:
当需求不断扩张,当我们需要计算图形面积差,输出多种格式,计算特定图形的面积的时候……
如果所有职责都放在一个工厂里面,那么这个函数将会变得臃肿和不可维护,上千行的函数,上万行的文件也就这样诞生。

Open-closed Principle

Objects or entities should be open for extension, but closed for modification.

预留需求扩展接口(方法),禁止修改已有接口与方法。
在计算面积的工厂中 sum 方法存在如下问题:
如果后续有新的图形需求,我们需要不断地修改 sum 方法,sum 方法将变得非常臃肿。
这样写肯定违反 OCP 原则。
如果该图形不具备通用性,只在某个需求中计算一次面积,那么放在 sum 方法中,所有用 sum 方法的地方都会多余的执行该判定条件,造成多余的开销。
计算面积应该属于对应图形的方法,优化代码,将各自的计算面积 area 方法放在对应的图形工厂。
square

const square = (length) => {
const proto = {
type: "Square",
area() {
return this.length ** 2;
},
};
return Object.assign(Object.create(proto), { length });
};

circle

const circle = (radius) => {
const proto = {
type: "Circle",
area() {
return Math.PI * this.radius ** 2;
},
};
return Object.assign(Object.create(proto), { radius });
};

rect

const rect = (length, width) => {
const proto = {
type: "Rect",
area() {
return this.length * this.width;
},
};
return Object.assign(Object.create(proto), { length, width });
};

计算面积

const areaCalculator = (shapes) => {
const proto = {
sum() {
return shapes.reduce((p, n) => n.area() + p, 0);
// logic to sum
},
};
return Object.assign(Object.create(proto), {
shapes,
});
};

使用

const shapes = [circle(2), square(5), square(6), rect(3, 4)];
const areas = areaCalculator(shapes);
const output = sumCalculatorOutputter(areas.sum());
console.log(output.JSON());
// {"sum":85.56637061435917}
console.log(output.HTML());
// <h1>
// Sum of the areas of provided shapes:
// 85.56637061435917
// </h1>

完整代码示例

当遇到多个判定条件的时候,可以思考其是否违反了 OCP 原则。

Liskov substitution principle

Let q(x) be a property provable about objects of x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

如果 q(x)对 T 类型的 x 实例成立,那么对 T 的子类 S 的实例 y,q(y)必成立。
子类可替代父类,代替后表现一致,即子类要对父类的特性保持兼容性。
后三个原则涉及到接口与类,用新的 TS 示例代码来说明。
在几何中,正方形被看做一个特殊的长方形,即其长宽相等。在抽象化的时候,我们趋向于让其继承与长方形。
Rectangle

class Rectangle {
constructor(private width: number, private length: number = 10) {}

public setWidth(width: number) {
this.width = width;
}

public setWidth(length: number) {
this.length = length;
}

public getArea() {
return this.width * this.length;
}
}

Square

class Square extends Rectangle {
constructor(side: number) {
super(side, side);
}

public setWidth(width: number) {
// A square must maintain equal sides
super.setWidth(width);
super.setLength(width);
}

public setLength(length: number) {
super.setWidth(length);
super.setLength(length);
}
}

使用

const rect: Rectangle = new Rectangle(10); // Can be either a Rectangle or a Square
rect.setWidth(20);
if (rect.getArea() > 300) {
console.log("It is big!");
} else {
console.log("It is small!");
} // 200

用 Square 替换

const rect: Rectangle = new Square(10); // Can be either a Rectangle or a Square
rect.setWidth(20);
if (rect.getArea() > 300) {
console.log("It is big!");
} else {
console.log("It is small!");
} // 400

完整代码示例
用子类替换父类后,表现不一致,不符合 LSP。
可见,Square 作为 Rectangle 并不是一个好的方案。
一个更好的方案为抽象出一个 Shape 接口。

interface Shape {
getArea: () => number;
}

调整代码后如下

interface Shape {
getArea: () => number;
setWidth: (width: number) => void;
setLength: (length: number) => void;
}

class Rectangle implements Shape {
width: number
length: number
constructor(width: number, length: number) {
this.width = width;
this.length = length;
}

public setWidth(width: number) {
this.width = width;
}

public setLength(length: number) {
this.length = length;
}

public getArea() {
return this.width * this.length;
}
}

class Square implements Shape {
width: number;
constructor(width: number) {
this.width = width;
}
public setWidth(width: number) {
this.width = width;
}
public setLength(length: number) {
}
public getArea() {
return this.width * this.width;
}
}


const square: Shape = new Square(20);

if (square.getArea() > 300) {
console.log('It is big!')
} else {
console.log(square.getArea(), 'It is small!')
}


// const rect: Shape = new Rectangle(20, 20);

// if (rect.getArea() > 300) {
// console.log('It is big!')
// } else {
// console.log(rect.getArea(), 'It is small!')
// }

Interface segregation principle

A Client should not be forced to depend upon interfaces and methods that they do not use.

接口使用方不应该强制接入不使用的接口与方法。

在 LSP 中,最后的代码,Square 被强制实现了不需要的 setLength 方法。
需要进一步优化,抽象出不同图形对应的接口

interface Shape {
getArea: () => number;
}

interface Rectangle extends Shape {
width: number;
length: number;
}

interface Square extends Shape {
width: number;
}

Implementation...

优化后的代码如下:

interface Shape {
getArea: () => number;
}

interface RectangleFace extends Shape {
width: number;
length: number;
}

interface SquareFace extends Shape {
width: number;
}
class Rectangle implements RectangleFace {
width: number
length: number
constructor(width: number, length: number) {
this.width = width;
this.length = length;
}

public setWidth(width: number) {
this.width = width;
}

public setLength(length: number) {
this.length = length;
}

public getArea() {
return this.width * this.length;
}
}

class Square implements SquareFace {
width: number;
constructor(width: number) {
this.width = width;
}
public setWidth(width: number) {
this.width = width;
}
public getArea() {
return this.width * this.width;
}


const square: Shape = new Square(20);

if (square.getArea() > 300) {
console.log('It is big!')
} else {
console.log(square.getArea(), 'It is small!')
}


// const rect: Shape = new Rectangle(20, 20);

// if (rect.getArea() > 300) {
// console.log('It is big!')
// } else {
// console.log(rect.getArea(), 'It is small!')
// }

Dependency Inversion Principle

Entities must depend on abstractions not on concretions. It states that the high level module must not depend on the low level module, but both should depend on abstractions.

依赖虚拟接口而不是实体类,关注接口,不关注类。
设计模式综述20211015102130
这里的接口与生活中的接口理解上没有太大差别。
对于手机充电来说,如果充电器针对手机而不是充电接口来设计,那么该充电器只能用于其型号。
如果针对充电接口来设计,比如都设计成 typec 接口的,那么所有实现 typec 接口的设备都可使用。
在与后端对接接口时,我们常使用如下代码:
infra/HttpClient.ts

import axios from "axios";

export default {
createUser: async (user: User) => {
return axios.post(/* ... */);
},
getUserByEmail: async (email: string) => {
return axios.get(/* ... */);
},
};

domain/SignupService.ts

import HttpClient from "infra/HttpClient";
export async function signup(email: string, password: string) {
const existingUser = await HttpClient.getUserByEmail(email);

if (existingUser) {
throw new Error("Email already used");
}

return HttpClient.createUser({ email, password });
}

上述代码 SignupService 与 HttpClient 耦合严重,不便于对其进行单元测试,同时,
mock 假数据也不方便。

优化代码
domain/ApiClient.ts

// domain/ApiClient.ts
export interface ApiClient {
createUser: (user: User) => Promise<void>;
getUserByEmail: (email: string) => Promise<User>;
// ...
}

infra/HttpClient.ts

// infra/HttpClient.ts
import axios from "axios";
import ApiClient from "domain/ApiClient";

export function HttpClient(): ApiClient {
return {
createUser: async (user: User) => {
return axios.post(/* ... */);
},
getUserByEmail: async (email: string) => {
return axios.get(/* ... */);
},
};
}

domain/SignupService.ts

// domain/SignupService.ts
import ApiClient from "domain/ApiClient";

export function SignupService(client: ApiClient) {
return async (email: string, password: string) => {
const existingUser = await client.getUserByEmail(email);

if (existingUser) {
throw new Error("Email already used");
}

return client.createUser({ email, password });
};
}

实际使用
index.ts

// index.ts
import SignupService from "domain/signup";
import HttpClient from "infra/HttpClient";

const signup = SignupService(HttpClient());

signup("bob@bob.com", "pwd123");

测试代码
infra/InMemoryClient.ts

// infra/InMemoryClient.ts
import ApiClient from "domain/ApiClient";

export function InMemoryClient(): ApiClient {
const users: User[] = [];

return {
createUser: async (user: User) => {
users.push(user);
},
getUserByEmail: async (email: string) => {
return users.find((user) => user.email === email);
},
};
}

tests/SignupService.spec.ts

// tests/SignupService.spec.ts
import SignupService from "domain/signup";
import InMemoryClient from "infra/InMemoryClient";

let signup: ReturnType<typeof SignupService>;

beforeEach(() => {
signup = SignupService(InMemoryClient());
});

test("it should signup a user", async () => {
await expect(signup("john@test.com", "pwd123")).resolves.toBe(undefined);
});

test("it should fail to signup the same email twice", async () => {
await signup("mark@test.com", "pwd123");
await expect(signup("mark@test.com", "pwd987")).rejects.toThrow(
new Error("Email already used")
);
});

BMW WARNING

  • Bulletin

本文首发于 skyline.show 欢迎访问。

I am a bucolic migrant worker but I never walk backwards.

  • Material

参考资料如下列出,部分引用可能遗漏或不可考,侵删。

S.O.L.I.D The first 5 principles of Object Oriented Design with JavaScript > Liskov Substitution Principle in Functional TypeScript > Dependency Inversion Principle in Functional TypeScript

  • Warrant

本文作者: Skyline(lty)
授权声明: 本博客所有文章除特别声明外, 均采用 CC BY - NC - SA 3.0 协议。 转载请注明出处!

CC BY - NC - SA 3.0

Copyright © 2017 - 2024 鹧鸪天 All Rights Reserved.

skyline 保留所有权利