在上篇文章中,我们介绍了Lisa的逻辑模块。 那么,Lisa的logical模块是如何实现的呢?首先,不是用Lisa实现的,而是用Scala。因为双重解释,效率感人。本篇文章我们将用Scala来实现一个简单的逻辑编程语言。
逻辑式编程本质是搜索,是用NP的复杂度来自动化解决简单逻辑问题。Prolog使用了回溯搜索算法1,就是在程序运行期间,当某一个子目标不能满足(即谓词匹配失败)时,控制就返回到前一个已经满足的子目标(如果存在的话),并撤销其有关变量的约束值,然后再使其重新满足。成功后,再继续满足原来的子目标。如果失败的子目标前再无子目标,则控制就返回到该子目标的上一级目标(即该子目标谓词所在规则的头部)使它重新匹配。
但是,Lisa不使用回溯算法,其实现是通过惰性流,在Scala2.13中是LazyList
来实现的2。惰性流在Scala中已经实现了,因此我们可以直接使用。如果你想自己实现,可以参见SICP第三章。
Facts
逻辑式编程的基本元素是事实,但是,什么是事实呢?什么是查询呢?我们可以把事实的集合看作一个数据库,所谓查询就是在数据库中查找匹配的数据。在关系型数据库中,一条数据就是一个元组。而Lisp系的语言中,元组可以看作列表。那么这个简单的数据库就可以看作一个列表的列表。这就构成了一个简单的schema-free数据库。
查找匹配的数据,和模式匹配十分相似,事实上,查询数据就是在数据库中filter出匹配的数据。因此,我们就发现了核心:模式匹配。
但是这个模式匹配和熟知的不大一样,因为他要满足同一个符号代表相同的元素。比如,(x x)
若是要匹配 ((1 y 3) (z 2 3))
,我们可以得出结论:
x = (1 2 3), y = 2, z = 1
。但是我们熟悉的模式匹配无法做到这一点。但是整体上的算法是相似的,我们称之为归一化匹配(unify matching)。
Unify Matching
我们得知了归一化匹配是核心了之后,我们就要开始动手写匹配算法了。其实该算法的目标很简单,我们已知一些约束条件,检查第一个模式是否匹配第二个模式。
type MatchConstraint = Map[String, Any]
def unifyMatch(pattern1: Any, pattern2:Any, givenConstraints: MatchConstraint): Option[MatchConstraint] = {
// 为了方便期间,使用了本地可变状态。
val constraints = collection.mutable.Map.from(givenConstraints)
// 表示匹配成功后该返回的结果
def succeed = Some(constraints.toMap)
// 检测当前的表达式是不是依赖于该符号,这是简单的树遍历。
def dependsOn(name: String, exp: Any): Boolean = exp match {
case Symbol(`name`) => true
case Symbol(symbol) => constraints.get(symbol).exists(dependsOn(name, _))
case list: List[_] => list.exists(dependsOn(name, _))
case _ => false
}
def extend(name: String, exp: Any): Option[MatchConstraint] = {
// 忽略匹配
if(name == "_") succeed
else constraints.get(name) match {
case Some(x) => matchOne(exp, x) // 如果已经有该变量的限制了,则限制的值应当于当前值归一匹配。
case _ => exp match {
// 如果当前要扩展的值是一个变量
case Symbol(symbol) => constraints.get(symbol) match {
case Some(x) => matchOne(Symbol(name), x) // 环境中有关于该变量的限制,则同上。
case _ =>
constraints.update(name, exp) // 否则就新增一个限制
succeed
}
case _ =>
if (dependsOn(name, exp)) None // 如果当前要扩展的表达式依赖于自身,我们无法求解一个不动点方程,故放弃。
else {
constraints.update(name, exp)
succeed
}
}
}
}
// 将一个表达式和另一个表达式做归一匹配
def matchOne(pattern1: Any, pattern2: Any): Option[MatchConstraint] = {
if(pattern1 == pattern2) succeed // 如果两者相等,yataze!
else pattern1 match {
case Symbol(name) => extend(name, pattern2) // 如果是一个变量,则扩展该变量的限制。
case _ => pattern2 match {
case Symbol(name) => extend(name, pattern1)
case list1: List[_] => pattern2 match {
case list2: List[_] => matchMany(list1, list2) // 如果要匹配的是俩列表,则匹配两个列表。
case _ => None
}
case _ => None
}
}
}
// 匹配两个列表
def matchMany(pattern1: List[Any], pattern2: List[Any]): Option[MatchConstraint] = {
pattern1 match {
case Nil => if (pattern2.isEmpty) succeed else None // 如果其中一个空了,另一个也得是空的
case (Symbol("...") :: (sym@Symbol(_)) :: Nil) :: xs => // 匹配任意长度的变量 这里是(... x),你可以定义自己的任意长度匹配的表现形式。
if (xs.isEmpty) matchOne(sym, pattern2)
else None
case otherwise :: xs => pattern2 match {
case (Symbol("...") :: (sym@Symbol(_)) :: Nil) :: xs => // 对pattern做相同对任意长参数检查
if (xs.isEmpty) matchOne(pattern1, sym)
else None
case y :: ys => for {
_ <- matchOne(otherwise, y)
tailMatch <- matchMany(xs, ys)
} yield tailMatch // 否则就先匹配头,再匹配剩下的
case _ => None // 第二个是空,第一个却不是空,这不可能匹配成功。
}
}
}
matchOne(pattern1, pattern2) // 最后启动匹配
}
这里我们可以看见unifyMatch由三个子过程组成,这三个自过程又是互相递归的,就像eval-apply循环一样。三个自过程分别处理了不同的情况。
我们需要特别注意:如果要匹配 (x x)
((1 (... y)) y)
,我们可以观察得出,x和y都必须是无限的1
序列。但是在匹配过程中,我们会遇到匹配(1 ...y)
和y
的时候。这个时候我们发现(1 ...y)
依赖了y
,为了解决这个问题,我必须求解一个不动点方程,但是现在没有什么有效的方法求解,因此这里我们就直接放弃。
Simple Queries
简单查询就是根据输入查询一系列事实。在Lisa/Logical中,一个查询的处理器接受一个当前事实的流和存储事实的上下文,返回一个处理后的事实的流,如下图所示(使用了SICP的图):
在Lisa中,表达约束的frame和Lisa的环境模型相同。在此处可以简化为Map[String, Any]
所以一个简单查询的处理过程就是对于流中的每一个约束,以及上下文中的每一个事实,进行对当前所处理的查询和上下文中的事实在约束条件下的归一化匹配。
在处理简单查询的过程中,我们发现了一个查询处理器该有的类型,我们如果称该处理器为Matcher
,则其应当具有以下类型:
type ConstraintType = Map[String, Any]
type MatcherFunction = (LazyList[ConstraintType], LogicalContext) => LazyList[ConstraintType]
trait Matcher extends MatcherFunction
Compound Queries
这种继承一个函数的类型使我想到了在写NaiveJSON
时Pasers的代数数据类型的设计,而代数数据类型的可组合性是大家有目共睹的。我们这么设计Matcher的好处就是,Matcher因此是可以组合的。
and
在进行与操作的时候,我们要求所有的Matcher
都必须匹配;观察到不匹配的情况会被直接丢弃,不会通过那个Matcher,因此我们容易想到处理与的时候,只要将流通过所有的Matcher即可,就像串联一样,如下图:
故and
的代码就呼之欲出了(Matcher的成员方法,下同):
def and(that: Matcher): Matcher = (in, lc) => {
val thisOut = this(in, lc)
that(thisOut, lc)
}
or
or
是有一个匹配即可,这种情况我们可以直接将两个matcher并联:
def or(that: Matcher): Matcher = (in, lc) => {
val thisOut = this(in, lc)
thisOut #::: that(in, lc)
}
not
not
是原本匹配的变为不匹配,他就像一个非门,具体的实现像一个过滤器。因此这里的not
和逻辑中的非有所不同,这里的not指的是无法从给定的上下文中推断出来。
def not: Matcher = (in, lc) => {
in.filter(frame => this(LazyList(frame), lc).isEmpty)
}
Rules
规则作为抽象的方法,是十分重要的一环。定义一个规则就如定义一个函数,不同的是,这里不要求具有闭包性,反而所有出现的自由变量应当被视为不受任何约束,这是与函数式语言不同的地方。
同名规则可以有不同的规则体,没有规则体的规则视为永远成立。因此在执行一个规则的时候,我们可以视为仅在参数所引入的约束下对规则体做匹配。当然,有一种更加暴力的方法,就是和SICP中介绍的一样,把规则中的所有变量换个名字。自然这也是一个不错的办法,其缺点是最后结果会出现期望以外的约束变量。同时,我们的目标和SICP中的不一样,SICP的目标是将查询体用约束变量实例化,而Lisa/Logical的目标和prolog相似,是返回自由变量的取值,故不能出现多余的变量。
Match a Rule
匹配规则参数和归一化匹配有些许的区别。首先,如果形参中和实参中如果有相同名称的变量,不能视为同一个。另外,如果有规则 (f :a b)
,当其匹配(f x y)
的时候,应当匹配,而且在结果中应当出现x -> :a
的映射。因此在匹配的过程中,我们应当注意参数带来的双向约束。
匹配过程和归一化匹配大致相同,不一样的地方在于来自规则形参约束和来自调用实参的约束应当分别存放。
Apply a Rule
执行一个规则的过程之前已经讲过了,在找到匹配的规则后,根据其形参确定已有的约束和可能被引入的约束。
之后,仅在参数所引入的约束下对规则体做查询,对获取的结果中要被引入的约束,比如上面的y
就会在执行完成后获得规则体执行完成后b
具有的值,而x
就会变成:a
。
Conclusion
这就是实现一个逻辑式编程语言必须有的要素。逻辑式语言具有一些局限性,例如,所有的逻辑式语言遵循封闭世界假设,即无法从给予事实中推断出来的都是假的。还有就是not
和众人理解的“非”不大一样,not只是一个过滤器,他不能凭空产生数据。还有就是执行复杂度过高,对数值计算有很蛋疼,导致没有多少人使用他。但是作为一个全新的编程范式,可以让我们了解原来程序还能这么写,竟然能比函数式编程更加声明式。
如果你想看具体的实现,可以进Lisa-lang中看Logical
模块的实现。所有和逻辑有关的内容都在这个模块里,没有分散到外面。
为什么逻辑式编程模块不在Lisa的iteration plan里呢?因为Iteration Plan是逗你玩的
r{{ changeExtraDisplaySettings({blurMainImage: true}) }} r{{ switchToColorMode(true) }}
-
Structure and Interpretation of Computer Programs Chapter 4.4 Logic Programming ↩