编码 · 设计模式
策略模式详解与示例
简短回答
策略是一种行为型设计模式,它定义一族算法,把每个算法放进单独的对象并让它们遵循同一个接口,使其可以互相替换。使用算法的代码称为上下文,它持有对某个策略的引用并调用它,而不必知道具体是哪一个。在 TypeScript 中,策略往往就是一个函数,例如传给 array.sort() 的比较函数。
意图与它要解决的问题
意图:定义一族算法,把它们一个个封装起来,并使它们可以互相替换。策略让算法的变化独立于使用它的客户端。它是《设计模式》(Gang of Four,GoF)一书中的行为型模式之一,在书中也叫 Policy。
假设结账流程要计算运费。标准配送收固定费用,加急配送按每公斤加价,到店自提免费。第一个版本把三种公式都放进一个方法里,用 if/else 链按配送类型判断。每增加一个选项(例如合作快递公司),就得再改一次这个方法,而测试其中一个公式也得穿过其他所有分支。
使用策略模式时,每个公式都成为各自的对象或函数,具有相同的签名。结账流程接收选定的策略并调用它。增加一家快递公司,只需新增一个策略,结账流程不用改。
结构与参与者
| 角色 | 职责 |
|---|---|
| 策略 | 算法所有版本共用的接口 |
| 具体策略 | 算法的一种实现 |
| 上下文 | 持有一个策略,需要算法时调用它,也可以允许客户端替换它 |
| 客户端 | 选择具体策略并交给上下文 |
TypeScript 实现
由于 TypeScript 中函数是值,最简单的策略接口就是一个函数类型。上下文是 Checkout 类。
type Parcel = { weightKg: number; subtotal: number };
type ShippingStrategy = (parcel: Parcel) => number;
const standard: ShippingStrategy = (p) => (p.subtotal >= 50 ? 0 : 4.99);
const express: ShippingStrategy = (p) => 9.99 + 1.5 * p.weightKg;
const pickup: ShippingStrategy = () => 0;
class Checkout {
constructor(private shipping: ShippingStrategy) {}
setShipping(strategy: ShippingStrategy) {
this.shipping = strategy;
}
total(parcel: Parcel): number {
return Math.round((parcel.subtotal + this.shipping(parcel)) * 100) / 100;
}
}
const parcel = { weightKg: 2, subtotal: 30 };
const checkout = new Checkout(standard);
console.log(checkout.total(parcel)); // 34.99
checkout.setShipping(express);
console.log(checkout.total(parcel)); // 42.99
checkout.setShipping(pickup);
console.log(checkout.total(parcel)); // 30如果策略需要配置或有多个方法,就改用接口和类。这个版本还给每个策略加了名称,界面可以显示出来:
interface Shipping {
readonly label: string;
cost(weightKg: number): number;
}
class Courier implements Shipping {
readonly label: string;
constructor(private ratePerKg: number, company: string) {
this.label = company + " courier";
}
cost(weightKg: number) { return this.ratePerKg * weightKg; }
}
const options: Shipping[] = [new Courier(2, "North"), new Courier(3.5, "Fast")];
for (const o of options) console.log(o.label, o.cost(4));
// North courier 8
// Fast courier 14改造前后对比
改造前,算法的选择被硬编码在方法里:
function shippingCost(type: string, p: Parcel): number {
if (type === "standard") return p.subtotal >= 50 ? 0 : 4.99;
else if (type === "express") return 9.99 + 1.5 * p.weightKg;
else if (type === "pickup") return 0;
throw new Error("Unknown shipping type " + type);
}
// After: a lookup table of strategies replaces the chain
const strategies: Record<string, ShippingStrategy> = { standard, express, pickup };这段代码复用了第一个示例中的类型和函数。表格把选项集合变成了数据而不是代码,因此可以由配置构建,或由插件扩展。
何时使用,何时不用
- 当同一个任务有多种做法,而选择取决于配置、用户输入或数据时使用。
- 用来消除在多种算法之间做选择的大段条件分支。
- 用来单独测试每种算法,并让其他代码无需修改上下文就能添加新算法。
- 只有一种算法且没有真正需要改变它时不要使用。多出来的接口只会带来无谓的间接层。
- 函数参数就够用时,不要创建类。在 TypeScript 中,传递函数就是策略模式的轻量形式。
实际应用
Array.prototype.sort(compareFn)以函数形式接收比较策略,因此同一个排序可以对数字、日期或名称排序。- Java 的
Comparator接口就是Collections.sort和List.sort的策略。 - Node.js 的认证中间件 Passport.js 把每种登录方式(本地密码、OAuth 提供方)称为策略(strategy),让应用自行接入。
- 压缩和哈希库让调用方通过统一接口选择算法。
相关模式
| 模式 | 与策略的区别 |
|---|---|
| 状态 | 结构相同,但状态会随着对象的变化自行切换;策略通常由客户端选定,并保持到被替换为止。 |
| 模板方法 | 模板方法固定整体步骤,通过继承改变算法的某些部分;策略则通过组合替换整个算法。 |
| 命令 | 命令是稍后执行的请求;策略是上下文在需要时调用的一种完成任务的方式。 |
| 装饰器 | 装饰器从外部包装对象来改变其行为;策略则通过替换其中一部分,从内部改变行为。 |
常见问题
策略和状态有什么区别?
两者的结构几乎相同。策略由客户端选择,决定任务怎么做;而状态对象会因上下文自身的动作而改变上下文的当前状态。
传递回调就是策略模式吗?
是的。在函数是一等公民的语言中,实现算法可变部分的回调(例如比较函数)就是最简单形式的策略模式。
策略和模板方法有什么区别?
模板方法使用继承:基类固定步骤,子类填充其中一些。策略使用组合:上下文持有一个可以在运行时替换的对象或函数。
策略模式遵循开闭原则吗?
遵循。你可以在不修改上下文的情况下添加新策略,因此上下文对扩展开放、对修改关闭。