重读设计模式:从理论到实践的反思(一)
重读设计模式:从理论到实践的反思(一)
最近读了经典的《Head First 设计模式》(Head First Design Patterns),前三章重点介绍了策略模式、观察者模式和装饰者模式。
之前也学习过这些模式,但受限于当时的开发经验,对它们的理解往往停留在“死记硬背”的层面——看过就忘,用时却想不起来。随着做过一些新的项目,如今再回看这些内容,发现原来在过往的许多业务场景中,已经使用过,或者很适合这种设计思想的介入。
本系列博客将结合我个人的实际开发经历,重新审视并总结这三个基础设计模式,探讨它们如何在工程中解决问题。
策略模式 (Strategy Pattern)
从 SimUDuck 看继承的陷阱
书中以一个非常经典的“鸭子模拟游戏”(SimUDuck)作为切入点。游戏中有各种鸭子,它们会戏水(swim)、会呱呱叫(quack)。最初的设计采用了标准的面向对象(OO)继承思维:创建一个 Duck 超类,实现通用的 quack()、swim() 和 display() 方法,具体的鸭子子类只需重写 display() 即可。
然而,当需求变更,需要加入“会飞的鸭子”时,问题出现了。如果在超类中直接添加 fly() 方法,会导致所有的子类(包括橡皮鸭、诱饵鸭)都具备飞行能力,这显然违背了业务逻辑。
这个案例揭示了一个核心的设计原则:
找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。
简而言之,就是封装变化。
重新理解面向对象的抽象
我们在这一步往往会陷入误区。在面向对象(OOP)的入门课上,我们习惯于模拟现实世界的分类,比如“学生是人”(Student IS-A Person)。但在复杂的软件工程中,我们要封装和抽象的不仅仅是“名词”(对象),更重要的是“动词”(行为的变化)。
一切设计模式和原则,本质上都是为了应对软件的变化。 如果需求永远静止,软件设计原则就失去了存在的意义,代码只需要写一次即可。但在真实世界中,唯一不变的就是变化。
解决方案:组合优于继承
回到鸭子的问题,既然“飞行”和“叫声”是随鸭子类型变化的行为,我们就应该将其从 Duck 类中剥离出来。
我们不再让 Duck 类亲自实现具体的行为,而是定义一组行为接口(如 FlyBehavior 和 QuackBehavior)。具体的行为(如“会飞”、“不会飞”、“火箭助推飞行”)则由一组专门的类(行为类)去实现这些接口。
在 Duck 类中,我们声明行为接口类型的成员变量。这样一来,鸭子类就不再依赖具体的动作实现,而是依赖于接口。这引出了策略模式的正式定义:
策略模式定义了算法族,分别封装起来,让它们之间可以互相替换,此模式让算法的变化独立于使用算法的客户。
通过这种方式,我们实现了运行时多态:我们甚至可以在程序运行时,通过 setter 方法动态改变一只鸭子的飞行行为,而无需修改代码重新编译。
实践案例:蒙特卡罗模拟中的边界条件
在我过去的开发经历中,有一个非常典型的策略模式应用场景——蒙特卡罗(Monte Carlo)模拟程序的界面边界处理。在这个物理模拟程序中,我们需要追踪粒子在三维空间中的运动轨迹。模拟区域通常是一个长方体,包含 6 个面。对于每一个面,物理上可能存在完全不同的边界条件,例如:
- 漫反射边界:粒子撞击后随机反弹。
- 周期性边界:粒子飞出后从对面飞回。
- 热化边界:粒子撞击后与热库交换能量。
更关键的是,这 6 个面的边界类型是在程序运行时由用户配置动态决定的。
如果在核心的粒子跟踪算法中写大量的 if-else 或 switch-case 来判断当前撞到了哪个面、该面是什么类型,代码将变得极其臃肿且难以维护。且一旦需要新增一种物理边界,就必须修改核心算法代码。
利用策略模式,我进行了如下设计: 1. 定义接口:创建一个 BoundaryCondition 接口,包含处理粒子碰撞的方法(例如 handleCollision(Particle p))。 2. 具体策略:为漫反射、周期性、热化等逻辑分别创建实现类,实现上述接口。 3. 上下文集成:在模拟区域对象中,维护一个包含 6 个 BoundaryCondition 对象的数组(对应 6 个面)。
当声子撞击某个面时,核心算法只需要调用对应面的 handleCollision() 方法,完全不需要关心具体的物理逻辑。通过这种方式,核心算法(不变的部分)与边界物理逻辑(变化的部分)实现了完美的隔离与解耦。
小思考:Service 层接口是策略模式吗?
在工业领域开发(尤其是 Web 后端或企业级应用)中,我们习惯于采用“基于接口编程”的数据驱动架构:定义一个 Service 接口,然后编写一个 ServiceImpl 类来实现它。这算不算是策略模式?
这需要从设计意图来区分:
架构解耦(结构型视角):
如果定义接口仅仅是为了遵循分层架构规范(如 DDD 或 MVC),为了方便依赖注入(DI),或者为了在单元测试时方便 Mock 掉具体实现,那么这更多是一种架构上的解耦实践。此时系统中往往只有一个主要实现类在运行,并不存在“算法互换”的需求。
真正的策略模式(行为型视角):
如果你的 Service 层设计是为了应对业务逻辑的多变性。例如,一个
PaymentService接口,在双十一期间使用HighPerformancePaymentStrategy,在平时使用StandardPaymentStrategy;或者根据配置文件动态加载不同的业务规则算法。在这种场景下,你明确地把 Service 的行为视作一种“可替换的算法”,这就是纯正的策略模式。
虽然形式上都是“接口+实现”,但策略模式的灵魂在于“多策略的动态替换”与“行为的封装”,而不仅仅是代码结构的松耦合。不过,良好的分层架构天然为引入策略模式留出了接口,这正是优秀架构设计的延展性所在。
观察者模式 (Observer Pattern)
1. 气象站与信息守恒
观察者模式可能是工业界应用最广泛的模式之一。书中举了一个气象站(WeatherData)的例子:一旦气象测量数据(温度、湿度、气压)发生变化,这就需要实时更新三个不同的显示界面(当前状况、统计数据、天气预报)。
直观上,我们该如何实现?最简单的做法是,让 WeatherData 类持有这三个显示对象的引用,然后在数据更新的方法 measurementsChanged() 中,依次调用这三个对象的更新方法。
这引发了一个有趣的思考:这种硬编码有问题吗?
从物理学的角度看,这没有问题。世界遵循能量或信息守恒定律。这三个界面要同步更新,必然需要在某个时刻、由某段代码去触发它们。无论使用什么设计模式,这个“通知”的动作(CPU 周期)是无法被省略的。
正如庖丁解牛,牛还是那头牛,但切入的角度不同,结果便大相径庭。硬编码的问题在于耦合。如果未来显示元素变多了,或者我们希望在程序运行时动态添加/移除显示面板,硬编码的方式就需要频繁修改 WeatherData 的源代码,这违背了“对扩展开放,对修改关闭”的原则。
2. 定义与结构
为了应对“观察者数量和类型”的变化,我们将“订阅-通知”的逻辑抽象出来,这很像柏拉图的“理型”。
我们将持有状态的一方抽象为 Subject(主题) 接口,它维护一个观察者列表,只管注册、移除和通知;将被通知的一方抽象为 Observer(观察者) 接口,它通常只有一个核心方法 update()。
Subject 并不关心观察者具体是显示器、是日志记录器还是警报器,它只关心观察者是否实现了 Observer 接口。只要实现了接口,就可以被动态替换。这种松耦合让双方都依赖于抽象,而非具体实现。
架构图如下所示:
classDiagram
class Subject {
<>
+registerObserver(Observer o)
+removeObserver(Observer o)
+notifyObservers()
}
class Observer {
<>
+update()
}
class ConcreteSubject {
-observerList
-state
+getState()
+setState()
}
class ConcreteObserver {
-subject
+update()
}
Subject <|.. ConcreteSubject
Observer <|.. ConcreteObserver
Subject --> Observer : notifies
ConcreteObserver --> ConcreteSubject : observes
3. C# 中的演进:从模式到语言特性
在 Java 等传统 OO 语言中,我们通常手动实现上述接口。但在 C# 中,观察者模式已经内化为语言层级的一等公民——委托(Delegate)与事件(Event)。
在 Python 中,函数是一等公民,你可以随意传递函数。但在 C# 的早期设计中,方法从属于类。为了能像传递对象一样传递方法,C# 引入了 委托(Delegate)。委托本质上是对方法的抽象封装,它定义了方法的“签名”,使得方法可以被当成参数传递,从而实现动态调用。
3.1 委托基础
1 | namespace ObserverDemo |
3.2 语法糖:Action 与 Func
为了避免每次都定义 delegate 类型,.NET 提供了内置的泛型委托,极大地简化了代码:
Action<T>:用于没有返回值的方法(void)。Func<T, Result>:用于有返回值的方法。
上面的代码可以用 Action 简化,不仅省去了定义委托类型的步骤,更是直接把“具体方法”变成了“观察者”。
3.3 事件(Event):安全的观察者模式
有了委托,为什么还需要 event 关键字?
委托本身是一个类,如果将其公开(public),外部调用者不仅可以订阅(+=),还可以直接赋值(=,这会清空其他订阅者!),甚至可以直接在外部调用它(Invoke())。这不仅不安全,也破坏了封装性——只有主题(Subject)自己才有权决定何时发送通知。
event 关键字就是委托的保护层。它限制了外部只能使用 += 和 -=,确保了观察者列表的安全。
1 | public class Button |
4. 现代 C# 实践:Lambda 与 Timer
在现代 .NET 开发中,我们经常结合 Lambda 表达式来通过事件处理异步逻辑。例如,使用 System.Timers.Timer 时,我们不需要专门写一个具名方法来处理时间到达的逻辑,直接用 Lambda 即可:
1 | using System; |
在这里,Lambda 表达式 (sender, e) => { ... } 就是那个被临时创建的“观察者”。
5. WPF 与数据驱动的核心:INotifyPropertyChanged
在桌面开发框架 WPF(以及现在的 MAUI/Avalonia)中,观察者模式是 MVVM 架构的灵魂。
UI 界面(View)需要观察数据源(ViewModel)的变化。当后台数据的属性改变时,UI 需要自动刷新。微软为此标准化了一个接口:INotifyPropertyChanged。
这完全就是观察者模式的教科书式应用: * ViewModel 是 Subject(主题)。 * View (Binding System) 是 Observer(观察者)。 * PropertyChanged 是那个核心的 event。
1 | using System.ComponentModel; |
在 XAML 中写下 <TextBlock Text="{Binding Name}" /> 时,WPF 框架其实就在背后默默地订阅了 PropertyChanged 事件。一旦我们在代码里执行 user.Name = "New Name",事件触发,UI 随之更新。
这就是观察者模式的威力:它将数据变更的逻辑与 UI 渲染的逻辑彻底解耦,支撑起了现代响应式 UI 框架的半壁江山。
装饰者模式 (Decorator Pattern)
1. 继承的噩梦:类爆炸
让我们回到物理模拟的场景。假设我们有一个基础的表面类(Surface),最初它只负责处理基本的粒子入射逻辑。随着物理需求的增加,我们需要为表面添加各种特性,比如: * 统计功能:记录被粒子撞击的次数。 * 分布统计:记录碰撞位置的空间分布。 * 能量损耗:模拟非弹性碰撞。
在早期的设计中,如果使用继承来解决问题,我们可能会陷入“类爆炸”的泥潭:
1 | Surface (基类) |
这种做法有两个致命缺陷: 1. 排列组合导致类数量指数级增长:每增加一种新功能(如“能量损耗”),就需要为所有现有的形状子类再派生出一组新的子类。 2. 编译期僵化:功能的组合在写代码时(编译期)就必须确定。我们无法在程序运行时,根据用户的配置动态地决定是否要开启“统计功能”。
2. 解决方案:运行时包装
我们希望将“核心职责”(表面的形状与基础反射)与“附加职责”(统计、日志、微扰)分离开来。我们希望核心结构稳定,而附加功能可以像衣服一样,一层层地穿在对象身上。
这就是装饰者模式的核心:动态地将责任附加到对象上。若要扩展功能,装饰者提供了比继承更有弹性的替代方案。
代码的使用方式将从“创建特定子类”变为“像俄罗斯套娃一样组装对象”:
1 | // 运行时动态组合:一个三角形 + 碰撞统计 + 碰撞分布 |
3. 模式结构与实现
装饰者模式利用了组合(Composition)和委托(Delegation)。装饰者类和被装饰类实现相同的接口,这样装饰者就能“冒充”被装饰者;同时,装饰者内部持有一个被装饰者的引用,将核心逻辑委托给它执行。
classDiagram
class IBoundaryInteraction {
<>
+Scatter(Phonon p, HitInfo h)
}
class ConcreteBoundary {
+Scatter(Phonon p, HitInfo h)
}
class BoundaryDecorator {
<>
-IBoundaryInteraction _inner
+BoundaryDecorator(IBoundaryInteraction inner)
+Scatter(Phonon p, HitInfo h)
}
class StatsDecorator {
+Scatter(Phonon p, HitInfo h)
}
class RoughnessDecorator {
+Scatter(Phonon p, HitInfo h)
}
IBoundaryInteraction <|.. ConcreteBoundary
IBoundaryInteraction <|.. BoundaryDecorator
BoundaryDecorator o-- IBoundaryInteraction : wraps
BoundaryDecorator <|-- StatsDecorator
BoundaryDecorator <|-- RoughnessDecorator
4. 继承 vs 装饰:思维方式的转变
为什么说装饰者比继承更灵活?
- 继承是替代(Replacement):当你
override父类方法时,你往往是在“替换”或“修改”原有逻辑。而且这种关系是静态的,C extends B extends A的结构在编译后无法改变。 - 装饰是增强(Enhancement):装饰者的标准写法通常是“前置/后置处理 + 调用原有逻辑”。你永远不会丢掉原有的行为,而是在其之上添加新行为。
1 | // 装饰者的典型逻辑:保留原味,增加佐料 |
最重要的是,装饰者支持动态插拔。在物理模拟中,我们经常需要“先跑一遍不带统计的快速模拟,再跑一遍带详细统计的慢速模拟”。使用装饰者,这只需要在初始化时改变几行代码,甚至可以在运行时由配置文件驱动;而使用继承,你可能需要重写整个实例化逻辑。
5. 物理引擎中的实战演练
回到之前的声子蒙特卡罗程序,装饰者模式非常适合处理那些“在原有物理逻辑之上的附加效应”。
场景 A:叠加表面粗糙度(Surface Roughness)
假设基础模型是完美的镜面反射。现在我们需要模拟真实的粗糙表面,这通常意味着在镜面反射角的基础上增加一个随机的微小偏转。
1 | // 基础接口 |
场景 B:无侵入式统计(Tallying)
如果我们想统计某个面的能量流,完全不需要修改物理核心代码,套一层壳即可:
1 | class TallyDecorator : BoundaryDecorator { |
场景 C:无限套娃(Chaining)
装饰者的最大威力在于组合。我们可以构建一个既有粗糙度,又能被部分吸收,还能记录日志的复杂边界:
1 | IBoundaryInteraction boundary = new SpecularReflection(); // 核心:镜面 |
6. 架构反思:策略模式 vs 装饰者模式
在设计物理引擎时,我们很容易混淆这两个模式。它们看起来都是“用接口把具体实现替换掉”。那么,什么时候用策略,什么时候用装饰者?
- 策略模式 (Strategy):用于改变“内核”(Core Behavior)。
- 比如:这个边界是“镜面反射”还是“漫反射”?是“绝热”还是“恒温”?这些是互斥的物理模型,决定了对象的本质。
- 装饰者模式 (Decorator):用于改变“外壳”(Skin/Enhancement)。
- 比如:是否有“粗糙度”?是否需要“统计”?是否开启“日志”?这些是附加的特性,可以叠加。
推荐的架构设计
在一个成熟的模拟软件中,我们可以结合两者:
- 工厂层 (Factory):读取配置文件(XML/JSON)。
- 核心层 (Strategy):根据配置创建最核心的物理模型(
Specular或Diffuse)。 - 增强层 (Decorator):根据配置中的开关,动态地将核心模型包裹在各种装饰器中。
代码蓝图:
1 | IBoundaryInteraction CreateBoundary(Config config) |
这样设计,既保证了物理内核的纯粹性(策略模式),又赋予了系统极大的可配置性和扩展性(装饰者模式)。这正是“对扩展开放,对修改关闭”原则的完美体现。
总结:设计模式的本质是应对变化
重读《Head First 设计模式》并结合工程实践,最大的感触不再是那些具体的类图结构,而是隐藏在这些模式背后的核心哲学——如何优雅地面对软件系统中的“变”与“不变”。
在这篇博客中,我们探讨了三个最基础也最重要的模式,它们恰好解决了三种不同维度的“变化”问题:
- 策略模式 (Strategy):
- 解决的是“算法实现”的变化。
- 当我们需要在不同的物理模型(如镜面反射 vs 漫反射)之间切换时,策略模式让我们把“做什么”和“怎么做”分离开来,利用组合替代继承,实现了内核逻辑的可替换性。
- 观察者模式 (Observer):
- 解决的是“状态同步”的变化。
- 当一个对象的状态改变需要波及到不确定数量的其他对象(如数据源 vs UI界面)时,观察者模式提供了一种松耦合的通信机制。在 C# 等现代语言中,它甚至演化为了语言层级的特性(Event/Delegate),成为了架构中通过“消息”解耦的基石。
- 装饰者模式 (Decorator):
- 解决的是“对象职责”的变化。
- 当我们不想修改现有代码,却想在运行时为对象动态叠加功能(如统计、日志、微扰)时,装饰者模式通过“套娃”式的组合,避免了继承带来的类爆炸,让核心逻辑保持纯粹,让附加功能随需而变。
所谓“设计”,就是对未来的预测
写出计算机能跑的代码很容易,但写出人类能维护、且能适应未来需求变更的代码很难。
我们在物理模拟程序的架构设计中看到,如果完全通过硬编码(Hard-coding)或深层继承(Deep Inheritance)来开发,初期的速度可能很快,但一旦需求变更(例如“我想在运行时给所有漫反射边界加一个能量统计”),整个代码大厦可能会瞬间崩塌。
一切设计模式的出发点,都是为了识别出系统中那些“会变化的部分”,并将其封装起来,从而保护那些“不会变化的部分”。
- 策略模式封装了怎么做(行为);
- 观察者模式封装了通知谁(消息);
- 装饰者模式封装了还能做什么(扩展)。
虽然设计模式极具威力,但在实际开发中,我们也要警惕“过度设计”。如果一个模块在其生命周期内几乎不可能发生变化,或者逻辑极其简单,那么强行套用模式只会增加代码的复杂度,降低可读性。
模式是用来解决痛点的。 当在开发中感到“痛”了——比如发现自己在反复修改同一个类来适配不同情况,或者发现新增一个功能需要改动五六个文件时——这通常就是引入设计模式的最佳时机。