第20章 咖啡的启示
这个例子对于教学有很多好处。它短小、易于理解并且展示了如何应用面向对象设计原则去管理依赖和分类关注点。但从另一方面来说,它的短小也意味着这种分离带来的好处可能抵不过其成本。就当做一个设计思路来看吧。
20.1 Mark IV型专用咖啡机
20.1.1 规格说明书
Mark IV型专用咖啡机一次可以产出12杯咖啡。使用者把过滤器放置在支架上,在其中装入研磨好的咖啡,然后把支架推入其容器中。接着,使用者向滤水器中倒入12杯水并按下冲煮(Brew)按钮。水一直加热到沸腾。不断产生的水蒸气压力使水洒在咖啡粉末上,形成水滴通过过滤器流入到咖啡壶中。咖啡壶由一个保温盘进行长期保温,仅当壶中有咖啡时,保温盘才进行工作。如果在水还在向咖啡粉喷洒时从保温盘上拿走咖啡壶,水流就会停止,这样煮好的咖啡就不会溅在保温盘上。以下是需要监控的硬件设备。
- 加热器的加热元件。可以开启和关闭。
- 加热盘的加热元件。可以开启和关闭。
- 保温盘传感器。它有3个状态:warmerEmpty、potEmpty和potNotEmpty。
- 加热器传感器,用来判断是否有水。它有两个状态:boilerEmpty、boilerNotEmpty。
- 冲煮按钮。这个瞬时按钮启动冲煮流程。它有一个指示灯,当冲煮流程结束时亮,表示咖啡已经煮好。
- 减压阀门,在开启时可以降低加热器中的压力。压力降低会阻止水流向过滤器。该阀门可以开启和关闭。
Mark IV型专用咖啡机的硬件已经设计完成,硬件工程师为我们提供了低层的API,如下:
namespace CoffeeMaker { public enum WarmerPlateStatus { WARMER_EMPTY, POT_EMPTY, POT_NOT_EMPTY }; public enum BoilerStatus { EMPTY, NOT_EMPTY }; public enum BrewButtonStatus { PUSHED, NOT_PUSHED }; public enum BoilerState { ON, OFF }; public enum WarmerState { ON, OFF }; public enum IndicatorState { ON, OFF }; public enum ReliefValveState { OPEN, CLOSED }; public interface CoffeeMakerAPI { /* * This function returns the status of the warmer-plate * sensor. This sensor detects the presence of the pot * and whether it has coffee in it. */ WarmerPlateStatus GetWarmerPlateStatus(); /* * This function returns the status of the boiler switch. * The boiler switch is a float switch that detects if * there is more than 1/2 cup of water in the boiler. */ BoilerStatus GetBoilerStatus(); /* * This function returns the status of the brew button. * The brew button is a momentary switch that remembers * its state. Each call to this function returns the * remembered state and then resets that state to * NOT_PUSHED. * * Thus, even if this function is polled at a very slow * rate, it will still detect when the brew button is * pushed. */ BrewButtonStatus GetBrewButtonStatus(); /* * This function turns the heating element in the boiler * on or off. */ void SetBoilerState(BoilerState s); /* * This function turns the heating element in the warmer * plate on or off. */ void SetWarmerState(WarmerState s); /* * This function turns the indicator light on or off. * The indicator light should be turned on at the end * of the brewing cycle. It should be turned off when * the user presses the brew button. */ void SetIndicatorState(IndicatorState s); /* * This function opens and closes the pressure-relief * valve. When this valve is closed, steam pressure in * the boiler will force hot water to spray out over * the coffee filter. When the valve is open, the steam * in the boiler escapes into the environment, and the * water in the boiler will not spray out over the filter. */ void SetReliefValveState(ReliefValveState s); } }
我们在为一个简单的嵌入式实时系统设计软件。我期望可以给出一组类图、顺序图和状态图。
20.1.2 常见的丑陋方案
下图展示了最常见的丑陋方案:
对于初学者来说,很难认出这个设计是多么丑陋。在这幅图中隐藏着一些非常严重的错误。其中有许多只有你开始编写针对这个设计的代码时才会注意到,那时你就会发现写出的代码多么荒谬。
先来看看这幅UML图创建方式的一些问题。
缺少方法
当设计者创建出没有方法的视图时,他们也许不是根据行为对软件进行划分的。不基于行为的划分基本上都有严重错误。正是系统的行为为我们提供了第一个关于如何划分系统的线索。
水蒸气类
如果考虑一下Light类应该具有的方法,就会发现这个设计所划分是多么糟糕。显然,Light对象应该能够被打开或者关掉。因此,我们会把On()和Off()方法放进Light类中。这些函数实现出来会是什么样子呢?如下:
public class Light { public void On() { CoffeeMaker.api.SetIndicatorState(IndicatorState.ON); } public void Off() { CoffeeMaker.api.SetIndicatorState(IndicatorState.OFF); } }
Light类有几个奇怪的地方。首先,他没有任何成员变量。有些不寻常,因为对象通常会具有某种要操作的状态。此外,On()和Off()方法只是简单地把工作委托给CoffeeMakerAPI的SetIndicatorState方法。显然,Light类只是一个调用转换器,没有做任何有用的事情。
Button类、Boiler类和WarmerPlate类具有同样的问题。它们都只是把一种调用格式转换成另外一种格式的适配器。事实上,完全可以把它们从设计中去掉而不引起CoffeeMaker类的逻辑发生任何变化。CofferMaker类完全可以直接调用CoffeeMakerAPI而不是使用这些适配器。
通过研究这些类的方法和代码,我们把那些在图中占有重要位置,降格为没有多少存在必要的纯粹的占位符。因此,我们称它们为水蒸气类。
20.1.3 虚构的抽象
系统中所有类都没有使用Sensor和Heater类。它们最多具有一些抽象的方法。比如Heater接口。一个仅仅含有抽象方法并且不具有任何使用者的类,完全是一个无用类。
public interface Heater { void TurnOn(); void TurnOff(); }
起初看了,好像确实有很多具有意义功能的类。但是当我们开始编写实现浙西类的代码时,就会发现其中只有一个CofferMaker类才具有一些有意义的行为,其余所有的类要么是虚构的抽象,要么是水蒸气类。
20.1.4 改进方案
解决这个问题(乃至任何问题)的技巧是:退一步,把问题的本质和细节分离。忘掉加热器、阀门、传感器等所有细小的细节,集中关注与根本的问题。什么是根本问题?就是如何煮咖啡。
如何煮咖啡?最简单、最常见的方法是把热水倒在研磨好的咖啡上,并把冲好的咖啡液体收集在某种器皿中。热水从哪来?从HotWaterSource类。把咖啡存放在什么地方?存放在ContainmentVessel中。
考虑一下Mark IV 的部件,就可以想象得到加热器、阀门及加热传感器在充当HotWaterSource角色。HotWaterSource负责吧水加热并喷洒在研磨好的咖啡上,形成溶液流入ContainmentVessel中。我们还可以想象得到保温盘及其传感器在充当ContainmentVessel的角色。他负责保持存放咖啡的温度,并让我们知道容器中是否有咖啡。
如何使用UML来描述上面讨论?下图展示了一种可能的做法。 HotWaterSource和ContainmentVessel都是类,通过咖啡流关联起来。
这种关联是初学者常犯的一个错误。该关联是基于问题中的一些物理关联而非软件控制行为作出的。咖啡从HotWaterSource流入ContainmentVessel和这两个类之间的关联完全无关。
如果通向器皿的热水流的开始和停止是有ContainmentVessel通知HotWaterSource进行的,会怎样呢?如下展示,注意,ContainmentVessel给HotWaterSource发送了start消息。这意味着关联关系是反方向的。ContainmentVessel依赖于HotWaterSource。
关联是对象之间消息发送的路径。关联和物理实体的流向没有任何关系。
现在还没有提供任何方法使得人可以和我们的系统进行交换。系统必须能够向主人报告自己的工作状态。我们向咖啡机模型中增加了一个UserInterface类。
我们来观察几个用例,看看是否可以找出这些类的行为。
用例1:使用者按下冲煮按钮
当HotWaterSource和ContainmentVessel都准备好,UserInterface对象就应该向HotWaterSource发送start消息,HotWaterSource就开始工作。
用例2:接收器皿没有准备好
当接收器皿没有准备好,ContainmentVessel通知HotWaterSource停止传送热水,当准备好后,再通知HotWaterSource再次开启热水流,热水流的终止和恢复如下:
用例3:冲煮完成
HotWaterSource和ContainmentVessel都可以发送Done消息。
用例4:咖啡喝完了
当冲煮结束并且一个空咖啡壶被放在保温盘上时,Mark IV 就会关掉指示灯。
根据这幅图,我们可以画出一幅具有相同关联关系的类图。如下:
20.1.5 实现抽象模型
我们创建的3个类都不能知道关于Mark IV的任何消息。这就是依赖倒置原则(DIP)。我们不允许系统中高层的咖啡制作策略依赖于低层的实现。
使用者按下冲煮按钮
UserInterface如何知道冲煮按钮被按下了呢?它必须要调用CoffeeMakerAPI.GeeBrewButtonStatus()函数。我们决定UserInterface类是不能知道CoffeeMakerAPI的。根据DIP,这个调用放在UserInterface的派生类中。
代码如下:
public class M4UserInterface : UserInterface { private void CheckButton() { BrewButtonStatus status = CoffeeMaker.api.GetBrewButtonStatus(); if (status == BrewButtonStatus.PUSHED) { StartBrewing(); } } } public class UserInterface { private HotWaterSource hws; private ContainmentVessel cv; public void Done() { } public void Complete() { } protected void StartBrewing() { if (hws.IsReady() && cv.IsReady()) { hws.Start(); cv.Start(); } } }
为什么要创建一个受保护的StartBrewing()方法呢?维护不再M4UserInterface中直接调用Start()函数呢?原因很简单,但是很重要。IsReady()测试以及随后对HotWaterSource和ContainmentVessel的start()方法的调用都是高层的策略,都应归属于UserInterface类。
实现IsReady()方法
public class M4HotWaterSource : HotWaterSource { public override bool IsReady() { BoilerStatus status = CoffeeMaker.api.GetBoilerStatus(); return status == BoilerStatus.NOT_EMPTY; } } public class M4ContainmentVessel : ContainmentVessel { public override bool IsReady() { WarmerPlateStatus status = CoffeeMaker.api.GetWarmerPlateStatus(); return status == WarmerPlateStatus.POT_EMPTY; } }
实现Start()方法
HotWaterSource的Start()方法只是一个抽象方法,M4HotWaterSource会实现该方法调用CoffeeMakerAPI中关闭阀门以及开启加热器的函数。在编写这些函数过程中,我开始不停地写一些类似CoffeeMaker.api.XXX这样的结构感到厌烦,因此我就同时也做了一些重构。
public class M4HotWaterSource : HotWaterSource { private CoffeeMakerAPI api; public M4HotWaterSource(CoffeeMakerAPI api) { this.api = api; } public override bool IsReady() { BoilerStatus status = api.GetBoilerStatus(); return status == BoilerStatus.NOT_EMPTY; } public override void Start() { api.SetReliefValveState(ReliefValveState.CLOSED); api.SetBoilerState(BoilerState.ON); } } public class M4ContainmentVessel : ContainmentVessel { private CoffeeMakerAPI api; private bool isBrewing = false; public M4ContainmentVessel(CoffeeMakerAPI api) { this.api = api; } public override bool IsReady() { WarmerPlateStatus status = api.GetWarmerPlateStatus(); return status == WarmerPlateStatus.POT_EMPTY; } public override void Start() { isBrewing = true; } }
调用M4UserInterface.CheckButton
系统的控制流是如何运转调用CoffeeMakerAPI.GetBrewButtonStatus()函数的呢?选择线程还是轮询?这个决策可以在最后一刻作出。这对设计没有任何影响。最好总是假设消息都是可以异步发送的,就好像存在有独立的线程一样。
假设我们用轮询的方式:
public interface Pollable { void Poll(); }
public static void Main(string[] args) { CoffeeMakerAPI api = new M4CoffeeMakerAPI(); M4UserInterface ui = new M4UserInterface(api); M4HotWaterSource hws = new M4HotWaterSource(api); M4ContainmentVessel cv = new M4ContainmentVessel(api); ui.Init(hws, cv); hws.Init(ui, cv); cv.Init(hws, ui); while (true) { ui.Poll(); hws.Poll(); cv.Poll(); } }
public class M4UserInterface : UserInterface,Pollable { private CoffeeMakerAPI api; public M4UserInterface(CoffeeMakerAPI api) { this.api = api; } public void Poll() { BrewButtonStatus status = api.GetBrewButtonStatus(); if (status == BrewButtonStatus.PUSHED) { StartBrewing(); } } }
完成咖啡机练习
20.1.6 这个设计的好处
线条把3个抽象类圈了起来。圈中的类没有依赖于任何圈外的类。因此,抽象完全和细节隔离开了。
20.2 面向对象过度设计
这个例子对于教学有很多好处。它短小、易于理解并且展示了如何应用面向对象设计原则去管理依赖和分类关注点。但从另一方面来说,它的短小也意味着这种分离带来的好处可能抵不过其成本。
如果把Mark IV 咖啡机实现为一个有限状态机,我们会发现它有7个状态和18个迁移。我们可以用18行的SMC(the State Machine Compiler,状态机编译器)代码来表示状态机。轮询传感器的简单主循环也就十几行代码,有限状态机要调用动作函数也在几十行代码左右。简而言之,我们可以在一页代码之内实现整个程序。
如果不算上测试代码,咖啡机的面向对象实现有5页代码。我们无法对这种悬殊做出合理的解释。在大型应用中,依赖管理和关注点分离带来的好处会明显超出面向对象设计的成本。但是在这个例子中,我们可能得出相反的结论。
摘自:《敏捷软件开发:原则、模式与实践(C#版)》Robert C.Martin Micah Martin 著
转载请注明出处: