注:本篇博文为重发稿件,“CSE脚本世界”曾被清空过,现已找回原文。
这本写于两年前(注:指2006年),网上早就盛传开来,当然,大部分转载都把我的大号隐掉了,标为“来源:互联网,作者:佚名”。这里重发一下,一是因为当时写这篇文章确实花了些时间,还修订过几次,二是为了正本清源,本文乃Wayne chan所作,有人想转载请标明出处。
一年前有人将本文推荐给某大学的学术期刊,有编辑找到我,问我想不想在他那里发表,我寻思,愿发就发吧,本人不图稿费,也乐于为人捧场。然后这个扯着嘶哑口音的编辑给我指出一些文字上要改进的地方,我一一记下来了,末了,老先生告诉我,如果想发表,请交xx元费用。我的热心劲一下子消失了,礼貌性回绝了他的要求,这世道怎么老跟钱挂钩,不要稿费也就罢了,怎么还拉赞助呢?他们期刊的质量想必好不到哪儿去。
程序语言的自我意识与仿他意识
2006-4-8, by wayne chan
从浮点数说起
昨天有一位网友问我一个问题,为什么表达式“1.9 + 2.3 == 4.2”在Python中计算结果是FALSE?我回答说,计算机模拟浮点数时会失真,网友说这个他明白,但为什么“1.9 == 1.9”结果却是TRUE,机器看1.9也同样失真呀?实际上,两个表达式是有差别的,前者在比较之前运算了,而后者没有。
这是一个很有趣的问题,如果我们把电脑看成有感知的生物,上述问题反映了人脑与电脑是按不同方式认识事物的。首先,电脑总是以2进制表达数值的,而人脑习惯使用10进制,比方被人脑标识为0.125的数据,到电脑中就被看作0.001,即“0/2 + 0/4 + 1/8”。这两种方式都能唯一表达浮点值,本例中,两个浮点值能在10进制与2进制之间顺利转换,但不幸的是,大部分10进制浮点数无法准确的转化成2进制,比如上面的1.9,在Python程序中,它显示为1.8999999999999999。当然,这个数值还不是电脑自己认为的值,它经过两次转换,第一次是我输入1.9后,电脑把它转换成它认识的2进制格式,第二次是电脑要告诉我它接收到的值是多少,又把2进制数据转化成10进制打印出来。
其次,电脑总尝试以最精确的形式表达一个数据,而人脑往往以简单形式表达复杂数据。比方,老板告诉你,“好好干活,这事成了分你三分之一利润”,如果老板长一个CPU脑袋,又很有偏执狂精神,他可能告诉你,“好好干活,这事成了分你0.3333333333333333...”,幸好本人还不太偏执,否则,我一辈子不用干别的,把这句话转述完就安息吧。
在Python中计算“1.0 / 3”得到结果值是0.33333333333333331,这是32位电脑对现实认知的最精确形式,而我们拿“1/3”来表达,为什么既省事,又精确?这里“1/3”不只是数值,还隐含了一种计算:先3等分再取走1份。之所以精确,因为它是自描述的,我们还原了它的本来面目,就像宋玉在《好色赋》中形容东家美女,“增之一分则太长,减之一分则太短”,如果提高浮点精度,不论32位、64位,还是128位CPU,存贮1/3最终都要截除尾数,都无可避免的要失真。
我们先确立两个概念,其一,某种形式化的计算过程,等价于静态数据;其二,自我描述的数据可以不失真。
表达式是不是数据?
1/3是自描述的数据,同时它还是一个可计算的表达式,我们推而广之,是不是所有表达式都是数据?这是一个见仁见智的问题,应该没有标准答案。
应用程序本来就是对现实世界一种模仿,编程语言只是实施模仿的辅助手段,要不要把表达式看成数据,完全在乎你——编程主体,以及当前依赖的环境——开发工具,需要采用哪种方式会更好达成目标。这句话有两层含义,首先,编程环境可以影响一个表达式该不该成为数据,比如,在C语言中,表达式是表达式,它最终编译成运行代码,数据是数据,编程中可直接读写。而在LISP语言中,表达式也是数据,既用于运算,也支持直接读写。此外第二层含义,即使开发语言很大程度上决定表达式该不该成为数据,却不绝对,还与应用相关,比如某些防跟踪软件,即使用C语言开发,也局部的将运行代码看成数据,运行中动态修改,让它在反编译工具变成花指令。
表达式是不是数据,是函数式编程(Functional Programing,FP)区别于命令式编程(Imperative Programming)的重要差异,也是根本性差异。按严格定义,函数式编程把程序的输出定义为其输入的一个数学函数,只有把函数调用也看成数据,中参数中层层串接,最后才组装出完整的程序。比如:if..else句式,使用函数式语言表达如下:
else(if(expr,TRUE_branch),FALSE_branch);
外层调用else函数时,先计算第一个参数(即if函数调用),如果if条件成立,执行TRUE_branch语句并返回TRUE,否则直接返回FALSE,接着else函数分析if调用的返回值,决定FALSE_branch语句是否要执行。这个例子我们看到,把函数用作参数,可以连接多个运算。
按严格FP定义,函数式编程没有内部状态的概念,而命令式编程要频繁使用赋值语句,用变量保存内部运行状态,比方if..else在命令式语言(如C语言)中实现时,两条语句看似无关,但实际是按固定格式框定了的,前后必定相连,计算中系统还隐式的使用临时变量记录if条件值。
我们常见的语言,包括C、C++、Pascal、Java等都是函数式语言,函数式语言主要有LISP、Haskell、Scheme、ML等,除了这两者,还有一类逻辑式编程(Logic Programming,LP)文献中通常也将它看成独立分支。因为,大部分函数式语言都很容易构造出逻辑式程序,LP与FP实际是比较近似的。
所以,大致而言,命令式与函数式代表当前编程语言的两大流派,本文尝试从根源上探究两者差异的原因,借用摩尔根(美国生物学家,1866—1945)的话,基因决定性状,我们尝试从基因分析入手,分析两者内在机制的差异性,理出脉络后,再将他们的外在差异贯穿起来讲述。
高阶函数与惰性求值
高阶函数(High-Order function)与惰性求值(Lazy evaluation)是函数式编程的重要特征,这两者都直接起源于运算式的形式化描述,也直接促使FP具有广泛的多态性。
所谓高阶函数是指以函数作为参数或返回值是函数的函数,比如Python中的apply函数:
apply(pow,[1.9,2])
8
pow是Python一个内嵌函数,把它当作一个参数传给apply,apply函数运行中,先把pow及两个参数组织成可计算对象(即Python中CodeType类型的数据),然后完成pow(1.9,2)求平方数运算。
下面,我们再看看惰性求值是怎么回事,比如我们定义一个函数handover,它的功能是把一个表达式从一台机器传递到另一台机器再计算,如:
handover(pow,[1.9,2],’Machine_B’)
惰性求值可以让我们在需要的时候才做计算,特定场合下惰性求值能产生奇异功能。比如,本例类似于前面提到1/3,我们知道,在异构系统中传递数值会失真,如果我们把“1.9 ** 2”这种自描述的数据传递给更高精度的CPU,比起算出浮点数再传递,结果肯定更加精确。再如,某些树状结构下实现快速搜索,遍历树节点时我们附带算法(当作数据被传递),在特定节点或特定情况下,才将它看成表达式进行计算。
让程序操纵它自身代码,将形成比面向对象语言更为广泛的多态性,不只程序的伸缩性增强了,也让软件具备某种自我感知的能力。
比如,1971年鲍布.托马斯编写了一个叫“爬行者”的程序,这个程序能自我复制,能从网络一个节点爬到另一个节点,还出乎意料的在每一台机器的屏幕写上“I’m creeper! Catch me if you can!”。没过多久,整个局域网就布满了爬行者,它们消耗很多资源。为了清理这些淘气包,另一个程序员设计出一个叫“收割者”的程序,它可以搜寻并删除爬行者,当系统中再也找不到爬行者时,收割者就执行程序中最后一项指令:毁灭自己,然后从电脑中消失。
这种爬行者与收割者实际上是病毒软件与反病毒软件的雏形,把自身代码也当成数据来处理,就轻松实现自我繁殖。当然,自我繁殖还只是一种简单的复制拷贝,后面章节我们将进一步讨论程序语言的更高形态——自我演进。
二元世界
下面再看另一个函数式语言特征:lambda演算。形式上我们可以把lambda看作动态定义的匿名函数,比如在Python中:
>>> apply(lambda x: x * x, [3])
9
>>> iValue = 4
4
>>> apply(lambda x: x * iValue, [3])
12
lambda运算深刻的表现出FP独有特征,实际上,lambda演算先于函数式语言就存在了,1930年Alonzo Church和Stephen Kleene为推论构造性证明与非构造性证明,建立起lambda数学基础,后来lambda引入LISP等语言,成为函数式编程的基础。
lambda演算克服了命令式语言的非构造性缺陷,也即,运算过程没有告诉我们,如何获得“针对任一输入得到确定输出”的计算方法。所谓构造性证明是指能具体的给出某对象,或某对象的计算方法,当能证实“存在一个X满足性质A”的证明时,我们称它是构造性的,反之,非构造性证明主要通过反证法实现。lambda演算中所有东西都是函数,例如自然数就可以表达成一个可分辨的零函数(通常用恒等函数表示),和一个后继函数(用于得到下一个自然数),其构造性特征成为数理推论的基础。
如康德所说,“数学知识是从概念的构造得出来的理性知识。构造一个概念,意即先天的提供出来与概念相对应的直观”,后来,19世纪德国的克罗内克又进一步指出:“上帝创造了整数,其余都是人做的工作”。主张自然数与数学归纳法,是数学最根本的和直观上最可信的出发点,其它一切数学对象都必须能在有限步骤内,从自然数中构造出来,否则就不能作为数学对象。
lambda演算完美的结合了作为表达式的动态运算特征,以及作为数据的静态存在特征,其描述形式(指匿名申明与即时定义)非常适合推导事物的本原,下面我们换一个角度诠释lambda的存在意义。
按照我们的生活经验,认识一个物体首先要从静态角度观察,高速运动中的物体是很难识别的。编程语言中的数值、变量,以及已定义的函数,都是这一类静态物体。另外,静态物体若不与其它物体交互作用,就不能展现其功能,它的存在仅仅是概念,编程语言中的表达式,或者说函数调用,实际就是这种交互作用的形式化描述。lambda将两者合二为一,如上面Python例子,“lambda x: x * iValue” 定义是上下文相关的(否则会找不到iValue), 在参数传递期间,lambda函数是静态存在,以数据形成传递,而同时它又是匿名的,该函数并不对他人产生影响。当这种没有副作用的演算方法,用于获得“针对任一输入得到确定输出”的计算方法时,我们说它具备自我描述能力。
自描述在众多FP语言中很容易找到例证,比如有人曾用LISP语言自身,开发出LISP解释器,下面我们用Python脚本再举一个浅显例子:
def PrintEval(s,Global,Local): if s == 'exit': return 1 else: try: print eval(s,Global,Local) except: from traceback import print_exc; print_exc() def test(): iValue = 99 dbLoop = lambda d1,d2:PrintEval(raw_input('DB>'),d1,d2) or dbLoop (d1,d2) dbLoop(globals(),locals())
本例定义一个标识为dbLoop的lambda函数,lambda函数中使用or连接它自身,由此构造出循环逻辑。调用dbLoop后系统进入交互调试状态,循环执行用户键入的命令直至输入exit退出。这个例子尽管简单,我们显然做到交互执行器自我描述了。
本文中自我描述是指对于所有表达式E,在新构造的解释器I下计算E得到的结果,与直接计算E得到的结果值相同,这不是说拿构造物替代原有功能。
受限于Python的语言能力,上述自描述代码没有用纯粹的FP方式实现,其中PrintEval函数定义及dbLoop变量是命令式的。命令式风格让自描述过程无法自包含,会遗留一些影响(如上例PrintEval与dbLoop定义),比如交互调试中,用户可能修改dbLoop的属性,而dbLoop要提供正常的调试功能,须保证它在使用过程不受影响。
按照自描述的定义,所有表达式E在自描述前后执行是等同的,所以,从严格意义上讲,上述代码还没实现真正的自描述,产生该问题的根本原因是引入了命令式风格。我们把代码改造成如下形式,可以消除dbLoop变量的影响。
def LoopEval(Global,Local): while 1: s = raw_input('DB>') if s == 'exit': return try: print eval(s,Global,Local) except: from traceback import print_exc; print_exc() yield s def test(): iValue = 99 apply( lambda d1,d2: [item for item in LoopEval(d1,d2)],[globals(),locals()] )
从理论上说,某系统如果是自描述的,它需要支持完整的lambda演算,以及,该系统中至少能找出一个不动点。大家不妨进一步练习,看看上面例子中,LoopEval函数定义是否也可消除(提示一下,请大家分析print与yield两个命令式语句,看看能不能找到可替换函数)。
前面讲到构造性证明与非构造证明,如果觉得不好理解,建议大家结合上面例子再细琢磨。命令式编程与函数式编程反映两种截然不同的世界观,命令式从已然经验推导行为规则,而函数式从非确定经验推导与源头可能等价的规则;前者反映了一种静态的、先验性的认知,而后者是动态的、后天学习的、尚在成长中的认知。
命令式语言好比是无所不知、无所不能的上帝,凭他自己的能力与意愿,制造出精美绝伦的亚当与夏娃。而函数式语言则是人类自身,任何个体都不是全能的,但通过自我演进直至最终趋于完美。
还有一个比较恰当的隐喻,拿人工生命科学中常用的两个术语概括:命令式风格是生命如我所识(life-as-we-know-it),而函数式风格是生命如其所能(lift-as-it-could-be),前者拿一种经验套用另一种经验,是仿他意识,而后者表现出一种自我意识。再通俗一点来讲,针对同一功能实现,命令式语言会说:“这事我看如何如何...”,而函数式语言会说:“这事本来如何如何...”。
不能两次踏进同一河流,还是一次也不能
早期算法研究表明,基于自动机、符号操作、递归的函数定义和组合学,都具等备等价的描述能力,尽管命令式与函数式的语言风格存在很大差异,但他们的计算能力是同等的,也即符合著名的丘奇论题:任何符合直观理解的计算模型都具有相同的计算能力。随着时间推移,人们已经形式化的证明了各个算法之间具有等价性,其中包括图灵自动机与lambda演算。
图灵机是一种由一个无限长纸带、一个读写头组成的机器,它具备访问纸带上任一单元的能力,由内部程序驱动不断的修改存储带上各单元的值,最终完成各项运算。图灵机是一种非常简洁的计算模型,具备完整的计算能力,我们只要改变它的内部程序,完全可以胜任现代计算机能做的工作。
大家知道,计算程序如果有Bug可能会造成死机,图灵机具有等价能力,它如果运行特定控制逻辑,会不会也出现类似情况呢?下面我们分析Alan Turing提出的图灵停机问题:
存不存在一个图灵程序,比如说P能够判断出任一程序X是否会在输入Y的情况下陷入死循环?若有死循环P将返回TRUE,否则返回FALSE。
我们尝试一劳永逸的构造出这种检查死循环的程序P,该程序同样可作用于自身,即,将P自身作为输入,运行P最后上报检查结果。如果假定P在某种输入情况下不存在死循环,那它最终上报结果是FALSE,但此时如果P存在死循环,那它应该上报TRUE,现在问题出来了,此条件下P程序有死循环,那它能否上报结果呢?
图灵停机是个悖论,它尝试在P程序尚不存在,或定义尚未完成时,就要求自身能被分析,这好比一条巨凶猛的蟒蛇,它有能力吞吃世界上任一条蛇,现在我们发出指令让它吞吃自己,于是蛇头咬蛇尾,有一半下肚了,另一半还能不能继续吞咽下去?!
图灵停机反映出一个深刻问题:自我演进的系统能否始终处于自我否定状态?GCC用来编译C代码,而它自身也是用C语言开发的,我们拿GCC编译自身源码,一次编译造就GCC一次自我演进,把图灵停机问题套用到GCC,就是GCC停机问题:
存不存在一个现成GCC版本,它总能顺利编译因自身语法调整而修改的源码?
当然,答案是否定的,我们先讨论一个更为广泛的命题——不动点定理,之后回头分析GCC停机的原因。
一维布劳威尔不动点定理:设f(x)是定义在[0,1]上的连续函数,且满足0≤f(x)≤1,则必存在x0∈[0,1],使f(x0) = x0。
由于f(x)是连续函数,当x在0到1的区间连续变化时,f(x)曲线必然跨越下图A区与B区,或者与分隔线(y = x)的端点重合,交叉点(或重合的端点)就是不动点x0,重合点既满足y = f(x),也满足y = x,即f(x0) = x0。
上面f(x)是指定区间内的连续函数,如果应用到FP编程,函数可以成为f(x)的传入参数,也可以是它的返回值,在自我描述的系统中,Y是这样的运算符,它作用于任何一个函数 F,就会返回一个函数X,再把F作用于这个函数X,还是得到X。即“(Y F) = (F (Y F))”,由于Y作用于F返回的X,在其操作前与操作后维持稳定,我们称它为F的不动点。
X代表了F中的某种稳定的本质性的东西,而Y操作的目的是要发现这种本质特征。从上图我们还可以看出,一个系统的不动点可能有多个,前面提到的编程语言等价性,不同计算模型等价性,都侧面验证了这一情况。
即使是同一编程语言,完成一次自我演化都可以凭借不同的不动点,比如,if、else语句可以用带短路功能的与或逻辑代替,循环语句能用递归调用构造,如前面举例的Python代码,用OR连接自身后调用就构造出完整的循环处理。条件选择与循环处理还可以用更低层次的汇编指令去实现,一个条件跳转指令就够了。
我们回到GCC停机问题,这个命题是说GCC“总能”编译自身变化中的代码,原理上说它的变化是指定区间内的连续函数,如果GCC基础语法调整了(比方删除if、else,改用其它表达方式),必然可能破坏已有不动点。尽管一次演化可选择不同的不动点,但这是普遍意义上讲的,针对GCC特例,本命题的前提是:当前编译器与被编译代码都还是GCC,并不变成其它语言(如汇编语言),不动点不存在了,就无法支持自我重构。什么叫GCC还是GCC?请大家体会一下,这句话是不是很有嚼头。
在一次编程中,我们把无副作用的lambda演算归结为函数式风格,把有静态规格定义的变量或命令语句,归结为命令式风格。推而广之,局部演化特征必然反映到整体,自描述系统的一次演进过程中,存在不动点可认为是命令式风格,而规格平滑演进属函数式风格。
这种动静结合、交互轮替的规则,遍布于有机体各个角落,及其发展进程的每一时刻。映证了辩证法观点,运动是绝对的,静止是相对的,而且是必须的,否则导致不可知论。比如GCC演进,按照赫拉克利特的说法,“人不能两次踏进同一条河流”,GCC演化中仍具有暂态的稳态性状,所以,GCC还可以看成GCC,而按赫拉克利特的学生克拉蒂鲁的说法,人连一次也不能踏进同一条河流,程序经过修改就不再是GCC,GCC停机无从谈起了。
无论图灵停机,还是GCC停机,提出问题者是以静态角度,命令方式提出质疑的,如果换成函数式思维去观察,这两个命题是不是还存在?
假定我们自身就是万能的图灵机,如果真要检查自身逻辑处理是否有死循环,现实一点,我们会再造一台图灵机,让那一台图灵机做检查,因为当自身还不能运转时,不可能检查自身的。对于迭代中的GCC,假定我们自身就是GCC程序,哪部分改了就是修改了,如凉水浇背,冷暖自知,并不是我们非得要给个概念,前一刻叫啥,后一刻又叫啥。
下面我们轻松一下,欣赏一段禅宗公案,《大慧普觉禅师语录.卷二一》中记录:
僧问赵州:“狗子还有佛性也无?”州云:“无。”看时不用博量,不用注解,不用要得分晓,不用向开口处承当,不用向举起处作道理,不用堕在空寂处,不用将心等悟,不用向宗师说处领略,不用掉在无事甲里。但行住坐卧时时提撕“狗子还有佛性也无?”,“无。”提撕得熟,口议心思不及,方寸里七上八下,如咬生铁橛,没滋味时,切莫退志。
(注:提撕是指佛教徒参究古公案)
注意,这里“无”并非“有”的对立面:没有,而是踏杀了“有”、“没有”、“有且没有”、“有或没有”、“非有且没有”、“非有或没有”...,直到言亡虑绝才悟得的道理。
总结
命令式与函数式是编程语言的两大流派,它们拥有各自的优势与劣势。总体上说,命令式语言更加符合人们的思维习惯,人总是习惯以自我为中心观察世界的,以静态的、有差别的(一个个对象)方式认识周边事物。而函数式语言,在某种意义上说,有点违反人性,它无时不刻都以函数为中心,用括号串接各个表达式(称作剑桥波兰记法),其风格让大多数编程人员范晕。所以,单凭这一点,命令式语言占据现实应用的绝对地位并不奇怪。
事实上,真正纯净的FP语言并不多,函数式语言也往往增加命令式特征,象LISP,尽管被公认为函数式编程的鼻祖,但它还不是纯正的函数式编程,在LISP中可以用let指令赋值,其它的,如Scheme、ML等都不是纯正FP语言。
函数式语言除不够友好,运算效率低下外,他的优点非常鲜明,动态定义、无副作用、良好伸缩性、强大的演进能力等,都是命令式语言难以企及的。由于命令式与函数式各有优势,两者走向融合是近些年来编程技术的发展趋势,尤其随着Python、Ruby等动态语言逐步深入人心,FP编程渐渐流行开来。目前像Java、C#等新兴语言,已经融入了不少FP编程要素,借助反射等机制,命令式语言也拥有很强的动态编程特性。
参考资料:
- 《Programming Language Pragmatics》,美国,Michael L.Scott著,《程序设计语言----实践之路》,裘宗燕译,电子工业出版社
- 《皇帝新脑》牛津大学,罗杰.彭罗斯著,湖南科技技术出版社,许明贤、吴忠超译
- 《数字创世纪:人工生命的新科学》,李建会、张江编著,科学出版社
- Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I,John McCarthy, Massachusetts Institute of Technology,April 1960,
- Why Functional Programming Matters,John Hughes paper, dates from 1984
- Charming Python: Functional programming in Python, David Mertz, 01 Mar 2001