策略模式
从 SimUDuck 谈起
SimUDuck 是一个鸭子模拟器应用。初始设计使用继承:所有鸭子共享一个 Duck 基类,子类覆写 display() 以展示不同外观。
classDiagram
direction LR
class Duck {
<<abstract>>
+quack()
+swim()
+display()*
}
class MallardDuck {
+display()
}
class RedheadDuck {
+display()
}
Duck <|-- MallardDuck
Duck <|-- RedheadDuck
quack() 和 swim() 由基类统一实现,display() 由子类各自覆写(绿头鸭和红头鸭有不同的外观)。这个设计清晰直观——直到需求变更。
继承的第一道裂缝
产品经理要求所有鸭子都能飞。最直觉的做法是在 Duck 中添加 fly() 方法,所有子类自动继承。
但问题随即暴露:橡皮鸭也飞上了天。橡皮鸭不会叫(quack() 已被覆写为吱吱声),现在又多了不该有的飞行能力。可以在 RubberDuck 中将 fly() 覆写为什么都不做来临时补救,但紧接着又要加入木头诱饵鸭(DecoyDuck)——它既不会飞也不会叫,又得覆写两个方法。
产品经理还说每六个月要推出一批新鸭子。每次新增子类,都要逐一检查并可能覆写 fly() 和 quack(),这是一场维护噩梦。
接口方案的陷阱
一个自然的想法是把 fly() 和 quack() 从基类中移除,改为定义 Flyable 和 Quackable 接口。能飞的鸭子实现 Flyable,能叫的鸭子实现 Quackable。这解决了「不该有的能力」问题,但带来了新问题——代码重复。如果有 48 种鸭子需要飞行能力,这 48 个类都要各自实现一遍相同的 fly() 方法。一旦飞行逻辑需要修改,就要改 48 处。
| 方案 | 优点 | 致命缺陷 |
|---|---|---|
继承(基类实现 fly()) |
代码复用好 | 不需要该行为的子类也被迫继承,覆写负担重 |
接口(Flyable/Quackable) |
精确控制每个子类的能力 | 相同行为在多个子类中重复实现,修改代价大 |
继承不够灵活,接口导致重复——两条路都走不通。问题的根源在于:软件开发中唯一不变的就是变化本身。客户需求变了、底层数据库换了、技术栈升级了、对现有系统的理解加深了……系统必须能优雅地应对变化。
设计原则驱动的重构
SimUDuck 的困境引出了三个关键的设计原则。它们并非全新的概念——在上一节的七大设计原则中已有系统论述——但这里我们通过实际案例看到它们如何协同工作,从问题驱动出最终的设计。
封装变化
Encapsulate what varies. Identify the aspects of your application that vary and separate them from what stays the same.
在 SimUDuck 中,fly() 和 quack() 是随鸭子类型变化的部分,而 swim() 和 display() 的基本结构是稳定的。将变化的部分抽取出来,封装到独立的类族中,后续就可以在不影响稳定部分的前提下自由修改或扩展变化的行为。
这正是开闭原则(OCP)和可变性封装原则(EVP)的实践体现:找到系统中的可变因素,将其封装起来。
具体做法是:将 fly() 和 quack() 从 Duck 类中抽离,为每种行为创建一组独立的类。
面向接口编程
Program to an interface, not an implementation. The declared type of the variables should be a supertype, usually an abstract class or interface, so that the objects assigned to those variables can be of any concrete implementation of the supertype.
这里的「接口」不仅指 Java 等语言的 interface 关键字,更广泛地指超类型(supertype)。核心思想是:变量的声明类型应当是抽象的接口或父类,而非具体的实现类。这样就能在运行时灵活替换不同的具体实现,而使用者完全不需要知道实际的对象类型。
这是依赖倒转原则(DIP)的另一种表述——针对抽象层编程,而非针对具体类编程。
据此,我们为飞行行为和叫声行为各定义一个接口,每种具体行为封装为一个实现类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // 飞行行为接口 public interface FlyBehavior { void fly(); } // 用翅膀飞 public class FlyWithWings implements FlyBehavior { public void fly() { System.out.println("I'm flying!!"); } } // 不会飞(橡皮鸭、诱饵鸭等) public class FlyNoWay implements FlyBehavior { public void fly() { System.out.println("I can't fly"); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | // 叫声行为接口 public interface QuackBehavior { void quack(); } // 正常嘎嘎叫 public class Quack implements QuackBehavior { public void quack() { System.out.println("Quack"); } } // 吱吱叫(橡皮鸭) public class Squeak implements QuackBehavior { public void quack() { System.out.println("Squeak"); } } // 不出声(诱饵鸭) public class MuteQuack implements QuackBehavior { public void quack() { System.out.println("<< Silence >>"); } } |
注意:实现飞行和叫声接口的并不是 Duck 的子类,而是一组专门代表行为的独立类。它们的存在理由就是封装特定行为——这不是什么奇怪的设计。
一个只有行为的类,合理吗?
在 OO 系统中,类通常既有状态(实例变量)又有行为(方法)。但行为类完全可以拥有状态——例如飞行行为可以有每分钟翅膀拍打次数(wing beats per minute)、最大高度(max altitude)、最大速度(speed)等属性。不必拘泥于「类必须代表现实实体」的思维定式。
组合与委托
Duck 类不再自己实现飞行和叫声逻辑,而是持有行为接口类型的引用,将行为委托(delegate)给行为对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | public abstract class Duck { FlyBehavior flyBehavior; // 声明为接口类型 QuackBehavior quackBehavior; public Duck() {} public abstract void display(); public void performFly() { flyBehavior.fly(); // 委托给行为对象 } public void performQuack() { quackBehavior.quack(); // 委托给行为对象 } public void swim() { System.out.println("All ducks float, even decoys!"); } // setter 方法——支持运行时动态切换行为 public void setFlyBehavior(FlyBehavior fb) { flyBehavior = fb; } public void setQuackBehavior(QuackBehavior qb) { quackBehavior = qb; } } |
两个关键的设计决策:
- 成员变量类型是接口(
FlyBehavior、QuackBehavior),而非具体类——面向接口编程 - 提供
setter方法允许运行时修改行为——这是组合相比继承的核心优势
鸭子不再通过 IS-A(继承)获得飞行和叫声能力,而是通过 HAS-A(组合)拥有飞行行为和叫声行为。
Favor composition over inheritance. HAS-A can be better than IS-A.
组合的优势在于:
- 灵活性更高——可以在运行时动态切换行为
- 将算法族封装到独立的类层次中,便于复用和扩展
- 其他类型的对象也可以复用这些行为,不再被锁定在
Duck继承体系中
这正是合成复用原则(CRP)的核心主张。
完整实现
具体鸭子
子类在构造器中设置具体的行为对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public class MallardDuck extends Duck { public MallardDuck() { quackBehavior = new Quack(); // 绿头鸭正常嘎嘎叫 flyBehavior = new FlyWithWings(); // 用翅膀飞 } public void display() { System.out.println("I'm a real Mallard duck"); } } public class ModelDuck extends Duck { public ModelDuck() { quackBehavior = new Quack(); flyBehavior = new FlyNoWay(); // 模型鸭一开始不会飞 } public void display() { System.out.println("I'm a model duck"); } } |
MallardDuck 的构造器设置了 Quack 和 FlyWithWings——当 performQuack() 被调用时,行为被委托给 Quack 对象,于是我们听到真正的嘎嘎叫声。
运行时动态切换
添加一种新的飞行行为——火箭动力飞行:
1 2 3 4 5 | public class FlyRocketPowered implements FlyBehavior { public void fly() { System.out.println("I'm flying with a rocket!"); } } |
然后在运行时给模型鸭装上火箭:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public class MiniDuckSimulator { public static void main(String[] args) { // 绿头鸭 Duck mallard = new MallardDuck(); mallard.performQuack(); // 输出: Quack mallard.performFly(); // 输出: I'm flying!! // 模型鸭——动态切换飞行行为 Duck model = new ModelDuck(); model.performFly(); // 输出: I can't fly model.setFlyBehavior(new FlyRocketPowered()); // 运行时切换! model.performFly(); // 输出: I'm flying with a rocket! } } |
模型鸭在运行时从「不会飞」变成了「火箭动力飞行」。如果行为逻辑写死在类内部——无论是通过继承还是直接实现——这种动态切换都无法实现。
完整类图
classDiagram
class Duck {
<<abstract>>
FlyBehavior flyBehavior
QuackBehavior quackBehavior
+swim()
+display()*
+performQuack()
+performFly()
+setFlyBehavior(FlyBehavior)
+setQuackBehavior(QuackBehavior)
}
class FlyBehavior {
<<interface>>
+fly()*
}
class FlyWithWings {
+fly()
}
class FlyNoWay {
+fly()
}
class FlyRocketPowered {
+fly()
}
class QuackBehavior {
<<interface>>
+quack()*
}
class Quack {
+quack()
}
class Squeak {
+quack()
}
class MuteQuack {
+quack()
}
class MallardDuck {
+display()
}
class RedheadDuck {
+display()
}
class RubberDuck {
+display()
}
class DecoyDuck {
+display()
}
Duck --> FlyBehavior
Duck --> QuackBehavior
FlyBehavior <|.. FlyWithWings
FlyBehavior <|.. FlyNoWay
FlyBehavior <|.. FlyRocketPowered
QuackBehavior <|.. Quack
QuackBehavior <|.. Squeak
QuackBehavior <|.. MuteQuack
Duck <|-- MallardDuck
Duck <|-- RedheadDuck
Duck <|-- RubberDuck
Duck <|-- DecoyDuck
客户端(MiniDuckSimulator)使用的是 Duck 抽象类型。飞行行为和叫声行为各自被封装为一族可互换的算法——每组行为都可以独立于 Duck 及其子类而变化。
这就是策略模式。
策略模式
策略模式
策略模式(Strategy Pattern)定义了一族算法,分别封装起来,使它们之间可以互相替换。策略模式使得算法的变化独立于使用算法的客户。
The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
别名:Policy
结构
classDiagram
direction LR
class Context {
-strategy: Strategy
+ContextInterface()
}
class Strategy {
<<interface>>
+AlgorithmInterface()*
}
class ConcreteStrategyA {
+AlgorithmInterface()
}
class ConcreteStrategyB {
+AlgorithmInterface()
}
class ConcreteStrategyC {
+AlgorithmInterface()
}
Context o-- Strategy : strategy
Strategy <|.. ConcreteStrategyA
Strategy <|.. ConcreteStrategyB
Strategy <|.. ConcreteStrategyC
三个角色的职责:
- Context(上下文):持有一个 Strategy 的引用,通过该引用调用算法。Context 不关心具体使用了哪种策略
- Strategy(策略接口):声明所有支持的算法的公共接口
- ConcreteStrategy(具体策略):实现 Strategy 接口,封装具体的算法逻辑
在 SimUDuck 中的对应关系:
| 策略模式角色 | SimUDuck 对应 |
|---|---|
| Context | Duck |
| Strategy | FlyBehavior / QuackBehavior |
| ConcreteStrategy | FlyWithWings / FlyNoWay / Quack / Squeak 等 |
动机
考虑一个文本排版系统,需要将文本流分割成行。存在多种换行算法(简单贪心、Knuth-Plass 最优、基于连字符等),如果将所有算法硬编码在一个类中:
1 2 3 4 5 6 7 8 9 10 11 | public class Context { // ... public void algorithm(String type) { // ... if (type == "strategyA") { ... } else if (type == "strategyB") { ... } else if (type == "strategyC") { ... } // 每增加一种算法,就要修改这个方法 } // ... } |
这段代码违反了开闭原则——每次新增算法都必须修改 Context 类。而且所有算法的代码纠缠在一起,不便于理解、测试和复用。策略模式将每种算法封装为独立的 Strategy 类,通过组合和多态实现算法的灵活切换。
适用场景
在以下情况下适合使用策略模式:
- 多个相关类仅行为不同——策略模式提供了一种用多种行为中的一种来配置类的方法
- 算法存在多种变体,且不同变体体现了不同的时间/空间权衡——可以将这些变体组织为算法的类层次结构
- 算法使用了客户不应知道的数据——策略模式可以避免暴露复杂的、算法特定的数据结构
- 一个类定义了多种行为,以多重条件语句的形式出现——将各个条件分支移入独立的 Strategy 类,以替代大量的
if-else判断
效果
策略模式的优势:
- 算法族的复用:Strategy 类层次定义了一族可复用的算法或行为。继承可以进一步提取各算法的公共部分,避免重复代码
- 替代子类化:直接在 Context 子类中覆写方法也能实现不同的算法,但会将算法逻辑纠缠到 Context 中,使其难以理解和维护。策略模式将算法独立出来,便于切换、理解和扩展
- 消除条件语句:将不同行为封装到独立的 Strategy 类中,消除了 Context 中冗长的条件判断
- 实现的多样选择:策略可以为同一行为提供不同的实现,客户端可以根据不同的时间/空间权衡来选择合适的策略
策略模式的代价:
- 客户必须了解不同策略:客户需要知道各策略的差异才能做出合适的选择,这可能暴露部分实现细节
- Strategy 与 Context 之间的通信开销:Context 会向 Strategy 传递通用参数,某些简单策略可能并不需要全部参数
- 对象数量增多:每种策略一个类,增加了系统中的对象数量
策略模式的代价在简单场景中几乎可以忽略——SimUDuck 中鸭子的行为种类有限,通信开销极低,而获得的灵活性收益是巨大的。这些代价在大规模系统中才需要认真权衡。
设计模式的价值
共享词汇
设计模式不只是技术工具,更是开发者之间的共享词汇(shared vocabulary)。课件用餐厅点单来类比:
- Alice(顾客):「我要白面包上涂奶油芝士加果冻,巧克力汽水加香草冰淇淋,培根烤芝士三明治,吐司金枪鱼沙拉,香蕉船加冰淇淋和切片香蕉,加一杯加奶加两块糖的咖啡,哦,再烤个汉堡!」
- Flo(老手服务员):「给我一个 C.J. White, a black & white, a Jack Benny, a radio, a house boat, a coffee regular and burn one!」
两人点的是完全一样的单。Alice 逐一描述每道菜的细节,Flo 用行业术语一句话搞定——更快、更精确、不易出错。
设计模式在团队沟通中扮演类似的角色:
- 以少传多:说「这里用策略模式」,团队成员立刻理解整套结构——接口抽象、组合委托、运行时替换——无需逐一解释每个类的关系
- 提升思维层次:在设计讨论中保持在模式层面(pattern level)思考,避免过早陷入对象层面(object level)的细节
- 加速团队协作:共享词汇帮助初级开发者更快融入团队,理解系统架构决策
设计模式与库/框架
Q: 既然设计模式这么好,为什么不能做成库直接调用?
设计模式比库更高层次。模式告诉你如何组织类和对象来解决特定问题,而具体实现需要根据应用场景来适配。它们不能被打包为可复用的代码库,因为模式描述的是结构和关系,而非具体的实现。
Q: 库和框架不就是设计模式吗?
不是。库和框架提供具体的实现代码,但其内部可能使用了设计模式。理解设计模式有助于更快掌握基于模式构建的 API——当你认出一个框架使用了策略模式,你就能立刻理解它的扩展方式。
设计模式的本质
仅仅了解抽象、继承、多态这些 OO 基础概念,并不能让你成为优秀的面向对象设计者。优秀的设计者思考的是如何创建灵活的、可维护的、能应对变化的设计。好的面向对象设计应当是可复用的(reusable)、可扩展的(extensible)和可维护的(maintainable)——而设计模式正是帮助你构建具备这些品质的系统的经过验证的 OO 设计经验。模式还提供了一种共享语言,能最大化开发者之间沟通的价值。
关于设计模式,有几个要点值得记住:
- 模式不给你代码,而是给你通用的解决方案,需要你根据具体应用来适配
- 模式不是发明出来的,而是从反复出现的实践中发现的
- 大多数模式和设计原则都在应对软件中的变化
- 大多数模式让系统的某部分可以独立于其他部分变化
- 我们经常做的事情就是把系统中变化的部分提取出来加以封装
小结
本节从 SimUDuck 实例出发,展示了纯继承和纯接口方案在应对行为变化时的局限性,引出了三个核心设计原则:
| 设计原则 | 含义 | 对应的 OOD 原则 |
|---|---|---|
| 封装变化 | 将变化的部分抽取并独立封装 | 开闭原则(OCP)/ 可变性封装原则(EVP) |
| 面向接口编程 | 变量声明为超类型,不依赖具体实现 | 依赖倒转原则(DIP) |
| 组合优于继承 | 用 HAS-A(组合)代替 IS-A(继承)获得灵活性 | 合成复用原则(CRP) |
这三个原则的协同应用催生了策略模式——通过将算法族封装为独立的接口与实现类、面向接口编程、使用组合委托,实现了算法与客户的解耦以及运行时的灵活切换。
设计工具箱至此:
| 层次 | 内容 |
|---|---|
| OO 基础 | 抽象、封装、多态、继承 |
| OO 原则 | 封装变化、面向接口编程、组合优于继承 |
| OO 模式 | 策略模式 |