我还是特别庆幸我掌握了Mathematica。回过头来看这半年写的程序,很大程度上已经不仅仅是纯的函数式编程了,由于模式匹配特别好使,所以使用了大量的规则代换。当一开始看到Prolog程序时倍感亲切,但后来越看越不对。我一开始认为规则代换编程(或者叫逻辑编程)是从函数式编程中衍生出来的,现在证实这完全是个错误。事实情况是两者在地位上没有从属关系,但却可以相互表示对方。
然而我更庆幸的是,我认真学过谓词演算。原因是高二信息奥赛有一道类似“A说B说谎B说C说谎C说我是无辜的D说A没说谎然后问谁说谎了”的题目没有做出来,心里留下了阴影,后来得知谓词演算就是用来解决这问题的,于是大二花整整一暑假仔细研读了一遍。
逻辑编程中不再有函数和操作对象的概念了,它组成的元素是“事实”和“规则”。其中“事实”是一个谓词短语,譬如Predicate1(arg1, arg2)表示某个动作被施加在arg1和arg2上。当然这种说法是相当不确切的,Predicate1可以是一个有实义的动作也可以不是,同样arg1和arg2可以是有实义的对象也可以不是。更一般地来说,谓词短语就是用一个叫做谓词的记号(字符串表示),将谓词作用域(圆括号内)中的若干(用逗号分隔)记号(同样字符串表示)建立起了一种抽象的关联。如果希望想的更形象一点,就可以认为arg1和arg2是图中的两个节点,一个谓词短语代表了节点之间的一个路径。需要注意的是,由于谓词短语的作用域是一个有序表,因此这个图显然是个有向图。
===============================================================
对于逻辑编程,谓词不一定需要产生动作,这是和其他语言中的函数最大的区别。因此它不需要定义类型,不需要和CPU指令相关联,而仅仅是被看做一个“事实”记录在知识库中。这一点Mathematica和它十分类似。比如
Predicate1(arg1, arg2).
就是一个事实。更确切地说,它告诉Prolog这是一个永真式,等价于
Predicate1(arg1, arg2) is always true.
“规则”在Prolog中有很多。第一个规则是蕴含。比如
Predicate1(arg1, arg2):- Predicate2(arg1),Predicate2(arg2)
不包含在括号中的逗号一律读作“且”。则这句话读作
“如果Predicate2(arg1)为真且Predicate2(arg2)为真,则Predicate1(arg1, arg2)为真”,或
“Predicate2(arg1)和Predicate2(arg2)均为真,蕴含Predicate1(arg1, arg2)为真”。
第二个规则是等价。如果已知Predicate1(arg1, arg2),那么输入
Predicate1(X, Y)
就会得到X=arg1, Y=arg2。
如果以Predicate1为谓词的事实还有很多,那么Prolog就会列出X,Y同时为真的所有组合。
此外还有模式匹配,和Mathematica的几乎一样,不废话了。Prolog的语言特性还没有看完,但是比起Haskell什么的要简单多了。Prolog的特点是,程序很难出错(就是执行不下去),最后也肯定能得到一个结果。但是如果不小心设计,那么这个结果通常不是你想要的那个。
=======================================================================
另外一个非常重要的内容就是规则的递归。规则的递归和函数的递归有很大差异。函数的递归最终可以写成一个高阶函数的嵌套,内层函数求值得到结果返回外层函数,外层函数求值将结果返回更外层,依此类推直到调用函数的最底层为止。函数递归要求存在一个递归终点,这个终点就是已知函数值的函数表达式。
而规则的递归,则是通过递归的蕴含式创造出同一个谓词的新的谓词短语,同时将新旧谓词作用域内的项建立等价关系。规则递归同样需要有一个递归终点,这个终点就是一个已知的事实。既然这个事实为真,则可将蕴含这个永真式的谓词短语(可以方便地理解为“外层调用它的函数”)作用域项依照等价关系进行代替,直到所有项的等价关系都已经建立,就能够确保这个谓词短语为真,那么这个谓词短语里面要求的那一项的结果肯定也是正确的。
举个例子,在prolog中同样有List数据结构,比如[1,2,3,4,5](和Python一样)。
首先要提到这么一种用法:[X|Y],如果[X|Y]=[1,2,3,4,5],那么X=1,Y=[2,3,4,5],和car/cdr是一个性质。[如果表中只有一个元素,那么X返回那个元素,而Y返回一个空表,这一点很重要。]
在Prolog上实现的列表追加是这样子的:
append(List_For_Appending, Original_List, New_List)
就是把头一个加到第二个上,然后得到第三个。
定义是这样的:
append([],List,List).
这是递归终点,也是一个事实,指的是往列表上加一个空列表还是它自己。需要注意的是,尽管prolog中没有数据类型,但是出现两个同名的项则意味着它们不论是什么,都指的是同一个东西。同名等价,好像叫做“自反律”吧。
下面这个比较折腾:
append([First|Rest],Original_List,[First|NewRest]):-append(Rest,Original_List,NewRest)
如果输入
append(List_For_Appending, Original_List, New_List),那么
则有
New_List
=[First(List_For_Appending)|Rest(New_List)]
=[First(Rest(List_For_Appending))|Rest(Rest(New_List))]
=[First(Rest(First(Rest(List_For_Appending))))|Rest(Rest(Rest(New_List)))]
直到当append的第一项是[]的时候,则不在继续生成新的蕴含式,而应用上面的规则,建立如下等价关系:
Rest(...(Rest(New_List))...)=Original_List
然后一步步逆推,得到
Rest(New_List)
最后得到New_List
=======================================================================
这是一个不算复杂的例子,更牛逼的例子其实大家都知道。我记得高中二年级看严蔚敏的数据结构(还是Pascal版)的时候,那个汉诺塔的递归程序怎么也看不明白,后来才明白,那个时候我无论如何也不会明白。用Prolog写出来的程序其实很像:
move(1,X,Y,_) :-
write('Move top disk from '),
write(X),
write(' to '),
write(Y),
nl.
move(N,X,Y,Z) :-
N>1,
M is N-1,
move(M,X,Z,Y),
move(1,X,Y,_),
move(M,Z,Y,X).
move谓词中作用域的项应该这么解释:
move(自顶向下数第n个盘子,当前所在的柱子,移动目标的柱子,途中经过的柱子)
对于三个盘子的情形,可以解释为
move(3,left,right,center)这个事实为真,只有在
2) move(2,left,center,right)为真,且
1) move(1,left,right,center)为真,且
3) move(2,center,right,left)为真的情况下
1)好说,只要几个字符串都输出完成就为真了。但是2)和3)没有可供匹配的事实,所以只能进一步展开蕴含式
2)
move(2,left,center,right)这个事实为真,只有在
move(1,left,right,center)为真,且
move(1,left,center,right)为真,且
move(1,right,center,left)为真的情况下
3)
move(2,center,right,left)这个事实为真,只有在
move(1,center,left,right)为真,且
move(1,center,right,left)为真,且
move(1,left,right,center)为真的情况下
于是,最终的结果,在屏幕上显示的就是
Move top disk from left to right
Move top disk from left to center
Move top disk from right to center
哥加的分割线------------------------------------
Move top disk from left to right
哥加的分割线------------------------------------
Move top disk from center to left
Move top disk from center to right
Move top disk from left to right
当时在书上用Pascal写出来的的牛逼之处在于它并没有使用函数(Function),而是过程(Procedure)。原因是它并不需要返回值,而只是来检测第一个参数是不是1就可以了。这就是谓词的力量。函数用来表示“在一个对象上施加某种操作,会得到另外某种特定的对象”。而表达相同意思的谓词则是用来表示“‘在一个对象上施加某种操作,会得到另外某种特定的对象’这个命题是真的”。
所以不妨这么理解,谓词短语是个隐函数。
或者说,函数式编程还可以分为“显式函数编程”和“隐式函数编程”?
======================
======================
今天开始看Prolog的Built-in,看到求列表的谓词length是这样写的:
length(List,len)
将List换成一个实际的列表,比如[1,2,3],那么得到的结果是
len=3
当时我大惊,以为逻辑编程竟然和过程式编程一样,需要将值储存在一个变量里。但后来想想还是概念有误。首先,length(X,Y)是个谓词短语而不是函数表达式,它的值只有真假。其次,3是个原子而并非一个事实或者断言,所以它没有真假。Prolog的工作方式不是直接将length同3相关联,而是同length作用域中的某一个对象相关联,推导出一个断言,能够满足length谓词短语的值为真。
这使我想起了当年看那个Pascal汉诺塔程序时,程序使用的是Procedure而非Function。原因在于,这个程序所看重的并不是函数返回的结果,而是这个函数的“边际效应”,也就是在屏幕上输出的结果。所谓“边际效应”指得就是函数中的一个额外的动作,它并没有改变当前函数参数的状态(但是却改变了某个对象的状态)。因此函数只进行了一个“边际效应”的动作而并没有产生结果。回顾一下《算法导论》中一开始就遇到的“递归树”,如果按照函数值传递,那么递归的过程就是不断拓展这个递归树,直到递归树中某个结点的值是已知的,然后在沿递归树向根节点的方向逐步更新每层函数的值,直到根节点的值也更新为止。形式看起来像这样:
RecursionFunction(arg1, arg2,...)=Blah(RecursionFunction(arg1, arg2,...))
但是此处并不是这样,由于结果并非函数值而是函数的一个参数,那么当拓展递归树的新节点时那个参数就也被带入到下一层的递归调用中了。所以当到达递归终点时,那个作为结果的参数就已经同递归终点的已知的值建立了显式的关系,也就是说结果已经知道了,所以不会再有回溯的过程了。它的形式看起来像这样:
RecursionFunction(arg1, arg2,...)=RecursionFunction(Blah(arg1, arg2,...))
这就是线性递归和尾递归的区别,可以看到关于这两个的讨论还是很多的。具体内容在这里讲得很详细,
http://topic.csdn.net/u/20071023/08/87c95c73-6022-4b39-8aae-b2fb38fb0c38.html
http://www.cnblogs.com/JeffreyZhao/archive/2009/03/26/tail-recursion-and-continuation.html
需要注意的一点是,尾递归是实参的传递,因此尾递归改变了状态,因此应用尾递归的函数是不具备引用透明性的,因此应用尾递归的函数不是函数式编程范畴内的函数。
【这话说的有问题,但是我还没想好更合适的说法】
【果然说的很有问题,引用透明性所指的“状态依赖”指的是依赖最底层函数(也就是调用入口)的状态,按照这个说法尾递归没有改变状态,仍然具有引用透明性(前提是“边际效应”的动作和参数无关),刚才特地查了SICP。这就意味着,除了像Prolog进行模式匹配可能还比较费劲以外,剩下的Lisp都做得到】
==========================================================
上一篇日志结束时,提到了Prolog像是隐函数式编程。现在要具体讨论一下隐函数。比如y=Function(x)是一个显式函数。如果写成y-Function(x)=0就是一个隐函数。我们现在关注的是其中的一个谓词,也就是那个等号。按照Prolog的写法(或者是通用的谓词演算的记法),这个隐函数就应该被写为
=(y, Function(x))
告诉Prolog这个断言为真,那么
?-=(y,Function(x)+1)
得到的结果就会是no。
需要注意的是,Prolog永远只能给出一个它认为值为true的等价关系,而无法给出位列等价关系中的任何一方。上面这个谓词短语将函数表达式和函数值联系起来,但是在Prolog中却无法独立存在。这是因为在Prolog中除了built-in的原子,剩下一切信息都是以谓词短语的形式存在的。换句话说,用户定义的信息必将是连接某些原子的一个通路【好像越说越不明白了】。这就意味着,在【显式】函数式编程中彼此分立的函数表达式和函数值,在Prolog中却被绑在了同一个谓词短语之中。
进一步地来说,为什么像Haskell这样的函数式编程函数表达式和函数值能彼此分立呢?这是因为Lambda演算中的替换是无条件的,它高于其他一切算符,所以才会有apply和eval这种替换和规约的规则。但是在Prolog中,等号只是体现等价关系的谓词而已,它表示两者存在可以相互替代的【关系】,但并不表示替代的这个【行为】的发生。可以说函数编程和逻辑编程的区别,就是Lambda函数的存在了。如果替换的行为不会发生,便意味着Prolog中没有显式函数的概念。这样Prolog完全不具备引用透明性,而只能依赖由谓词短语的真值来推断等价关系。在函数式编程的范畴里来说,Prolog产生的一切动作都是边际效应。这比一般的指令式编程还极端,指令式编程还能构造出具有引用透明型的函数呢。
事实上类似地能够得到很多结论,并且结论能够彼此互证,就好像热力学第二定律的不同表述一样。但是为了首尾呼应,我只陈述一句话:
Prolog的递归只能是尾递归!
没有评论:
发表评论