拍脑壳所想之
——戏言面向对象
说到protected这个词,我不可避免的就会想到一个概念——面向对象。那么什么是面向对象呢?其实我个人认为面向对象这个概念是一直在发展变化的,到了今天,面向对象这个词也许让它叫做面向抽象更加贴切。在刚刚建立面向对象这个概念的时候,大概连创造者对于到底什么是面向对象都不是很清楚。要搞清楚面向对象(编程,或者设计)是什么,也许得看看过去的软件代码都是什么样的。
I.公元前
软件开发在最初的十几二十年里面,基本上就是面向过程的。面向过程的核心内容有两项,一个是控制流,另外一个就是数据流。在这一个时期里面,软件界最大的发展估计是数据结构与算法这两个“科目”了,这两者分别对应着两个“流”。在面向过程的软件代码里面,执行主体是过程或者函数。一个过程所代表的就是一个动作,动作的对象(这里还不是面向对象的对象)是一些数据,数据也许通过参数得到,也许通过全局变量得到,还有一些常量或者预定义值。如果我们仔细想一下,就会发现这是一个“动宾”结构的体系,比如说Basic里面比较著名的“Line (x1, y1) -(x2, y2)”,翻译成自然语言就是“画一条(x1,y1)到(x2,y2)的直线”。类似的例子还有很多,比如C语言里面的“printf("%s/r/n", "Hello world!");”。
可是主语在哪里?
II.创世纪
面向过程的代码里面并没有突出一个主语,很多时候这个主语也并非不存在,就像上面的例子里面,主语就是一个屏幕。可是如果我们需要往打印机里面画一条直线呢?(或者打印一个"Hello world"。)在面向过程的代码里面,我们就不得不自己写一个PrintLine的函数。(C语言往文件里面些东西就是fprintf。)如果我们要往远程设备上画一条直线,那还要写一个RemoteLine,如果……不需要我多说,您也会觉得麻烦。围绕着这样一个问题,人们就开始思考:是否能够把主语明确的给写出来?是否能够让我们少做一点重复性的工作?后来就有了面向对象这个东西,在面向对象是一个“主谓宾”结构的世界,绝大多数东西都有一个主语,比如我们所熟悉的“g.DrawLine(pen, pt1, pt2);”,由于我们有了“主语”,我们就可以让不同的东西,用相似的方法做相似的事情。如果光是把g换成h,仅仅解决了“在这个窗口画”与“在那个窗口画”的问题,如果我们希望他能够在其他类型的空间上画,我们还需要容许主语的类型可以不完全相同。但我们要解决的更多问题还是概念相同之处,例如打印机的g和屏幕的g都能够画线,因此有了诸如继承、封装等概念。这就是面向对象的一切了吗?
III.改革开放
随着面向对象概念的诞生,春风沐浴大地。正如上帝说要有光,于是有了光。上帝说要有毒蛇,于是有了毒蛇,上帝说要有苹果,于是有了苹果,结果亚当和夏娃吃了这个上帝创造出来的苹果受到了上帝的“惩罚”。真不明白,既然上帝不希望亚当和夏娃吃这个苹果,为什么还要创造这么一个东西?其实上帝创造这个苹果当然是不希望他们“吃”这个苹果,创造这个苹果实际上是为了产生浪漫的爱情以及其后千秋万代的动人故事。如果你把这个苹果仅仅看成是吃的,那么接下来你看到的就是痛苦的惩罚。如果你看到的是背后动人的故事,那么浪漫甜蜜等美好之辞就会充满你的大脑。
面向对象也一样,他的核心意义并不在于你把东西封装成什么样了,不在于有什么东西被继承出来了,最重要的是他容许我们用抽象的方式来构建一个软件。比如当我们写代码写到:
stream.Write(buff, 4, buff.Length - 4);
或者
hashbuff = hasher.ComputeHash(buff);
我们是否需要关心stream到底是什么,hasher用的又是什么算法呢?如果我们由始至终,在做相应的东西的操作都用相同的stream对象和hasher对象,任务是否都应当能够正确完成呢?应该是能够正确完成的,因为这正是我们的期待。如果让我们来设计某一个stream,是否应该从这个角度去考虑如何设计这一个类呢?如果我们定义这个stream变量,是否应该更抽象一点呢?考虑这么一个函数:
void DoSomething(FileStream stream, MD5CryptoServiceProvider hasher, byte[] buff) {...}
如果写成如下形式将会更加灵活,也更加符合面向对象(面向抽象)的真实含义:
void DoSomething(Stream stream, HashAlgorithm hasher, byte[] buff) {...}
换句话说,所有的封装、继承、接口等等,实际上是为了提供抽象能力而存在的。如果我们把protected当作保护“某些方法的存在”这个秘密的话,那就大错特错了。保护这些秘密严格说来应该是密码学的职责,而不是面向对象的职责。
IV.回顾历史
面向对象的核心是面向抽象,但我们看到,实际发展的过程并非如此。我们在过去有着太多错误的概念了,比如说这个面向对象技术的面向对象,就太容易让我们认为,这项技术的核心就是面向对象。于是很多时候我们写一个“面向对象”的程序充斥的过度的对象,泛滥的继承,以及不知道为什么的封装。并且不少开发者,包括我在内,都曾经认为所谓的面向对象就是把一些要素抽象成对象,进行封装,然后从某个基类派生出万物。好比有一个基类叫做物体,派生出活物与死物,活物派生出细菌病毒植物动物,动物里面有猴鸡狗猪和人,人里面有张三李四王二麻子(还有个娃)。
没错,面向对象当然得包括这些,但是这不是全部,更不是根本。根本就是在于我们写某些东西的时候,不需要关心具体的对象是什么,只需要知道至少它应该是一个什么。比如上一节当中的例子,DoSomething只需要知道stream是一个流,而hasher是一个哈希算法提供者就够了。至于具体提供的是什么样的流和哈希算法,则不应当是我们关心的,而是使用我们这段代码的用户所关心的。如此一来,我们就可以在设计这一段我们所关心的功能的时候,不需要考虑过多的、过于具体的、不断变化的问题。
仔细想想,我们是否真的已经明白了面向对象的核心所在呢?
V.封装保护的是什么
面向对象的封装并非保护你的秘密,而是防止被错误使用,是为了明确划分问题的界限。就“保护”这个词而言,更进一步的讲,它并非对使用该对象的用户(下面称为用户)做出使用某个成员的授权,而是对延展该类的设计人员(下面称为设计人员)做出延展问题领域的授权。现在让我们回过头来看一下 wayfarer 所写的例子:
这个例子确实是非常容易迷惑人的,曾经,我也被这样的问题所困扰。在解决这个困扰之前我们首先要弄清楚下面两个问题:
protected是什么?new又是什么?
protected 很好回答,他表明该成员容许在派生类当中被使用,但不允许使用本类对象的用户代码直接使用。实际上是对设计人员的有限度授权,和对用户的拒绝授权。
而new也并非难以回答,比如说:他是为了在没有override的情况下造成一种被重写了的假象。如果您真的这么认为,那就掉入了幻觉的漩涡当中去了。事实上new的作用并非一个trick,让你可以造成各种各样的假象,或者企图绕过某些使用与设计的授权。new的作用仅仅是为了解决一个命名冲突的问题,也就是说new所指定的成员实际上与基类的同名成员毫无干系,只是非常抱歉的跟他重名了只好声明此Print非彼Print。如果您真的企图用new来制造trick假象的话,终究是要撞掉你的门牙的。在我举出“撞掉你的门牙”的例子之前,请容许我首先给出一个正确使用new关键字的场景。
不知道各位有没有真正的研究过.NET Framework里面interface呢?如果研究过,对于下面的这个问题应当不是非常难以回答。
上面这个代码会在Foo的Bubble函数上面产生一个警告,但是仍然能够编译通过。为什么能够编译通过呢?这个问题留给读者自己琢磨了。解决这个警告的办法有两个:一个是显式实现接口IFoo;可是如果我不希望通过显式的方式来实现该接口,那么就只能够在Foo的Bubble函数前面添加一个new修饰符,告诉编译器我知道他们有冲突,但是我还是希望选择用这种方式来完成它们。这两个Bubble相同的名称给我们一种它们之间有什么联系的错觉,事实上 new bool Bubble() 与 bool new_Bubble() 的含义接近,和Boo里面的void Bubble可以看作毫无关系。如果你觉得有关系的话,那么下面的我将举出一个例子让你碰一鼻子灰。
你猜你会说Boo sheet呢,还是Foo sheet?为什么会这样也请自个儿思考一下。
wayfarer所举的那个例子,看起来确实容易造成困惑,或者会让大家觉得这里有一个暴露protected函数的bug,但事实上并非如此。还记不记得前面我说过了,protected是一个授权问题,而非保密问题,这一个说法应该能部分解决您的困惑。而上面的Boo sheet例子表明,实际上你并没有暴露那个protected函数,因为你仍然无法直接从一个指向Derived实例的Base变量上面寻求到使用Print函数的方法。如果您还记得我前面说过的面向抽象这个概念,也应该意识到,您写的Derived类只是对Base类的补充,而用户一般应该用Base变量来使用您的对象,而不是Derived变量,除非他认为他需要使用Derived提供而Base不提供的功能。如果说我不使用new关键字,而是把Derived的Print函数命名为PrintBase,那是否算是会引起“暴露”基类成员的Bug呢?
显然不是。此时如果用Base变量仍然无法访问Print,用Derived变量则仍然可以访问到PrintBase(并最终调用Print)。还记得派生是为了延展问题领域的边界吗?这里将Base的一个受保护函数暴露出来,就是延展了问题领域的边界。设计Base的人认为,Print的功能是对象的内部事务,而Derived的设计人员则认为Print功能应该是外部与内部之间的事务,此时是否仍叫Print已不重要了。(P.S.: 容许派生类使用,却不允许被暴露,这是根本不可能的事情,即使new关键字不存在也一样。而真正能够称之为“暴露”的是反射邦定/反射调用等。可惜我们还是不能够称之为Bug,因为这正是“反射”这个设计所期望的功能,而非不小心造成的有害副作用。)
说到这里,我想protected和new的问题应该已经讲完了。还有什么疑惑吗?
VI.回到未来
回过头来再说说面向抽象,以及面向接口。 前面已经提到了面向抽象了,不知道大家是否有更多的感想。抽象到了头是什么?当然不是什么都没有,不是虚空太极。抽象的本质是描述某个主语能够完成一组什么动作,这些动作构成了什么样的功能。如果我们从这样的一个角度去想,就会发现接口能够很好的完成这样的任务。比如说IList,它表达的是“一个列表”这样的抽象,这个抽象能够提供一组相应的动作,比如"object this[int index];" 能够取出或设置列表当中的第n项内容。只有拥有IList接口所定义的成员,才能够表明这个物体确实能够称之为“一个列表”。“服务”这个词也许能够更加深刻的表达上述的含义:
class ArrayList : IList {...}
这样的定义表明,ArrayList提供“列表”相关的服务。如果我们在定义变量和参数的时候更多的使用接口,而不是具体的类,那么我们的代码将拥有更大的自由度。这个时候我们关心的事某个对象是否能够提供我所需要的服务,而不关心他到底是什么。
在面向对象刚刚开始的时候,我们在这个方面走进了一个误区,就是用多继承来解决上面的这个需求。比如我们可以在C++里面看到Stream派生自IStream和OStream,这么做也能够解决问题,也许同样能够表达“服务”这个含义。但是我觉得这样做还是会引起许多不必要的麻烦,比如同名称冲突等。现代的理论甚至直接告诉我们继承他不是一个好东西,如果能够用引用来代替继承,那就不要继承,如DesignPattern里面的Decorate等。
真正未来的理论是什么,我不知道。但是至少我看到面向接口的设计思想比“面向对象”要更为先进,而目前真正能够这样思考的人远比知道如何封装继承的人要少得多。从这个角度讲,那也算是未来的技术,至少是未来需要普及的技术。(现在COM不就是这样一种思想吗?)