在若干年前——不对,应该是若干若干年前——要不是过了这么久我还真不好意把它拿出来讲——在那虽值得怀念却并不一定是无忧无虑的——小时候,有一天,老爸突然对我说,“我看到那个游戏机挺好玩的,给你买一台吧”。看官,我当时那个高兴劲儿你一定可以想见了吧!——我一向爱玩具胜过好吃的东西,可是还真没敢奢望能买那个东西——当时它还是个稀罕物呢,此前我也只是在电视上看到过而已。
  你猜的没错,老爸所说的游戏机就是经典的任天堂红白机。
  话说我站在卖游戏机的专柜前,看着满柜子满墙的游戏卡、宣传画、手柄、激光枪还有大大的电视机,眼睛都花了。老板娘推荐给我的游戏卡,是当时最最经典的《魂斗罗Ⅰ》。我手里捏着游戏卡那硬邦邦的黄色塑料外壳,盯着上面印着的两个拿着机枪的壮汉,脑袋里开始漫无边际地幻想这游戏该有多么好玩——就在这个时候,突然间,鬼使神差地,我问了一个这辈子最傻的问题——
  “呃,这个卡一般可以播放多长时间?”

  如今,这个傻小子已经变成了一个傻大叔(承蒙老天眷顾),并且做了7年程序员,可是,他仍然不敢说自己真的理解了计算机——因为在玩游戏的时候,他仍然会感觉“真神奇呀!”。每次进入游戏,都像是开始了一段新的故事。不像变形金刚,只能从汽车变成机器人,或者从机器人变成汽车。也不像橡皮泥,虽然理论上它是千变万化的,但是它是无结构的——也就是说,你输入什么,它就输出什么,没有交互的感觉。如果硬要作个比喻的话,我觉得游戏就像小河,虽然它有固定的河床,但是,每次都有不一样清凉的河水从你的腿间流过,不同的小鱼追逐嬉戏,跃出水面。

系统的结构

  如果有机会看到人类设计的或者自然界存在的那些自动装置,你常常可以发现它们的结构很大程度上受控于这些自动装置可能发生的故障以及对付故障所采取的相应的预防性测试办法(或多或少有些效果)。说它们能预防故障的产生有些夸张,好似采用了与这个主题不同的一种乐观的描述方法。它们不是预防故障的产生,而是被设计成当大部分故障发生时,系统不至于因此而失效。要消除所有的故障,或者消除故障所带来的影响是不可能的,我们能尝试采取的措施仅仅是设计一种自动装置,使得系统在发生大部分故障时仍能继续工作。这种装置减轻了故障产生的危害,并不是消除了故障本身。大部分人造的和自然的自动装置以及它们内在原理概莫如此。
                                                                                                                     ——约翰·冯·诺依曼

  当年玩变形金刚的时候积攒了不少怨念。我总是盯着不知重复着变了多少次形的机器人发呆,幻想着一定还有某种奇妙的形态没有被我发现。
  如果把变形金刚和魂斗罗都看作一个系统——它具有一个内部状态并存在于一个外部环境中,外部环境也有一个状态,这两个状态总是唯一地决定了下一个内部状态——的话,我很想替小时候的自己问一句,“同是系统,差距怎么就这么大呢?”。与变形金刚的区区2个状态相比,魂斗罗可以接收的输入状态和内部状态几乎是无限多的。确实,许多计算机程序让人感到神奇很大程度上是因为它们可以接收无限多个(至少是数量极大的)输入状态。不过说起来,这个能耐倒也不是计算机独有的,生活中的许多系统也能做得到。
  先说线性系统,譬如切菜机,它的设计者可能预设了它能切黄瓜、切土豆、切苹果……不过如果我们把手指头或者小JJ伸进去它也能照切不误。当然没有哪个傻蛋会塞进去一根钢筋,这倒不是因为说明书上写了“不要放入硬物”(到底有多硬才算是“硬物”?),而是我们知道切菜机里面的刀片碰上钢筋要断掉。这就是悲哀的现实:系统设计者的初衷总是希望用户能把系统当作黑箱来使用,但是用户要想用好系统往往需要多了解一些系统的内部状态实现原理。如果你硬不告诉他们原理,他们也会去猜测。电视机刚被发明出来的时候,人们猜测那个方盒子里有一个小人。如果人们一直这么不明真相的话,可以想见,在播放A片的时候,该有多少电视机被砸啊。

  在计算机世界里,和切菜机类似的是像AddOne()这样的方法。

static int AddOne(int a)
{
    return a + 1;
}

由于硬件工程师和编程语言设计者的努力,对于程序员来说,实现这样的功能就和按下切菜机的按钮一样简单直接。

  再稍微复杂一点的,是像Sign()这样的方法。

static int Sign(int i)
{
    if(i < 0) return -1;
    else if(i > 0) return 1;
    else return 0;
}

Sign()比AddOne()高级的地方在于,系统能够把输入先分类再处理。在现实世界中,与Sign()类似的系统是公共厕所。当然,犀利的你一定注意到了,公共厕所本身是没有能力把人分为男人、女人、中性人的,要做到这一点恐怕需要复杂的硬、软件设备。

  更复杂的,是像排序算法这种输入是一个集合的情况。它复杂的地方在于,往往不能把集合拆解成单个的元素分别处理,而是得把它作为一个整体来考虑。换句话说,得把输入当作一个系统。这样排序算法就成了“输入是一个系统的系统”,有点别扭。我们不如换一个视角,把输入作为一个系统看待,把排序算法看作是对这个系统的调节。也就是这种感觉:new int[]{3, 1, 2}.Sort()。
  我们在前面几篇曾经提到过无法把集合直接处理成有序的,但是我们忘了问一句“为什么呢?”。答案自然是“因为硬件不支持”。想象一下,如果有这样一个“排序存储器”,它就像一个水池一样能对存储单元产生“浮力”,且存储单元里的值越大受到的浮力越大,而且存储单元还能像潜水艇似的自由地根据受到的浮力浮起、沉下,那么当我们把数据放到这个“排序存储器”里面的时候自然就得到排好序的集合了。
  说到硬件,正所谓巧妇难为无米之炊,了解硬件能提供哪些功能是编程的起点。让我们简单回顾一下计算机系统的硬、软件的分工。计算机硬件本身是无结构的,它就像刚刚买来的乐高拼装玩具,只有一些尚未连接在一起的碎片。这些碎片——四则运算/比较大小的指令、寄存器和随机存储器以及寻址、数据传送、跳转等控制指令——就是硬件可以提供的“直接处理”的功能。当我们编写软件时,就是在把这些碎片拼装成具有某种结构的系统,这个系统接收到输入时,系统的内部状态也会发生改变。
  我这个说法,好像在暗示“代码是结构,变量(里面的数据)是状态”,但是请警惕这种说法。对于AddOne()和Sign()函数,可以认为系统的结构只是那几个if else语句,系统的状态就是返回值。但是,当系统状态由一个集合来表现时,就不那么容易分得清了。考虑第4篇介绍的堆,这个数据本身也具有结构(所谓数据结构),但是它到底是一个一维数组还是一个二叉树全靠解释它的代码来决定,而系统的行为(譬如运行时间),不单依赖于代码提供的结构,也依赖于数据的结构。另一个有趣的例子是第3篇介绍的使用了随机变量的快速排序。即使是同样的输入,每次调用随机化快速排序所耗费的时间(即系统的行为)都不同,这样从黑箱的角度,我们已经无法确定为我们排序的到底是同一个程序还是不同的程序。我们只能说代码和数据共同体现系统的状态。结构和行为,作为状态的一部分,也是由代码和数据共同体现的。换句话说,虽然程序的代码是程序员一早写好的,但是程序运行起来之后的结构却是动态的。正因为如此,我们可以期望有一天程序能够学习、推理、创新、解决问题、具有自我意识……
  为什么冯·诺依曼说“要消除所有的故障,或者消除故障所带来的影响是不可能的”呢?作为一个系统设计者,难道可以接受这么令人沮丧的结论吗?要消除所有故障,系统设计者必须做到:1)将系统的输入严格限制为预设的有限的范围之内;2)确保除了输入之外,系统的内部状态不会被任何其它的外部状态所影响。在局部上,人们的一些努力(如类型安全DEP等)使我们向这两个条件迈进了一大步。但是如果我们把系统放到无限宽广、无限深远的外部环境中,就会发现,要想百分之百地满足这两个条件只是系统设计者的一厢情愿罢了。其一,往往很难限制输入的范围。像“被强行灌入毒药而变成小正太”这种事总是会发生。其二,很难预先穷举出所有可能导致系统故障的因素。放入太硬的东西会使切菜机的刀片断掉;放入软的但是韧性很强的东西刀片又割不断;放入太粘的也不行;酸性太强也不行……其三,你可以只允许测试过的输入,但是这样将大大减弱系统的功能。没有人会愿意买一个只能切黄瓜和土豆的切菜机(放入其它东西会导致机器立刻自爆);一个算法若只能排序10个元素也足以让人无语了;其四,虽然也有像变形金刚那样只有2个状态但仍然卖的很好的系统,但是我们仍然无法阻止变形金刚内部的螺丝钉被腐蚀、生锈,它的朔料外壳在过热或过冷的环境中变形(变形!!)或者碎裂。
  更别提还有Hack这种有趣的事情。系统总是适用于现存的环境,或是基于现存的环境被设计的。当我们将系统置于设计者不曾预料到的外部环境之中,或者直接修改系统的内部状态时,会发生什么事情呢?眼影、口红、胸罩、丝袜再加上一双高跟鞋,就是最简单、实用的Hack装备,更妙的是,你明知道那是一种Hack,仍然会兴高采烈地盯着看(这说明系统的某些部分容易改变,另外一些部分却极难改变)。至于直接修改系统内部状态的例子,我想举的例子是我们每天都要喝上一杯的——咖啡。“就像运行中的发动机会产生废气一样,神经活动会排出一种叫做腺苷(adenosine)的化学物质。腺苷的堆积会使我们感到疲倦,这样,身体就会提醒我们该上床休息了。腺苷本身不会产生睡意,人体通过一种叫做受体(receptor)的细胞侦测腺苷的量。由于一天中腺苷会制造得越来越多,于是有越来越多的受体被充满。不管刺激有多强烈,我们的大脑细胞都会变得越来越迟缓,我们累了。睡觉时,值夜班的会把腺苷清扫掉。醒来后我们会感觉好些,因为头脑确实变得更加清醒。”这本来是一个可以工作得很好的机制。但是当咖啡因进入人体时,“由于在外形上和腺苷碰巧相似,咖啡因会进入本来接收腺苷的受体。一旦进入,咖啡因就会安营扎寨,阻止腺苷工作。所以,或许我们已经发疯一样地工作了几个小时,大脑里本应充满了腺苷,我们本该累得要死,但是由于受体被咖啡因阻碍不能工作,腺苷不能传递我们应该上床睡觉的信息,所以我们会感到出奇的清醒,仍然准备大干一场。”(摘自《本能》

有时,编程就像腌黄瓜

  我们刚刚正说到——当系统处于无序的状态,而硬件的能力又不支持一下子把系统调节到有序状态的时候,该怎么办呢?通过前面几篇我们看到,人们不但找到了可行的方法,而且方法还不止一个。这些方法有什么共通之处吗?如果有的话,它们的原理又是什么呢?也许,我们可以从普瑞斯科特的故事里得到一些启发。这个多少有些令人沮丧的、发人深省的、也挺有趣的故事是我几年前在温伯格的《咨询的奥秘》一书里看到的:
  普瑞斯科特大众商店是诚信的典范,普瑞斯科特本人也是这样。没有人腌的咸菜能像普瑞斯科特腌的那样好。
  “我们有祖传秘方,是我爸爸传给我,我爷爷传给他的。”普瑞斯科特说。
  “我并不想剽窃你的配方。但我在试着学习保存东西的方法。你能不能大致地告诉我一些关于制作腌菜的秘密?”温伯格说。
  “好吧,也许我能告诉你一些。”普瑞斯科特重新回到椅子上说道,“我的祖父曾经讲过一个关于一根固执的黄瓜的故事。当时他把这根黄瓜放进桶里准备腌制,这根黄瓜环顾了一下四周和他处境相同的黄瓜,准备对它们所处的境遇进行反抗。‘真讨厌!’它咒骂道,‘你们这些家伙怎么了?你们难道没有一点勇气吗?任何一个有自尊的黄瓜都不会让自己在未经历抗争的情况下就被腌了。’
  “‘但我们能做什么?’众黄瓜问道,‘你可以抵制,这是你能做的,也是我们不想做的。盐水不会因此而降到我们身子底下。’
  “然后祖父就会停下来,我就总是问他‘那根固执的黄瓜怎样了?’”
  “那他怎么说?”温伯格问道。
  “他说:‘别那么笨了,孩子。如果你在盐水中呆足够长时间,你也会变成腌菜。’”
  这就是普瑞斯科特腌菜原理(Prescott's Pickle Principle):是黄瓜变得更像腌菜,而不是盐水变得更像黄瓜
  用另一种方式来阐述普瑞斯科特腌菜原理就是:试图经过长时间的接触去改变一个大系统的小系统,其本身更容易被改变
  无论是插入排序还是归并排序,我们都是在造这样一种势:系统的绝大部分是有序的,无序的总是小部分,这样才更容易把整个系统变成有序的。在快速排序和堆排序算法里面我们也很容易找到腌菜原理的影子。
  再说一点题外话。第一次听到邯禅学步这个故事的时候,我感到既悲哀又不解——那个既好学又勇于实践的寿陵少年为什么到头来落得那么悲惨的结局呢?现在想来,一定是因为他没能让自己已有的知识“吃掉”新的知识,他学的太多,又太快,这些知识没能在他脑子里形成稳定的、有层次的系统,他学得越多、越快,系统反而越无序。所以,让我们每天再多练习下基本功吧,再多做些抽象的思考吧,虽然它们看上去没有实战技巧那么有用,那么有趣,那么有成就感。
  

posted on 2010-05-04 09:19  1-2-3  阅读(3767)  评论(22编辑  收藏  举报