本文是本系列的完结篇。本系列前面的文章:html
下午,吃饱饭的老明和小皮,各拿着一杯刚买的咖啡回到会议室,开始了逻辑式编程语言的最后一课。git
老明喝了一口咖啡,说:“你看咖啡机,是否是咖啡的列表。”github
“啥?”小皮有点懵圈,“你说工厂的话还好理解,列表不太像。”编程
“每次点一下按钮,就至关于调用了一次next,出来一杯咖啡。而它自己并不包含咖啡,每一次都是现场磨豆冲出来的。这正是一个典型的惰性列表。”app
“有点道理,可是这跟逻辑式编程语言解释器有什么关系呢?”编程语言
“这就是下面要说的流计算模式,它是实现分支遍历的核心技巧。”函数
下面先讲流计算模式,而后再讲替换求解的实现与分支遍历的实现。学习
老明在白板上写下“Stream”,说:“Stream最多见的用途是用来表示数量未知或者无穷的列表。在代码中怎么定义流呢?咱们先来看看天然数,天然数是无穷的,那咱们怎么定义天然数列呢?”优化
“这很显然,不就是0、一、二、三、四、5等等吧。”this
老明鄙夷地看着小皮,说:“若是我是你的数学老师,那我确定要罚你站在墙角数完全部天然数……想一想数学概括法!”
“哦哦,哎!数学这些乌漆嘛黑的知识老是喜欢偷偷溜走。天然数的定义简单来讲(严谨的不会),由两部分组成:
“这样咱们根据第1部分,获得起点0;再根据第2部分,一直加1,依次获得一、二、三、四、5等天然数。”
“看来基础仍是不错的。”老明微笑着点点头,而后开始进入正文……
从天然数的定义,咱们能够获得启发,Stream的定义也是由两部分组成:
第2部分之因此是个函数,是为了得到惰性的效果,仅当须要时才计算剩余的Stream。
使用代码定义Stream以下:
public delegate Stream DelayedStream(); // Stream的定义,咱们只会用到替换的Stream,因此这里就不作泛型了。 public class Stream { // 第一个元素,类型为Substitution(替换) public Substitution Curr { get; set; } // 获取剩余Stream的方法 public DelayedStream GetRest { get; set; } private static Stream MakeStream(Substitution curr, DelayedStream getRest) { return new Stream() { Curr = curr, GetRest = getRest }; } ... }
其中Substitution
是替换类,后面会讲到这个类的实现。
还须要定义一个空Stream,除了表示空之外,还用来做为有限Stream的结尾。空Stream是一个特殊的单例。
正常来说,空Stream应该额外声明一个类型。这里偷了个懒。
private Stream() { } private static readonly Stream theEmptyStream = new Stream(); public bool IsEmpty() { return this == theEmptyStream; } public static Stream Empty() { return theEmptyStream; }
特别的,还须要一个构造单元素的Stream的方法:
public static Stream Unit(Substitution sub) { return MakeStream(sub, () => Empty()); }
只有这些平凡的构造方法还看不出Stream的用处,接下来结合前面讲过的NMiniKanren运行原理,探索如何使用Stream来实现替换的遍历。
回顾一下Any
的运行原理,Any
的每一个参数会各自返回一个Stream。这些Stream表明了各个参数包含的可能性。Any
操做把全部可能性放在一块儿,也就是把这些Stream拼在一块儿组成一个长长的Stream。
因此相应的,咱们须要把两个Stream s1
和s2
拼接成一个“长”Stream的Append方法。
如何构造这个“长”Stream呢?
首先,若是s1
是空Stream,那么拼接后的Stream显然就是s2
。
不然,按照Stream定义,分两个部分进行构造:
s1
的第一个元素;s1
的剩余Stream,拼上s2
,这里是个递归定义。按照上面分析的构造方法,咱们就能轻松地写下代码:
public Stream Append(DelayedStream f) { if (IsEmpty()) return f(); return MakeStream(Curr, () => GetRest().Append(f)); }
在这个实现中,f
是还没有计算的s2
。咱们须要尽可能推迟s2
第一个元素的计算,由于推迟着推迟着可能就没了不用算了。在不少场景中,这个能够节省没必要要的计算,甚至避免死循环(“这都是血泪教训。”老明捂脸)。
下面是一个Any
与Append
的例子:
Anyi
和Any
的区别只有顺序。Anyi
使用交替的顺序。
因此相应的,咱们须要一个方法,这个方法把两个Stream s1
和s2
中的元素交替地拼接组成一个“长”Stream。
首先,若是s1
是空Stream,那么“长”Stream显然就是s2
。
不然,分两部分构造:
s1
的第一个元素;s1
和s2
的位置调换了,剩余Stream是s2
交替拼上s1
的剩余Stream,一样是个递归定义。代码以下:
public Stream Interleave(DelayedStream f) { if (IsEmpty()) return f(); return MakeStream(Curr, () => f().Interleave(GetRest)); }
这里使用惰性的f
是很是必要的,由于咱们不但愿取剩余Stream的时候调用GetRest
。
这个方法比较复杂,是对应到All
运算中两两组合参数里的分支的过程。
不一样于Append
/Interleave
做用在两个Stream上,Bind
方法做用在一个Stream和一个Goal上。
为何不是两个Stream呢?
前面已经分析过了,k.All(g1, g2)
这个运算,是把g2
蕴含的条件,追加到g1
所包含的Stream中的每一个替换里。
同时,g2
是个函数。追加这个动做自己由g2
表达。
举例来讲,假设st
是g1
所包含的Stream中的一个替换。那么把g2
蕴含的条件追加到st
上,其结果为g2(st)
。
正是由于Bind
方法中须要有追加条件这个动做,因此Bind
方法的第二个参数只能是既包含了条件内容,也包含了追加方法的Goal类型。
用记号s1
表示g1
所包含的Stream,Bind
方法的做用就是把g2
蕴含的条件追加到s1
中的每一个替换里。
首先,若是s1
是个空Stream,那显然Bind
的结果是空Stream。
不然,结果是s1
的第一个元素追加g2
,再拼上s1
的剩余Stream Bind
g2
的结果。这还是递归定义,不过是借助的Append
方法进行Stream构造。
代码以下:
public Stream Bind(Goal g) { if (IsEmpty()) return Empty(); return g(Curr).Append(() => GetRest().Bind(g)); }
这个方法为何叫
Bind
,由于取名废只好抄《The Reasoned Schemer》里的命名……
下面是一个All
与Bind
的例子:
对应Alli
,交替版的Bind
方法。代码实现再也不多说,直接把Bind
实现中的Append
换成Interleave
便可:
public Stream Bindi(Goal g) { if (IsEmpty()) return Empty(); return g(Curr).Interleave(() => GetRest().Bindi(g)); }
更多Stream的玩法,参见《计算机程序的构造和解释》(简称《SICP》)第三章。
构造目标时会用到替换里的方法,因此和上一篇顺序相反,先讲替换求解。
替换的定义为:
public class Substitution { private readonly Substitution parent; public FreshVariable Var { get; } public object Val { get; } private Substitution(Substitution p, FreshVariable var, object val) { parent = p; Var = var; Val = val; } private static readonly Substitution theEmptySubstitution = new Substitution(null, null, null); public static Substitution Empty() { return theEmptySubstitution; } public bool IsEmpty() { return this == theEmptySubstitution; } public Substitution Extend(FreshVariable var, object val) { return new Substitution(this, var, val); } public bool Find(FreshVariable var, out object val) { if (IsEmpty()) { val = null; return false; } if (Var == var) { val = Val; return true; } return parent.Find(var, out val); } ... }
这是个单链表的结构。咱们须要能在替换中追根溯源地查找未知量的值的方法(也就是将条件代入到未知量):
public object Walk(object v) { if (v is KPair p) { return new KPair(Walk(p.Lhs), Walk(p.Rhs)); } if (v is FreshVariable var && Find(var, out var val)) { return Walk(val); } return v; }
例如在替换(x=1, q=(x y), y=x)
中,Walk(q)
返回(1 1)
。
注意替换结构里面,条件都是未知量 = 值
的形式。可是在NMiniKanren代码中并不是全部条件都是这种形式。因此在追加条件时,须要先将条件转化为未知量 = 值
的形式。
追加条件时,不是简单的使用Extend
方法,而是用Unify
方法。Unify
方法结合了Extend
和代入消元法。它先将已有条件代入到新条件中,而后再把代入后的新条件转化为未知量 = 值
的形式:
public Substitution Unify(object v1, object v2) { v1 = Walk(v1); // 使用已知条件代入到v1 v2 = Walk(v2); // 使用已知条件代入到v2 if (v1 is KPair p1 && v2 is KPair p2) { return Unify(p1.Lhs, p2.Lhs)?.Unify(p1.Rhs, p2.Rhs); } if (v1 is FreshVariable var1) { return Extend(var1, v2); } if (v2 is FreshVariable var2) { return Extend(var2, v1); } // 两边都是值。值相等的话替换不变;值不相等返回null表示矛盾。 if (v1 == null) { if (v2 == null) return this; } else { if (v1.Equals(v2)) return this; } return null; }
Unify
方法实现了代入消元的第一遍代入(详情见上一篇)。Unify
的全拼是unification,中文叫合一。
因为NMiniKanren的输出只有未知量q
,因此第二遍代入的过程只须要查找q
的值便可:
Walk(q)
经过Stream的分析,咱们知道,只要构造了目标,天然就实现了分支的遍历。
任何替换追加Success
,至关于没追加,因此k.Success
直接返回一个只包含上下文的Stream:
public Goal Succeed = sub => Stream.Unit(sub);
任何替换追加Fail
,那它这辈子就完了,k.Fail
直接返回空Stream
public Goal Fail => sub => Stream.Empty();
k.Eq(v1, v2)
向上下文追加v1 == v2
条件。
首先,使用Unify
方法将v1 == v2
条件扩展到上下文表明的替换。
若扩展后的替换出现矛盾,表示无解,返回空Stream。
不然返回只包含扩展后的替换的Stream。
代码以下:
public Goal Eq(object v1, object v2) { return sub => { var u = sub.Unify(v1, v2); if (u == null) { return Stream.Empty(); } return Stream.Unit(u); }; }
首先,利用Stream.Append
实现二目运算版本的Or
:
public Goal Or(Goal g1, Goal g2) { return sub => g1(sub).Append(() => g2(sub)); }
而后扩展到多参数:
public Goal Any(params Goal[] gs) { if (gs.Length == 0) return Fail; if (gs.Length == 1) return gs[0]; return Or(gs[0], Any(gs.Skip(1).ToArray())); }
同理实现Ori
和Anyi
:
public Goal Ori(Goal g1, Goal g2) { return sub => g1(sub).Interleave(() => g2(sub)); } public Goal Anyi(params Goal[] gs) { if (gs.Length == 0) return Fail; if (gs.Length == 1) return gs[0]; return Ori(gs[0], Anyi(gs.Skip(1).ToArray())); }
利用Stream.Bind
实现二目版本的And
:
public Goal And(Goal g1, Goal g2) { return sub => g1(sub).Bind(g2); }
而后扩展到多参数:
public Goal All(params Goal[] gs) { if (gs.Length == 0) return Succeed; if (gs.Length == 1) return gs[0]; return And(gs[0], All(gs.Skip(1).ToArray())); }
同理实现Andi
和Alli
:
public Goal Andi(Goal g1, Goal g2) { return sub => g1(sub).Bindi(g2); } public Goal Alli(params Goal[] gs) { if (gs.Length == 0) return Succeed; if (gs.Length == 1) return gs[0]; return Andi(gs[0], All(gs.Skip(1).ToArray())); }
public static IList<object> Run(int? n, Func<KRunner, FreshVariable, Goal> body) { var k = new KRunner(); // 定义待求解的未知量q var q = k.Fresh(); // 执行body,获得最终目标g var g = body(k, q); // 初始上下文是一个空替换,应用到g,获得包含可行替换的Stream s var s = g(Substitution.Empty()); // 从s中取出前n个(n==null则取全部)替换,查找各个替换下q的解,并给自由变量换个好看的符号。 return s.MapAndTake(n, sub => Renumber(sub.Walk(q))); }
其中,MapAndTake
方法取Stream的前n个(或全部)值,并map每个值。
Renumber
将自由变量替换成_0
、_1
……这类符号。
NMiniKanren的完整代码在这里:https://github.com/sKabYY/NMiniKanren
总结一下NMiniKanren的原理:
另外NMiniKanren毕竟只是一门教学级的语言,实用上确定一塌糊涂,说难听点也就是个玩具。咱们学习的重点不在于NMiniKanren,而在于实现NMiniKanren的过程当中所用到的技术和思想。掌握了这些方法后,能够根据本身的须要进行优化或扩展,或者将这些方法应用到其余问题上。
“神奇!”小皮瞪着眼睛摸摸脑壳,之前以为宛若天书般的逻辑式编程语言就这么学完了,还包括了解释器的实现。
“认真学习了一天半的效果仍是不错了。嘿嘿。”老明欣慰地拍了拍小皮的肩膀,微微笑道,“世上无难事,只怕有心人。刚好今天周五了,周末就来公司把这两天落下的工做补完吧。”
小皮:“???”
PS:最后,用《The Reasoned Schemer》里的两页实现镇楼。俗话说得好,C#只是恰饭,真正的快乐还得看Scheme/Lisp。