有时无知是福。俺看到一点新鲜的科普也能觉得造化神奇。刚才读Gerald Jay Sussman(SICP作者)的文章,Building Robust Systems – an essay,竟然心如小鹿乱撞,手心湿润,仿佛第一次握住初恋情人温柔的手。
这篇文章主旨明了:构造复杂的健壮系统非常困难。我们的软件能够有效完成某件具体任务,却不能适应业务领域的变化。一点细微的需求或部署的改动都能让我们的系统变得脆弱。反观生物进化的历史,无数生物在频繁变更的严酷大自然里繁衍生息。也许我们能从中学到让系统随环境变更而自动演化的秘密。当然,不是所有的软件都身段僵硬。35年前问世的Emacs至今是最高效的编辑器之一(不怕惹众怒地说一句,流行的EditPlus和UltraEdit除了学习曲线,还是不能和Emacs比),实属居家旅行杀人越货之必备利器。20年前问世的TeX ,1989年就停止更新代码,但这并不能阻挡它横扫科技出版业,变成科技出版物排版的事实标准,基本上干掉了科技出版业里的排版员这项本来很有前途的工作。不过,这些都是特例。我们需要了解的是构造复杂系统的通用方法。不然skynet和matrix怎么能够问世嗫?
Sussman在文章里讨论了生物在残酷进化里得来的五坨特性。这些特性对谙熟生物的老大们也许不是新鲜事,却能让我这个靠三思科普生物的半文盲肾上腺素急剧释放:
- 冗余(redundancy)和简并(degenerate,不知道这个翻译对不对)
强健的生物系统都有大量冗余。比如肾和肝。割掉一个肾,我们仍然能活蹦乱跳。把肝的三分之一供奉给乙肝病毒,我们被歧视乙肝携带者的公司气死的几率也大多得肝癌挂掉的几率。
生物系统也高度简并:同一功能可以由不同的部分来完成。比如我们的能量既能从糖获得,也能从脂肪获得,还能从蛋白质里获得(这个造成我们为节食和消耗热量绞尽脑汁,实属进化跟不上变化的特例,另当别论)。而这三者的代谢过程都不一样。甚至我们的遗传代码也是简并的。我们有20来种氨基酸,但核苷酸构成的密码子组合却有64种。这样才能让点突变不至于影响某个密码区的蛋白质,使得突变能积累起来,同时不会导致明显的表现型后果。不然领导某天生出一特聪明的小孩儿(变异了),但就是长得像头羊,我们非得抓狂不可。简并成于进化,也成就进化。环境改变了,大不了某个部分废掉,但生命继续(系统依然满足规格)。而废掉的功能为变异(或修补)腾出空间。整个系统没有被惊扰。
现在的软件系统往往包含冗余,但很少刻意加入简并。作者顺便对Python拍砖:Python的口号是TIOWWTDI(There is only one way to do it),明显是零简并系统。
- 探索行为
探索行为也是生物系统健壮的基石。生物需要的合适功能通过“生成-测试”的机制获得。系统某部分生成功能,而另外相对独立的部分测试功能,决定接受还是拒绝测试结果。比如说支持细胞上的微管阵列决定细胞的形状。微管们总是不断被生成和摧毁。那些有幸碰到细胞膜里稳定子的微管们得以存活。最终结果就是细胞的形状又稳定子的位置决定。所以细胞形状的生成和维护机制同确定细胞最终形状的机制分开(操作系统设计里policy和mechanism分开有点类似)。
探索过程中测试者不必知道行为提供者。行为提供者也不必知道测试机制。这样的结果是变异和适应非常灵活,因为测试者和行为提供者可以自由发展。反正合者生,不合者死。负面作用就是这种选择代价高昂。自然选择的每一秒都伴随着无数生命的消亡。
- 隔离和定位
我们身体的每一坨细胞都源于单一的受精卵。所有细胞的遗传信息(才1GM内存!)都一样。但是,我们有各种专门细胞,比如皮肤细胞,神经细胞,肌肉细胞等。这些细胞再进一步组织成组织,器官,和器官系统。这一切之所以可能,是因为细胞们能根据环境特异化。也就是说,细胞的行为并没有被编入细胞间的信号传递,而是由基因组决定。不同的信号组合可以激活或关闭细胞的某项功能。不同的细胞组合起来,进一步实现更为复杂的功能。
优秀的软件系统有类似的特点。它们高度模块化。不同的模块在不同的环境里执行对应的功能,进行不同的组合。
- 防御,修补,和再生
生物大都能防御异物的攻击,修补缺损的部分,再生死亡的肌体。现在的软件开始包括防御功能,比如安全特性。不过,很少有强大的修补和再生功能,虽然号称自己能自我调控的系统不少。前两年IBM闹腾得欢的autonomous computing最近好像也让位给牛皮轰轰的SOA。Erlang的容错系统倒非常诱人:它的基础设施让系统比较轻松地侦测出问题的进程,然后在不影响系统运行的前提下重启该进程。
- 组合
复杂系统都是又小模块组合而成。这点我们并不陌生。我们构建的系统模块依赖事先写好的规范。比如接口的规范,比如接口调用的顺序。这种规范现行的办法随着系统复杂程度的增高变得越来越难。相反,人类的基因组信息不过区区1G,还不够容纳一个普通操作系统的规范,却足以决定我们的构造和行为。我们的“系统接口”必须能够自我配置,适应环境。代价是系统的初始化时间太长,9月怀胎不过是刚开始。
文章也讨论了具备这些通用特性的健壮系统需要哪些基础功能:
- 通用的部件
健壮的系统总是由一系列通用部件构成。每类部件都有广泛的应用范围。每个部件能接受范围宽广的输入,但能输出范围狭窄的结果。这好比久经考验的电子原件。他们能在充斥了噪音信号的环境里正确工作,输出可以预测的信号。
- 可以扩展的泛型操作符。比如同样是加号,+, 既可以处理整数相加,也能处理实数相加,也能处理矩阵相见。更重要的是,还能让用户扩展该操作符的语义,引入新的功能。这样不仅能让新程序容易编写,而且能旧的程序自然增加功能,应对新的环境。这点其实很多语言都有所支持,尤其是现下流行的动态语言。比如说在Lisp环境下开发,我们可以开发一个简单的版本,让程序运行起来。然后我们就在这个运行的程序里不断调试,修改,和加入新的功能,直到这个系统健全。这里有演示录像。
- 生成和测试
我们的系统应该让我们能够写出返回多项选择的函数,然后同步测试这些结果,挑选出合适的结果。如果结果不好,系统自动回溯。这样做的危险是回溯可能导致指数级的运行时间。不过这也是进化不可避免的代价。其实生成-测试的理念早已用到编程当中。比如Prolog的自动回溯。和众多动态语言支持的模式匹配--比如Erlang, 比如Scala,比如Ocaml。
- 通过约束得到的普适过程
这个大概是说通过一系列的约束条件,我们可以构建出对应的约束网络。通过这个网络,我们能生成适应这些约束条件的过程或函数。这样生成的函数能满足广泛的应用环境。
- 工程中的简并
作者提出了两种方法。一是利用AI中常用的技巧:目标导向(goal-directed)的方法。这个本质上是说我们不规定系统怎么做一件事,而是告诉系统要达到什么目标。Prolog简直是解决这类问题的天生杀手。当然,现代的函数编程语言也是解决这类问题的利器。二是对同一问题实现多种独立解法,然后让系统挑选合适的方法。
文章还讨论了支持系统健壮和进化的方法:
- 组合子
简单说,我们应该通过搭配组合相对独立的部件来构建系统。函数语言对这种编程方式尤其擅长。特别是支持transparent referential integrity和lazy evaluation的函数语言,比如Haskell:我们搭建出基本的模块。Haskell系统提供一大堆强大的黏合工具,让我们把这些模块轻松地组装起来。不过文章也强调,关键还是模块的质量。函数编程只是有效的手段。
- Continuation
这个特性在Ruby, Python,Scheme, Smalltak等现代动态语言里都有。Continuation是非常强大的编程手段。一个Continuation对象被创建时能保留当时系统的有关状态,并在其他任意时间被调用。这让程序员获得对时间的直接控制。我们可以暂停某段计算,并在一段时间后继续那段暂停的计算。牛人Avi就利用Smalltak对Continuation的完善支持写出了绝对让人惊叹的Seaside Web编程框架。不信邪的老大们可以去体验以下用Seaside搭建的应用,dabbledb。
- 回溯和并发
没有回溯,我们就不能自动检验多项选择,排除不合适的结果。没有并发,我们不能同时检验多个结果,计算的代价太高。这些好像不新鲜。
- 任意联系
标注元数据便是一个例子。我们不能也不可能预测系统运行时数据间的所有关联。所以构建可以任意扩展的数据关联系统就非常重要了。这好像也不新鲜。比如说至少20年前人们就认识到Metaobject Protocol的重要性,有兴趣的老大可以去读这本经典的书。现在Java支持的Annotation也可以算一个例子。
- 动态配置的接口
这个比较新鲜,属于正在研究的难题。现在的方法是让一个群体内的计算实体(agent)通过不断地交流信息来建立大家理解的规则。俺没有看明白。
总之,这篇文章属于高来高去的主题演讲性质的文章,但它对生物系统以及生物系统与计算系统的联系的描述着实让我大开眼界。也许文章结尾是最好的总结:正经的工程不过几千年历史。我们构造健壮系统的手段还远未成熟。我们还没有从漫漫几十亿年生物进化中吸取经验….
Sussman认为构造出健壮软件需要我们的系统支持continuation, 回溯,和生成-测试的方法。生成-测试最直观简单的方式是为系统提供多项结果。系统一个一个地测试这些结果,并接受符合要求的一个。Sussman举了一个例子:平方根函数通常返回正根,而抛弃那个负根。那按照生成-测试的方法,一个平方根函数应该将负根和正根一起返回,然后由系统决定到底哪个根更好。后来他进一步提到(第20页最后一段)可以返回正负根的AMB值。昨天写帖子写得头晕眼花,竟然忘记讨论这坨精彩的函数(编程意义上)。今天补上。我们可以看到一个简单(简单不等于浅显哈)的抽象,居然能实现众多美妙的功能。一个看似无意创造的玩具,竟然是有助于揭示构建辉煌软件大厦的秘密。
AMB这个操作符历史久远。AI大牛,LISP的奠基人,John McCarthy在这篇
1961年的论文里首次提出了模糊函数(ambiguous function)。我们一般把这个函数简写为AMB。似乎最近俺读的文章也好,书也好,都越来越年代久远。不过不怕打击喜欢追新的老大们:现在流行和将要流行的技术背后的理念都至少有20年的历史。比如这个AMB函数,虽然1961年就被提出,但知道1987年还有研究它的论文发表。互联网?Google一下Douglas Engelbart。面向对象?Google一下Simula和Alan Kay。垃圾收集?Google一下LISP。BPEL?Google一下Robin Milner。作为编程语言的XML? Google一下S-Expression。元编程?Google一下Metaobject Protocol和CLOS。这其实符合技术革新的规律:大学里新奇的编程手段差不多要20年到30年才被大众接受。新理念提出后,还得被无数学者验证,润色,完善;还需要无数工程师实现,优化,改进,推广。所以说呢,如果老大们不满足于赶潮而憧憬弄潮,慢下来,向后看,也许是不错的手段。跑题老,还是说这个AMB操作符。俺最早在
SICP上读到关于AMB的讨论。SICP也算是一本奇书。大一的入门教材,问世20来年了,里面的内容仍然让人拍案叫绝。每年翻开这本书,都能学到一点新的东西。书的第三章已经开始详细讨论并发和stream。第四章详细讲述了梦幻般的meta-circularity,接着就是现在开始热门的lazy evaluation,和logic programming。而第五章干脆教我们搭建一个简单的寄存器机器。又跑题了。最近越来越像老人家,变得唠叨。通俗讲,amb接受0或多个参数,并不确定地返回其中一个参数。比如说,amb(1, 2)可能返回1,也可能返回2。如果amb没有参数,那它不返回任何值,并抛出异常。也就是说,amb()一定挂掉。同时,amb的参数可以是一个函数(对用C/C++语言的老大来说,就是一个函数指针),amb返回的值必须是不会抛出异常的参数。比如说,amb(amb, 1)必须返回1,因为amb()会抛出异常。同理,amb(1, amb)也必须返回1。显然,amb的实现不能是简单的检测每个参数。比如下面这段代码里的amb(false, true)必须返回true, 不然else里的amb()就会被调用,导致这段代码抛出异常。
if amb(false, true)
return 1
else
return amb()
end
那这种不确定的函数到底有什么用呢?呵呵,用处大了。它正好简单地模拟了生成-测试:我们有一系列备选答案。我们把这些答案传给amb,他返回不会失败的那个。这不正好符合生成-测试的定义么?其实哪怕编程中这个函数也有妙用。比如说下面这道题(SICP4.3.2):
Baker, Cooper, Fletcher, Miller, 和Smith住在一动5层公寓的不同楼层。Baker不在顶楼住。Cooper不在底楼住。Fletcher既不在顶楼也不在底楼。Miller住的楼层比Cooper住的高。Smith和Fletcher的楼层不相邻。Fletcher和Cooper的楼层不相邻。问每个人住的楼层。
有了amb这个函数,我们可以这么解决这道题(为了方便大多数人,我把Scheme代码移植到Ruby代码了。调用amb()变成了amb.choose):
看哈,每一行语句直接对应题目的条件。没有循环。没有递归。全靠amb这个看似简单的函数。运行的结果是:
有Scheme运行环境的老大们可以运行下面的代码(在实现amb以后)。结果一样。
第一次看到这段代码,我只觉得背后妖风阵阵。从来没有想到过,程序能写到这个地步。每一行语句居然和题目条件切合。比伪代码还为伪代码,但偏偏可以运行。那到底amb怎么实现?为什么俺要专门说这个函数呢?原因是这个函数的实现要用到continuation和回溯。当然不用continuation也能实现。比如用Java也行(友情提示:thread + 匿名类)。不过用continuation的方法最为简洁。Ruby代码不过10来行。所以我们先简介一下continuation。
Continuation到底有多重要一直有争议。Matz在邮件组里说Ruby 2.0会去掉continuation, 因为没有”compelling use cases”,结果引来一场讨论。Avi Brant就力挺continuation,毕竟他的Seaside框架就是基于continuation技术的。对了,Avi在今年的ETech会议上做了报告,批评RoR方法落后。这里有
会议笔记,非常有教育意义。简单说,continuation就是高级goto,好比C下面的setjmp/longjmp + Solaris下的getContext()。它允许程序记住某一点的所有状态,然后在其它时刻回到那一点,继续执行。用Ruby举例(Ruby的callcc和Scheme的用法没有本质区别):Ruby的函数Kernel#callcc生成一个Continuation对象,并把这个
知道了Continuation和回溯的概念,实现Amb也就容易了。因为是Ruby,我用了一个类来封装变量backtrack_points。这个实现的本质是深度优先搜索。每一步的状态用Continuation保存起来,回溯时使用。
这本写得很好的Scheme入门书上也有amb的实现。Ruby Quiz提供了
类似的代码,是从同一本书的Scheme代码直接移植的。不过Scheme不支持return, 所以它的实现用了两个call/cc。外层的用于模拟return (所谓的escaping continuation)。所以移植的代码不是那么容易理解。在Ruby里用了return语句后,代码要干净清晰得多:
困。。。代码的解释改天继续。也许这段代码很直观不需要解释呢?哪位老大读到了这里,说说看?先谢谢了。