本文是本系列的完結篇,本系列前面的文章:
- 邏輯式編程語言極簡實作(使用C#) - 1. 邏輯式編程語言介紹
- 邏輯式編程語言極簡實作(使用C#) - 2. 一道邏輯題:誰是兇手
- 邏輯式編程語言極簡實作(使用C#) - 3. 運行原理
下午,吃飽飯的老明和小皮,各拿著一杯剛買的咖啡回到會議室,開始了邏輯式編程語言的最后一課,
老明喝了一口咖啡,說:“你看咖啡機,是不是咖啡的串列,”
“啥?”小皮有點懵圈,“你說工廠的話還好理解,串列不太像,”
“每次點一下按鈕,就相當于呼叫了一次next,出來一杯咖啡,而它本身并不包含咖啡,每一次都是現場磨豆沖出來的,這正是一個典型的惰性串列,”
“有點道理,但是這跟邏輯式編程語言解釋器有什么關系呢?”
“這就是下面要說的流計算模式,它是實作分支遍歷的核心技巧,”
下面先講流計算模式,然后再講替換求解的實作與分支遍歷的實作,
流(Stream)計算模式
老明在白板上寫下“Stream”,說:“Stream最常見的用途是用來表示數量未知或者無窮的串列,在代碼中怎么定義流呢?我們先來看看自然數,自然數是無窮的,那我們怎么定義自然數列呢?”
“這很顯然,不就是0、1、2、3、4、5等等吧,”
老明鄙夷地看著小皮,說:“如果我是你的數學老師,那我肯定要罰你站在墻角數完所有自然數……想想數學歸納法!”
“哦哦,哎!數學這些烏漆嘛黑的知識總是喜歡偷偷溜走,自然數的定義簡單來說(嚴謹的不會),由兩部分組成:
- (起點部分)0是自然數;
- (遞回部分)任意自然數加1也是自然數,
“這樣我們根據第1部分,得到起點0;再根據第2部分,一直加1,依次得到1、2、3、4、5等自然數,”
“看來基礎還是不錯的,”老明微笑著點點頭,然后開始進入正文……
從自然數的定義,我們可以得到啟發,Stream的定義也是由兩部分組成:
- 起點:第一個元素(非空流);
- 遞回:一個無參函式,呼叫它會回傳這個Stream去掉第一個元素后剩下部分組成的剩余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來實作替換的遍歷,
Append方法
回顧一下Any的運行原理,Any的每個引數會各自回傳一個Stream,這些Stream代表了各個引數包含的可能性,Any操作把所有可能性放在一起,也就是把這些Stream拼在一起組成一個長長的Stream,

所以相應的,我們需要把兩個Stream s1和s2拼接成一個“長”Stream的Append方法,
如何構造這個“長”Stream呢?
首先,如果s1是空Stream,那么拼接后的Stream顯然就是s2,
否則,按照Stream定義,分兩個部分進行構造:
- 第一個元素,顯然就是
s1的第一個元素; - 剩余Stream,就是
s1的剩余Stream,拼上s2,這里是個遞回定義,
按照上面分析的構造方法,我們就能輕松地寫下代碼:
public Stream Append(DelayedStream f)
{
if (IsEmpty()) return f();
return MakeStream(Curr, () => GetRest().Append(f));
}
在這個實作中,f是尚未計算的s2,我們需要盡量推遲s2第一個元素的計算,因為推遲著推遲著可能就沒了不用算了,在很多場景中,這個可以節省不必要的計算,甚至避免死回圈(“這都是血淚教訓,”老明捂臉),
下面是一個Any與Append的例子:

Interleave方法
Anyi和Any的區別只有順序,Anyi使用交替的順序,
所以相應的,我們需要一個方法,這個方法把兩個Stream s1和s2中的元素交替地拼接組成一個“長”Stream,

首先,如果s1是空Stream,那么“長”Stream顯然就是s2,
否則,分兩部分構造:
- 第一個元素是
s1的第一個元素; - 這里和Append方法的區別是把
s1和s2的位置調換了,剩余Stream是s2交替拼上s1的剩余Stream,同樣是個遞回定義,
代碼如下:
public Stream Interleave(DelayedStream f)
{
if (IsEmpty()) return f();
return MakeStream(Curr, () => f().Interleave(GetRest));
}
這里使用惰性的f是非常必要的,因為我們不希望取剩余Stream的時候呼叫GetRest,
Bind方法
這個方法比較復雜,是對應到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的例子:

Bindi方法
對應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與Fail
任何替換追加Success,相當于沒追加,所以k.Success直接回傳一個只包含背景關系的Stream:
public Goal Succeed = sub => Stream.Unit(sub);
任何替換追加Fail,那它這輩子就完了,k.Fail直接回傳空Stream
public Goal Fail => sub => Stream.Empty();
Eq
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);
};
}
Any/Anyi
首先,利用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()));
}
All/Alli
利用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代碼描述的是一個Goal,Goal是一個替換到Stream的函式,
- 從NMiniKanren代碼可以構建一張描述了條件關系的圖,每條路徑對應一個替換,使用流計算模式可以很巧妙地實作對所有路徑的遍歷,
- 使用代入消元法求解未知量,
另外NMiniKanren畢竟只是一門教學級的語言,實用上肯定一塌糊涂,說難聽點也就是個玩具,我們學習的重點不在于NMiniKanren,而在于實作NMiniKanren的程序中所用到的技術和思想,掌握了這些方法后,可以根據自己的需要進行優化或擴展,或者將這些方法應用到其他問題上,
“神奇!”小皮瞪著眼睛摸摸腦袋,以前覺得宛若天書般的邏輯式編程語言就這么學完了,還包括了解釋器的實作,
“認真學習了一天半的效果還是不錯了,嘿嘿,”老明欣慰地拍了拍小皮的肩膀,微微笑道,“世上無難事,只怕有心人,恰好今天周五了,周末就來公司把這兩天落下的作業補完吧,”
小皮:“???”
PS:最后,用《The Reasoned Schemer》里的兩頁實作鎮樓,俗話說得好,C#只是恰飯,真正的快樂還得看Scheme/Lisp,


轉載請註明出處,本文鏈接:https://www.uj5u.com/net/6414.html
標籤:C#
