用反射解决水果篮问题 [Design, C#]
Solve The FruitBasket Problem With Reflection [Design, C#]
Written by Allen Lee
When will my reflection show who I am inside?—— Christina Aguielra,《Reflection》
1. 问题的引入
《OOD 启思录》[1]一书提到了一个有趣的“水果篮问题”[2],我把这个问题概要描述如下:
考虑这样一个水果篮,里面可以装有任意多的苹果(Apple)、桔子(Orange)和香蕉(Banana),而这些都派生自一个叫做水果(Fruit)的基类,并重写水果的 Print 和 Cost 两个方法以实现多态性。
有一天,你希望这些派生类更具个性,即各自具有一个别的派生类没有的特殊行为,例如苹果可以去核、桔子可以切片、香蕉可以剥皮。
并且,你希望迭代整个水果篮,让这些派生类执行它们特有的“个性行为”,即让苹果去核、让桔子切片、让香蕉剥皮,但你知道你已经不能再从多态性上获得更多的便利了,怎么办?
为了再次能从多态性上获得便利,你决定在水果类中加入一个叫 Prepare 的纯虚多态函数,并且让这些派生类把它们的“个性行为”实现到这个函数中。这样,当你使用多态性调用 Prepare 时,如果实际的类型是苹果,那么就去核;如果是桔子就切片;如果是香蕉就剥皮。
不过这个方法有个很大的弊端,就是假如你只希望迭代水果篮,让里面的部分派生类执行它们的“个性行为”,例如只需要水果篮里面的苹果去核,别的原封不动,那么这个方法就帮倒忙了!
对于这个问题,Arthur 在《OOD 启思录》中提到了两个流行的解决方案:“肥接口”方案和“记账”方案。
“肥接口”方案
该方案是作者认为目前最流行,也是很多设计者认为最好的方案。它让水果为苹果定义一个名叫 Core 的空虚方法,并让苹果重写该方法的实现,而其他的派生类就直接继承水果的空方法,然后用同样的方法来处理其他的派生类。这样,当你迭代整个水果篮,并且发送“去核”的消息时,苹果就会知道如何做,而其它派生类由于都直接继承水果类的 Core 这个空方法,就会呆在那里什么也不做。随后 Arthur 举了一个该方法不适用的情景,假如某个人增加了一个樱桃类,该类也需要去核,但用户只想迭代水果篮让苹果去核,那么这种方案也将帮倒忙。再者,如果派生类很多,水果类就要为这些派生类定义很多空方法,这样一来不好看,而来也会造成日后维护的负担。
“记账”方案
由于 Arthur 并没有提到该方案的名字,为了便于描述,我就为它起了这样一个名字。该方案提出水果篮除了维护一个水果列表,还应该为各个派生类维护一个单独的列表(当然,列表里面所存放的是指向实际对象的指针),这样就可以在无需改变水果层次结构的前提下根据用户的需要执行派生类的“个性行为”。然而,当派生类比较多的时候,簿记工作可能是一场噩梦,而且运行时的类型处理上也存在许多隐藏的问题。再者,如果某人增加了一个派生类,例如西瓜,那么你也将有可能需要为此向水果篮添加额外的代码。
Arthur 并没有在《OOD 启思录》中给出一个令人满意的解决方案,他认为这个问题“没有最好的解决方案,所有的方案都有需要解决的问题”。
2. 客户的要求
某日,我被告知要到会议室开一个紧急会议。当我来到会议室时,发现与会者中有一个魔鬼——一个很麻烦的客户,马上就有一种不祥的预感。客户要求开发一个水果篮的类,当我接过客户的需求描述后,我愣住了,我知道即将要面临“水果篮问题”!这个客户是公司的大客户,把工作推掉是不可能的;从过往的经验中,我知道该客户非常善变,如果我提供的产品没有足够的灵活性,那么将陷入一个可怕的维护噩梦。
会上,客户在白板上写下了一下这段 C# 代码:
并得意地说:“我很明白你们开发人员的难处,看,我为你们设想好水果篮将如何被使用了,算是减轻一下你们的负担吧!”
我发现我的顶头上司正望着我,从他的眼神可以看出他在期望我的答复能使客户满意,于是,我只好无奈地笑着说:“没问题,我不会让你失望的。”
3. 问题的分析
由于我不能够碰触水果层次结构,于是只好选择 Arthur 所提到“记账”方案来试着解决“水果篮问题”了。这里我将会充分使用反射机制来改善该方案,使水果篮与水果的派生类完全解耦。
设想水果篮里面维护着这样一个 Dictionary 数据结构:
其中,Dictionary 的 Key 是派生类的运行时类型(Type),而 Value 是是一个集合类(ArrayList),里面存放着对应的 Key 所描述的类型对象实例,即:
这样,我们就知道 Add 方法的代码是如何写的了:
当客户向水果篮添加某个派生类的实例时,例如香蕉,Add 将首先获取该实例的运行时类型,并与 m_Fruits 的各个 Key 做对比,看看是否已经存在香蕉这种类型。如果存在,就把该实例直接添加到与之对应的列表里;否则,为该类型新建一个列表,把该实例添加进去,再把该类型和此列表作为一个 Key-Value 对加入 m_Fruits 里。
当客户要执行某一派生类的“个性行为”时,我们将会从客户中获取两个重要的参数:派生类的类型和派生类的方法名。于是,我们可以使用反射来满足客户的要求:
当客户希望迭代整个水果篮,并让桔子切片时,Invoke 首先会检查 m_Fruits 里面是否存在桔子的列表,如果是将会迭代该列表并使用反射机制让桔子切片。值得注意的是,你必须先检查水果篮里面是否存有客户指定的那种水果。
这样,即使客户今晚决定去掉水果层次结构的苹果,并代之以榴莲,我也可以安心的睡觉,不怕被客户的电话吵醒了。
4. 进一步思考
对于“水果篮问题”,我们可以看到多态性对于执行派生类的“个性行为”几乎没有起到作用。如果要勉强使用多态性,那么你很可能会陷入“肥接口”的陷阱,导致水果层次结构的扭曲;如果你不适当地使用簿记工作,也将会陷入繁重的簿记泥潭,导致水果篮过多的与水果层次结构耦合在一起。
而我在这里所提供的方案,主要是以 .NET 丰富的运行时类型信息和强大的反射机制作为中介,使水果篮和水果的派生类解耦。这样不但可以减轻和改善了原本繁杂的簿记工作,而且还使得水果篮具有更好的适应性。
还有什么要考虑的吗?答案是:很多!是的,你没有眼花,上面只是提供了解决问题的基本思路,而我们离问题的解决还有一段很长的距离。那么,还有什么需要考虑呢?
- 你可能需要提供更多的 Invoke 的重载版本以方便那个魔鬼,如果你不希望某晚熟睡时突然接到他的电话,说他想以字符串的方式指定派生类的类型,因为 GUI 也是直接提供一个字符串!
- 在这里,为了简单起见,派生类的“个性行为”都不需要任何参数,也不返回任何结果。但你知道这等好事在现实世界中通常不会发生的,于是,Invoke 必须能够正确处理这些问题。
- 性能,一个我们不可以忽略的因素。程序能正常运行通常不会使客户满意,它还必须及时响应客户端的请求。你别指望客户会很有耐性地打开你的程序,然后去冲杯咖啡喝,最后回来看看运行的结果。
这里,我还有另外一个解决方案:
这个方案看起来更简单,尤其是水果篮里的数据结构以及 Add 方法,可以说不能再简单了,而 Invoke 也只不过是在迭代时对每个对象进行类型检查。
我并没有对这两种方案进行严格的性能测试,因为我只想在本文讨论设计的问题;但估计后者的性能更胜一筹,因为从定性的角度来看,后者占用的内存更少。当然,这仅仅是我的猜测,至于具体的性能测试工作,我会留给那些有兴趣的人做。
- [1] [美] Arthur J. Riel 著;鲍志云 译;《OOD 启思录》;人民邮电出版社,2004
- [2] 关于该问题的详细内容,请参见《OOD 启思录》一书 P108 的《5.19 没有最优解的问题》