我们若不更加深邃,定将更加复杂。
                                                             ——史蒂芬 霍金

摘要


设计难题

我们将借用一个非常经典的例子:鸭子模拟程序[1]。如下图所示,我们的程序需要表现Mallard Duck(绿头野鸭)、Redhead Duck(红头野鸭)和Rubber Duck(橡皮鸭子)。由于Mallard Duck和Redhead Duck都有一个相同的函数“Fly()”,所以我们把这个函数提升到了父类Duck中。


由于Rubber Duck是Duck的子类,所以它自动获得了Fly()这个函数,但是橡皮鸭子是不能飞的!这是一个十分常见的设计问题。我们经常会把一些相关的类组织在一起,将它们共有的行为提升到父类中。这样做的好处不仅仅是少写了几行代码,更为重要的是,所有的子类拥有了相同的行为模式——对于新加入的开发者,他只要弄懂了父类,就对整个类层次了解了一大半;对于在程序的生命周期内需要不断增加子类的情况,这种设计就更加显得方便,因为我们不需要在每增加一个子类的时候都把所有的逻辑重新想一遍。
但是,讽刺的是,如果子类很多,就难免出现像Rubber Duck这样的另类;对于需要不断增加子类的情况,就更加的糟糕,我们在每一次将子类的行为提升到父类时都会恐惧将来会不会出现一个另类无法适用已经固定在父类中的逻辑……
下面将分别讨论可以解决此问题的4种不同的方法。

第1种设计:使用虚函数

最简单的方法就是把父类中的Fly()函数设为虚函数,并提供一个默认的实现。子类可以根据需要决定使用父类的默认实现或者重写一个新的实现,如下图所示。


嗯,这不是一个让人舒服的设计。那个Rubber Duck看起来就像一个转校生,它孤单寂寞,老师也不喜欢它。有时,你甚至会听到Duck、Mallard Duck和Redhead Duck在悄悄商量着:放学后我们去打电动游戏吧,嘘,别让Rubber Duck听到了……
如果需要不断的增加子类,情况就更加的糟糕。因为另类越来越多,我们只能尽力确保最初的那个小团体里的代码重用和逻辑一致。对于新来的,只能放任自流。
还有一个问题就是:对于那个不会飞的Rubber Duck,是否应该提供一个Fly()函数呢?有人赞成这样做,因为这样可以使用一个一致的接口来操作所有的Duck,例如可以这样写:
IList<Duck> ducks = new List<Duck>();
ducks.Add(
new MallardDuck());
ducks.Add(
new RedheadDuck());
ducks.Add(
new Rubber Duck());
foreach(Duck duck in ducks)
{
    duck.Fly();
}
也有人讨厌这样做。理由是,OO最吸引人的地方就是它是与人的思考方式最接近的编程方法。一个好的设计可以非常简洁自然,以至于不需要额外的文档加以说明。而Rubber Duck提供的Fly()函数无疑是一个例外情况,这样的例外越多,人们就越容易迷惑。就好像一个人的简历中明明写着“精通.net”,却既不懂C#,也不会VB,这么做只是为了和别人的简历一致,岂不是很奇怪?
那么,用窄接口会怎么样?
源代码下载:VirtualMethod.rar (VS2005控制台工程)
第2种设计:使用窄接口

如下图所示,在这个设计里提炼出了一个概念:Flyable。


请注意将一个概念显式地表现出来有多么的重要:现在我们都知道要实现哪些函数才算得上是“会飞”,并且知道Mallard Duck和Redhead Duck会飞,而Rubber Duck不会飞。
但是这个设计也有一个大缺点:Mallard Duck和Redhead Duck的Fly()是重复的代码,可是我们无法把它提升到Flyable接口中去,因为.net的接口里只允许定义抽象函数。与第1种设计相比,父类对子类控制的力度不够,增加子类也比较费事。
源代码下载:Interface.rar(VS2005控制台工程)
第3种设计:应用Strategy模式

这次,我们提炼出来的概念不再是“会不会飞(Flyable)”,而是“飞行的方式(FlyBehavior)”。


Duck的子类不再需要重复实现Fly()函数了,它们现在只需要指定一个适合自己的飞行方式就可以了。特别地,我们不再恐惧增加新的Duck的子类了——如果新的Duck的子类需要新的飞行方式,我们只要再增加一个FlyBehavior接口的实现类就可以了。这简直就是一个完美的设计!当然,看上去有些复杂,如果硬要挑出些毛病的话。
源代码下载: Strategy.rar(VS2005控制台工程)
第4种设计:使用Mixin

Mixin曾是Ruby等动态语言的独门秘技,没想到.net这个静态语言竟然也能学到八分像,还给取了个名叫Extension Methods。不过它绝不仅仅是“利用语法糖为无法修改源代码的类增加些函数”这么简单,它可以给我们更多的选择。还记得第2种设计(使用窄接口)的缺点么?没错,Mallard Duck和Redhead Duck的Fly()是重复的代码。Extension Methods正好可以用来解决这个问题。

注:我使用了一种比较形象的方法表示FlyModule和Flyable之间的关系,这并不是官方认可的UML表示法。
源代码下载: Mixin1.rar(VS2008控制台工程)
Strategy VS Mixin

现在来考虑为所有的Duck增加一个Quack()的功能。Mallard Duck和Redhead Duck可以嘎嘎叫(quack),而Rubber Duck只能吱吱叫(squeak)。Strategy和Mixin将如何应对这一需求变化呢?

1. Strategy



我们可以效仿FlyBehavior再添加一个QuackBehavior。虽然看上去需要作许多工作(添加了1个接口和2个类),但是可以注意到需要修改的代码非常少,大部分工作都是在新增代码(符合open-close原则);而且新增的代码只要按照原有的代码照猫画虎即可,基本不用怎么动脑子——这些正是优秀设计的特点。
源代码下载: Strategy2.rar(VS2005控制台工程)
2. Mixin



如上图所示,增加了Quackable和Squeakable两个接口,相当地简单直接。
源代码下载: Mixin2.rar(VS2008控制台工程)
Strategy和Mixin,你喜欢哪一个呢?

Strategy会使概念更集中一些——只要看一下Duck抽象类中都有哪些函数,对整个类层次就了解得差不多了;而且,FlyBehavior都有哪些实现方式也一目了然。统一的宽接口使得Client代码简单而一致。特别地,Strategy允许运行期动态更换FlyBehavior和QuackBehavior的实现,这是.net里的Mixin做不到的。

如果你是窄接口的拥护者,那么很可能会选Mixin了。不过看一下上面那个图,会有一种散和乱的感觉。Mixin更适合实现比较独立的概念,或是XXUtility这种东西。Mixin给人的感觉是简单、直接、轻巧。

Mixin 和 Template Method

Mixin非常适合用来实现“无论你是谁,只要提供了CompareTo()函数,就可以立即免费获得LessThan()、GreaterThan()、EqualTo()、LessEqual()和GreaterEqual() 这5个非常有用的函数”这样的语义。


Client代码:
IList<Duck> ducks = new List<Duck>();

MallardDuck duck1 
= new MallardDuck(1);
MallardDuck duck2 
= new MallardDuck(2);
MallardDuck duck3 
= new MallardDuck(2);

Console.WriteLine(
"duck1 <  duck2 ?   {0}", duck1.LessThan(duck2));
Console.WriteLine(
"duck1 >  duck2 ?   {0}", duck1.GreaterThan(duck2));
Console.WriteLine(
"duck1 <  duck3 ?   {0}", duck1.LessThan(duck3));
Console.WriteLine(
"duck2 <= duck3 ?   {0}", duck2.LessEqual(duck3));

源代码下载: Mixin3.rar(VS2008控制台工程)
以前,要想实现同样的语义只能用Abstract Class。


单以这个例子来看,使用Mixin有许多优点。首先,我们提炼出了一个明确、独立、通用的概念IComparable。其次,IComarable+CompareModule重用性更好。在这个例子中,由于IComarable+CompareModule里面的内容并不是领域相关的,所以就更应该将其提炼出来,这样Mallard Duck就可以集中精力处理领域相关的问题,程序的结构更加清晰,可读性更好。

不过上面那个例子并不能算得上是真正的Template Method模式。真正的Template Method模式是将所有子类共有的完成某项任务的算法提炼到父类中固定下来,子类只负责实现算法中的一个部分,不需要操心整个算法的所有步骤以及进行这些步骤的先后顺序和条件。就像说把大象装冰箱统共分三步,其实把猪装冰箱也统共分三步,把任何东西装冰箱都是分三步,这样我们就发现了一个可以提炼出来的算法“装冰箱”。

我们提炼出概念或算法不仅仅是为了“代码”重用——少些几行代码,仅仅是节省了一点儿体力而已。更为重要的是,我们想要节省脑力——对于已有代码,可以更快、更深刻地理解;如果需要新增代码,不需要把所有的逻辑再想一遍。

沏茶和泡咖啡的方法很相似,都要先烧水,然后把茶或速溶咖啡放入杯子,再倒满水。而且都需要考虑不少的细节,比如沏茶要用80度的水,泡咖啡用95度的最好;沏茶的话可以加两朵菊花,泡咖啡可以多加些糖;茶是可以直接加水续杯的,而咖啡不能续杯……如果有一天我想喝黑芝麻糊,难道必须再把所有的细节再想一遍?在现实生活中没有办法,但是在编程世界里就可以使用Template Method模式来逃避这烦人的工作。


活在编程世界是不是比现实世界要轻松些?

不过本文并不是想讲述Template Method的诸多好处,而是想问一个问题:用Mixin的方式代替Abstract Class实现Template Method模式是否更好?一个明显的好处是可以不受.net单继承的限制。

.net 只允许单继承,一个类只能继承一个Abstract Class,有时这的确挺不爽的。比如要实现一个“黑芝麻糊及刨冰一体机”,以前只能使用组合的方法。


而如果使用Mixin,就能使用多继承了。


等一等,多继承不是复杂与混乱之源么?是的,在C++中使用多继承确实存在许多陷阱和禁忌[3]。造成多继承复杂性的主要原因是属性和函数的模棱两可(ambiguity)问题——如果子类所继承的多个父类中有同名的函数或属性,当子类重载这些函数或Client代码想要调用这些函数时,编译器会不知道应当重载或调用哪个父类的函数。经过多年的实践和讨论,人们意识到必须对多继承做一些限制以减少造成混乱的可能性。.net中的Interface+显式实现接口正是这样一个简单而优雅的折中方案。现在,.net又稍稍放宽了限制,允许使用Interface+Extension Methods的方式多继承非虚函数,使得.net中的多继承功能更强大了一些。

Template VS Freedom

你会害怕使用继承么?你是否曾对Template Method模式充满恐惧?我曾经觉得把子类的核心算法提升到父类中固定下来是一个疯狂的想法。继承会造成子类对父类强烈的依赖,特别是使用Template Method模式的时候,子类将受到父类强大的束缚。我不喜欢束缚,我喜欢自由。特别是我还铭记着“组合优于继承”的设计原则。于是我分离出了一个又一个XxxUtility和Xxxxxxer,于是我的Abstract Class成了空壳。后来,我突然发现每次增加子类都要把所有的实现步骤和细节全部重新想上一遍同样十分恐怖,特别是有50多个子类等着你去添加的时候。继承使得子类对父类产生很强的依赖,这可以看作是缺点,但是也可以说,父类对子类有很强的“控制力”(特别是在使用Template Method模式的时候),使得子类简单而又整齐划一,就像同一个模子里铸出来的锡兵一样。幸运的是,继承和组合并不是一个非此即彼的抉择,我们可以在纪律和自由之间折中。把子类的核心算法提升到父类中形成Template Method模式,把其它的重复代码提炼出来形成Strategy或Mixin再使用之。不用害怕,没有人可以在一开始设计的时候就做出完美的提炼,除非他以前作过类似的程序或者是个不折不扣的先知。



参考文献

[1] Freeman et al, Head First Design Patterns. O’Reilly, 2004.
     影印版:深入浅出设计模式(英文影印版)。东南大学出版社,2005。

[2] Thomas et al, 孙勇等 译, Programming Ruby 中文版。电子工业出版社,2007.

[3] Meyers,侯捷 译, Effective C++ 中文版。华中科技大学出版社,2001. ——条款43:明智地运用多继承。


posted on 2008-02-26 08:16  1-2-3  阅读(7180)  评论(52编辑  收藏  举报