从“白箱复用与黑箱复用”谈起...

类别:编程语言 点击:0 评论:0 推荐:

从“白箱复用与黑箱复用.”谈到“概要设计”与“详细设计”的划分及其它

(题目好长 J 斗胆与鲁迅的《魏晋风度及文章与药及酒之关系》比试)

 

“封装、继承、多态”是面向对象编程的三大特性。

“美丽、智慧、大方”是(我认为的)女人应具有的三大优点。

然而我可以经常夸奖一个女人“最美丽,最智慧,最大方”;但我从来不敢自吹自己写的程序“最封装,最继承,最多态”。

因为“封装、继承、多态”之间属于相形相克。相形者,指三者中缺少任意一个,则余下二个都将不存在;相克者,则是三者中任意一个如果被发挥或表现到极限,则余者同样无法生存。

正由于此,可以说,在抽象意义上,任何时候我们进行的程序设计1,都是力图在针对当前的问题,调整出这三个特性的各自的最佳“实现度”。

这也是所有程序员在不断培养,苦苦追求的设计能力。

什么叫高手、老手?什么叫新手、生手?虽然我懂得“盐是咸的,味精是甜的,姜是辛”,但我始终做不出一手好汤。虽然我也明白“油门、刹车、方向盘”的作用,但当舒马赫在F1赛道上艺术地操作这三者时,我还在某个坡路上流汗:“又要刹车又要加油门……为难人啊!”

是的,编程的难点与技艺正在于此。好的程序设计得让人几乎要归之为“艺术”;而糟糕的设计,就像一个蹩脚的厨子走了,留下一桌恶心的菜,你却不得不去咀嚼它,消化它,其间之苦,真非言语所能表达的。

很不幸,我就是这样一个蹩脚的厨子,说起来“饭菜”也做了10年矣,但依然无法用“封装、继承、多态”做“面向对象”这一道菜。

我这学习编程的10载,倒几近完整地见证了中国软件开发行业发展的全过程。我也看到了很多程序员都和我一样,在不断的摸索、碰壁中缓慢地成长。

好在,编程界的泰斗终于感到于心不忍了,开始提供编程界的“菜谱”。这就是如今火热之至的“设计模式”2。

当然,正如万有引力一直存在着,而不是等到牛顿被苹果砸了以后才出现。“设计模式”其实一直存在于优秀程序员的设计里。不过是没有形诸于文字,而是表现在代码中。当然,也远远没有泰斗们所归结出来的模式那样具备抽象性、概括性和通用性。

说到设计模式,我只是想进一步证明,一个程序员面对不同问题,其驾驭面向对象三大特性的能力的重要性。这篇小文不准备讲那23个经典模式。我想以最基本的“白箱、黑箱复用”为例,开始我的论题。

首先,不要看到“黑白箱”就想到测试。“继承、聚合”分别二者的原意,在不同的编程语言里可能有不同的术语,这里我们用最直观的“白箱”,“黑箱”来表述。

所引用的代码,来自于早些日子我在“非程序员”(一个国内专讲UML的网站)的BBS的发言。那时有一位可能比我还菜的家伙在上面质疑复用为何要分“白箱”“黑箱”,我一时技痒,上去口水了一番。下面我会通过一个有关“项目”的故事,将我当时用来论证的代码,串成一次代码设计的演进。

先得说说“继承”。C++ 提供了三种三继承“private, protected, public”。考虑到JAVA和C# 均只能支持最通用的 public 继承,我们这里就仅以此为继承的标准。

所谓的public 继承,字面意思是“公开继承”。要个比方就是除了“老子”声明要带到阴间的财产,其它的,它继承者,都可以获得,使用。(这是一个蹩脚的比喻,但我想你会承认它确实表达了公开继承和其它继承的不同L )。

“公开继承”就是一种最常见的白箱复用的设计。它表示:“B复用A的功能,并且B可以了解A的内部细节”。

接下来我们讲“黑箱复用”。可以推测,它表示:“B复用A的功能,但B无法看到A的内部细节”。这在像C#或JAVA这样不支持私有或保护继承,也不支持多重继承的语言,是一种极其常见的设计。

“黑箱复用”的实现方法是:如果B类想复用A类的功能,不是从A类派生,而是将A类的对象,声明成为B类的成员数据。

嗯,是该来举一个“实际项目”了。通过演示这个“项目”的实作,我想,就算你是外行人,你也应能了解一点:事实上程序员最后用指头敲写代码,其实那不算是编程,真的编程,在于之前他的大脑必须做的分析与设计(这句话一会儿我会继续重复)。

(声明一下,以下故事纯属虚构)

10年的编程生涯,嗯,我的家里有5台电脑了。书房和卧室各有一间,但因为搬家所以常常换,老婆也有一个(不允许再多,也不允许换)。前三年又有了一个女儿,由于出生“脑香门第”,所以最近小家伙也开始用上我的电脑。这些算是项目的背景和资源。

项目的初始需求是这样:结婚后每天晚上我都在书房时和电脑打交道到很晚。于是我老婆认为应该把那台笔记本搬到卧室,并责令我写个程序,可以实现她在卧室通过电脑向我发号施令。这样就有了本项目的产生。

作为“客户”,老婆当然希望她可以发各种各样的命令;而作为该项目的产品经理、技术架构师,开发负责人,代码撰写者及测试师于一身的我,当然明白正确地引导客户的需求是一个项目是否成功的最重要前提之一,同时也是对客户负责的表现。

我向她解释了一个无所不包的软件,首先将让用户界面变得繁杂无比,用户极易操作失误,而失去耐心;其次是众多功能之间将互相牵制,导致表面上得到一个无所不包的软件,实际功能却强项不强,弱项更弱等等……最后我也委婉地提到了它对开发周期的可能的影响,以及在开发和后期维护费用上恐怕会出现几何级的增长……

最后约定是只实现最为常见两条命令的发送:

a)“老公限N分种内来睡觉,否则门将反锁。”;

b)“脚已洗好,请来端盆。”

有了具体的需求描述,这下显得清楚多了。当然,老婆也不吃素的。在具体功能之外,也提出一些速度,性能的要求(这样就可以杜绝我在限定时间内无反应,会推托是软件传送命令太慢等后路),最重要一点也提到该系统应具备一定的扩展性,以备今后增加新的命令的要求等等……

需求之后是概要设计,首先我确定通过局域网,采用SOCKET来实现传输,而不是通过串口并口红外线或蓝牙。无论是硬件还是软件,这方面的资源均充备,这算是对开发资源做了认真详实的调研并确定。然后我把数据流图画到了概要设计。

在概要设计内,我也决定了将有采用.Net + C# 来进行开发,当然,也提到了采用Win32+ C或C++或JAVA 的可能性。

最后我也在概要设计里提出,由于该系统的简小,在速度,性能,及扩展性并无太多要求,所以应将设计的天平侧向于“易用性”(以博取老婆欢心)。

界面上的东西,及第二条命令数据的流程,均略。

之后开始详细设计,秉承概要设计的思想,我觉得将两条命令的发送分别提供。那么要不要采用“多态”?即是否将发送两条命令的发送动作取同一命名?考虑到以后可能会有新的命令扩展,这里采用多态会带来麻烦。所以我在这一步详细设计里,放弃多态特性之一。

很显然,我对“扩展性”虽然没有完全忽略,甚至是在概要和详细设计里都可见“扩展性”的影响,但问题我缺少对“扩展”与“易用”做深入的,更具体的考虑。所以下一步的错误的根本,已经埋下来。

下面我开始提供设计的伪代码。

假设C# 提供基类Socket,用于在网络发送数据。

我没有标出函数参数 data 的数据类型。但显然,作为该类的设计者,他并不知道你要发送什么样的数据(老婆的命令?老板的命令?)所以这个Send () 可以发送的 data 肯定是无具体含义的。我们可称为无格式的数据。

而我们要发送的两个有着具体意义的命令。根据前面设计。我们需要为这两个类分别提供发送函数。当然,这两个发送具体命令的函数,最终肯定是要调用上述系统提供的Send()命令来完成实际发送操作。

让我们来继承它:

是的,我派生了一个新类:“卧室的Socket”。这一命名表征了我心里其实很清楚,我要设计一个仅供卧室那端的人使用的Socket。而我对两个具体的命令,提供了名字直观的两个函数,这也充分体现了我正在按概要设计的要求进行详细设计:请看,通过我对原来的Socket 类的派生,以及我对它的Send()动作的扩展,就在原来抽象的,无特别应用方向的类的基础上,得到一个新类,它有具体应用方向,也有具体意义的动作。

这比起拿起Socket就直接使用的人(这类人往往是C的高手);或者比起为了“多态”而“多态”,从而把新加的两个函数也命名为Send()的人(这往往是刚接触C++才几天的人),我的这个设计,确实显得很正确。

然而,事实上,这个设计在面向对象编程的世界里,仍然是一个拙劣的设计。在面向对象编程领域里有经验的程序员,我想已经看出其中的欠妥之处。

假设这个项目付诸实施了。当老婆的人倒也没有提出什么扩展。光阴荏苒,结婚三年过去了,我们有了一个孩子;然后又是三年过去了,我们的孩子也开始会在电脑上施展她的天才。对这个软件提出了她看法:“爸爸,应该增加一个给我送牛奶的命令”。

扩展需求终于出现了。然而,6年过去了,我对这个软件的记忆是零。没有看设计文档,我就开始看代码。然后我看到一个类:BedroomSocket。我开始使用它,然后我看到它有三个有关发送的方法:

bool Send(…);

bool SendSackCommand(…);

bool SendFootBathCommand(…);

 

作为一个使用者,我并不想去花时间了解BedroomSocket的具体细节,所以我并不知道其中那个Send() 其实是来自Socket这个基类(在实际大型项目开发中,比如大型ERP,专门写上层业务逻辑的程序员,甚至是没有权限可以看到他所使用的类的设计文档,更看不到源代码)。我错误地认为当初设计BedromSocket时,可能是为了易于对付一些新加的命令,所以提供了一个通用的Send()方法。

就这样,纵然有100个项目经理,也无法在第一时间内阻止我义无反顾地通过BedroomSocket的实例来调用Send(),我会发现这个Send()实在太好用了,什么格式的数据都可以发送。

也就这样,一个项目原来的设计倾向开始出现偏差。如果这种情况在多人之间出现多次,那么一个项目的设计风格与模式,就将被每个人的理解而肢解成五花八门。不仅仅是在人的方面:理解,改错,扩展等方面会增加难度,而且对于代码本身,也必然由于模块之间接合困难,而需要增加很多附加代码,最终是程序运行效率低下。

你可以怪罪后来者(在这个例子里仍然是“我”),不去深入学习需求,概要,设计文档。但正如我前面所言,对一个大的项目,会按设计的层次分成多个子项目; 要每一个人都去学习每一个项目的详细设计文档,并且最好是从需求开始看起,这是不可能的。再考虑那些中间件的实现,通常都凝聚了一个软企的核心技术——这种情况下,分配在实现业务逻辑的程序员,没有权限去学习中间件的具体设计思路。大家看到的,永远只是对方的接口。类似于我看到了Bedroom接口透露出来的三个方法,但我不知道这些方法的实现背景。

针对这个例子中碰上的问题。我们可以将“白箱复用”(这里是继承),改为“黑箱复用”。

在这次设计中,Socket 的对象成为类 BedroomCommand的一个成员。类BedroomCommand不再是通过“继承”来获得网络发送的能力。而是通过“拥有”一个Socket对象来获得该对象所有公开的能力。

由于Socket 的对象sender 在BedroomCommand 里被声明为私有(private,,或者也可以是保护protected),所以,有关Socket网络发送的能力,仅有BedroomCommand 的设计者可以直接获取和使用。这就是“黑箱复用”的一种常见方法。BedroomCommand 的使用者不再需要面对 Send() 。它所能看见和用到的接口,是BedroomCommand 提供三个意义明确的发送方法:

  public bool SendSackCommand (…);

  public bool SendFootBathCommand (…);

  public bool SendMilkCommand(…);

这样,我们就解决前面的问题。我们实现了一个类,它提供了它应有的功能,同时杜绝提供它不该有的功能。这正是一个良好的设计的基本标准。

这么看来,是不是黑箱复用总是白箱复用来得正确?答案当然不是如此,下面我们继续给这个设计制造问题——想要给“设计”制造问题,最好的办法就是修改“需求”了。

我们假设原来的 Socket 类在除了提供一个公开对外的Send()方法以外,还提供了一个保护的SetOptions()方法。该方法用于对网络发送做一些参数调整,以便可以定制出更符合具体要求的网络发送能力。

class Socket

{

       public Send (data);  //发送数据

    protected SetOptions(…);  //定制网络条件

    …

};

Socket 的设计者,认为SetOptions这一能力是不能直接对外公开,所以SetOptions被设计为“保护(protected)”。这就使得:除非是Socket本身或它的继承它的类,否则就无法使用到SetOptions。

我们前面讲的“白箱复用”,正是继承。这就给我们出现一个两难:

如果使用“白箱复用”,那么我们可以获得我们想要的SetOptions,但同时我们却不得不公开了我们不想公开的Send。

如果使用“黑箱复用”,那么们可以不公开Send。但却无法获得SetOptions的能力。

由此产生了“复合复用”。

(一般来说,SetOptions() 在 Socket 里不会被设置成 virtual,所以在C# 里,我们加上 new 指示符,而在C++,最直接的方法是另取一个名字,比如叫 SetMyOptions(),如此可以避免关于编译器说我们覆盖了基类同名函数的小问题。如果SetOptions是virtual类,则不存在该问题。另外,在C++里, base.SetOptions(),应写成: Socket::SetOptions())

问题得以完美解决。Socket提供的超强能力,只有BedroomCommand的设计者能获得,使用。并且通过BedroomCommand的设计者来决定要对外公开哪些能力。任何一个后来的程序员,无论他是老手还是新手,都不会在使用BedroomCommand上出现偏差。就算是我在下一个6年之后,我也能正确地使用BedroomCommand。这样的一个设计,针对当前问题,做到既有“粒度”又有“弹性”。

由此引申出几个话题。

第一,关于需求分析、概要设计、详细设计的划分。

概要设计更多地是在将需求模块转换为设计模块。它从总体上把握了技术设计的可行性。着重表达各个设计模块之间的静态及动态关系,并由此确定各设计模块之间接口规划。

一般地说,概要设计并不需求每个写代码的人都参加直接参加设计。它要求项目技术负责人了解技术实现上可行性,总体难度;它也要求技术负责人具备把握整个设计的风格、倾向、取舍;但它并不要求技术负责懂得每一个模块的具体实现。

本例中,如果想在概要设计中就明确好BedroomCommand是如何实现,这是不现实的。一个概要设计的负责人如果此时就开始关心每个类是如何实现(继承?聚合?),那将使他陷入细节实现的泥潭。从而根本无法在整体把握设计。

在概要设计的形成过程中,项目技术负责人必须基本得出项目开发的分工安排。最后由每个程序员以“概要设计”为纲领,分头研究+相互研讨,逐步得出各自负责模块的详细设计。详细设计也负有对概要设计反证的功能,当发现无法得出概要设计的实现时,应提交讨论,修改概要设计。

当然,不同的项目情况,对需求,概要,详细工作划分,也会有所不同。有些行业用户,比如税务,银行,由于IT实施较早,本身具备有相当的软件开发能力。他们往往可以直接严格的,详细的需求,到软企的手里,不仅仅需求文档有了,连概要设计也出了一半。而更多的用户,比如企业用户,在需求上往往只有一句话:“我要一套超强的ERP,解决我所有问题@_@”。显然只能先行挖掘用户的“需求”了。

第二,关于程序员的设计分工

设计模块不是以“需求/功能模块”来划分。大家的分工,一般也不应直接按功能模块分配。因为这样将迫使每个程序员都成为“七项全能”。这个问题的另一面,就是我个人认为,公司在招聘技术人才上,几乎没有任何“定向测试”,也没能得出任何量化的结果。而设计分工要求我们了解每个程序员特长,从而合理地定位。我个人非常倾向于以“界面(UI)、业务逻辑,中间件(包括定制控件),底层模块”为纵线先进行层面上的设计任务安排。然后如有需要,再在各个层面进行横向划分。

本文地址:http://com.8s8s.com/it/it28223.htm