相信不少朋友對於邏輯式編程語言,都有一種最熟悉的陌生人的感受。一方面,平時在書籍、在資訊網站,偶爾能看到一些吹噓邏輯式編程的話語。但另外一方面,也沒見過周圍有人真正用到它(除了SQL)。git
遙記當時看《The Reasoned Schemer》(一本講邏輯式編程語言的小人書),被最後兩頁的解釋器實現驚豔到了。看似如此複雜的計算邏輯,其實現居然這麼簡潔。不過礙於當時水平有限,也就囫圇吞棗般看了過去。後來有一天,不知何故腦子靈光一閃,把圖遍歷和流計算模式聯繫在一塊兒,瞬間明白了《The Reasoned Schemer》中的作法。動手寫了寫代碼,果真如此,短短兩百來行代碼,就完成了解釋器的實現,才發現原來如此簡單。不少時候,並不是問題自己有多難,只是沒有想到正確的方法。程序員
本系列將盡量簡潔地說明邏輯式編程語音的原理,並實現一門簡單的邏輯式編程語言。考慮到C#的用戶較多,所以選擇用C#來實現。實現的這門語言就叫NMiniKanren。文章整體內容以下:github
故事從兩個正在吃午飯的程序員提及。算法
老明和小皮是就任於同一家傳統企業的程序員。這天,兩人吃着午飯。老明邊吃邊刷着抖音,鼻孔時不時噴出幾條米粉。編程
小皮是一臉麻木地刷着求職網和資訊網,突然幾個大字映入眼底:《新型邏輯式編程語言重磅出世,即將顛覆IT界!》小皮一陣好奇,往下一翻,結果接着的是一些難懂的話,什麼「一階邏輯」,什麼「合一算法」,以及鬼畫符似的公式之類。數組
小皮看得索然無味,但被勾引發來的對邏輯式編程的興趣彷彿澳洲森林大火同樣難以平息。因而伸手拍下老明高舉手機的左手,問道:「嘿!邏輯式編程有了解過麼?是個啥玩意兒?」數據結構
「邏輯式編程啊……嘿嘿,前段時間恰好稍微瞭解了一下。」老明鼻孔朝天吸了兩口氣,「我說的稍微瞭解,是指實現了一門邏輯式編程語言。」數據結構和算法
「不愧是資深老IT,瞭解也比別人深刻一坨坨……」編程語言
「也就比你早來一年好很差……我是一邊看一本奇書一邊作的。Dan老師(Dan Friedman)寫的《The Reasoned Schemer》。這本書挺值得一看的,書中使用一門教學用的邏輯式編程語言,講解這門語言的特性、用法、以及原理。最後還給出了這門語言的實現。核心代碼只用了兩頁紙。函數
「所謂邏輯式編程,從使用上看是把聲明式編程發揮到極致的一種編程範式。普通的編程語言,大部分仍是基於命令式編程,須要你告訴機器每一步執行什麼指令。而邏輯式編程的理念是,咱們只須要告訴機器咱們須要的目標,機器會根據這個目標自動探索執行過程。
「邏輯式編程的特色是能夠反向運行。你能夠像作數學題同樣,聲明未知量,列出方程,而後程序會爲你求解未知量。」
「挺神奇的。聽起來有點像AI編程。不過這麼高級的東西怎麼沒有流行起來?感受能夠節省很多人力。」小皮突然有種飯碗即將不保的感受。
「嘿嘿……想得美。其實邏輯式編程,既不智能,也很差用。你回憶一下你中學的時候是怎麼解方程組的?」
「嗯……先盯一會方程組,看看它長得像不像有快捷解法的樣子。看不出來的話就用代入法慢慢算。這和邏輯式編程有什麼關係?」
「邏輯式編程並不智能,它只是把某種相似代入法的通用算法內置到解釋器裏。邏輯式編程語言寫的程序運行時,不過是根據通用算法進行求解而已。它不像人同樣會去尋找更快捷的方法,同時也不能解決超綱問題。
「並且邏輯式編程語言的學習成本也不低。若是你要用好這門語言,你得把它使用的通用算法搞清楚。雖然你寫的聲明式的代碼,但心裏要時刻清楚程序的執行過程。若是你拿它當個黑盒來用,那極可能你寫出來的程序的執行效率會很是低,甚至跑出一些莫名其妙的結果。」
「哦哦,要學會用它,還得先懂得怎麼實現它。這學習成本還挺高的。」小皮跟着吐槽,不過他知道老明代表上看似嫌棄邏輯式編程的實用性,私底下確定玩得不亦樂乎,而且也喜歡跟別人分享。因而小皮接着道:「雖然應該是用不着,但感受挺有意思的,再仔細講講唄。每天寫CRUD,腦子都淡出個鳥了。」
果真老明坐直起來:「《The Reasoned Schemer》用的這門邏輯式編程語言叫miniKanren,用Scheme/Lisp實現的。去年給你安利過Scheme了,如今掌握得怎麼樣?」
「一竅不通……」小皮大窘。去年到如今,小皮一直很忙,並無自學什麼東西。若是沒有外力驅動的話,他還將一直忙下去。
「果真如此。因此我順手也實現了個C#魔改版本的miniKanren。就叫NMiniKanren。我把NMiniKanren實現爲C#的一個DSL。這樣的好處是方便熟悉C#或者Java的人快速上手;壞處是DSL會受限於C#語言的能力,代碼看起來沒有Scheme版那麼優雅。」老明用左手作了個打引號的動做,「先從簡單的例子開始吧。好比說,有個未知量q
,咱們的目標是讓q
等於5或者等於6。那麼知足條件的q
值有哪些?」
「不就是5和6麼……這也太簡單了吧。」
「Bingo!」老明打了個響指,「咱們先用簡單的例子看看代碼結構。」只見老明兩指輕輕夾住一隻筷子,勾出幾條米粉,快速在桌上擺出以下代碼:
// k提供NMiniKanren的方法,q是待求解的未知變量。 var res = KRunner.Run(null /* null表示輸出全部可能的結果 */, (k, q) => { // q == 5 或者 q == 6 return k.Any( k.Eq(q, 5), k.Eq(q, 6)); }); KRunner.PrintResult(res); // 輸出結果:[5, 6]
「代碼中,KRunner.Run
用於運行一段NMiniKanren代碼,它的聲明以下。」老明繼續撥動米粉:
public class KRunner { public static IList<object> Run(int? n, Func<KRunner, FreshVariable, Goal> body) { ... } }
「其中,參數n
是返回結果的數量限制,n = null
表示無限制;參數body
是一個函數:
KRunner
實例,用於引用NMiniKanren方法;「接着咱們看函數體的代碼。k.Eq(q, 5)
表示q
須要等於5
,k.Eq(q, 6)
表示q
須要等於6
,k.Any
表示知足至少一個條件。整段代碼的意思爲:求全部知足q
等於5
或者q
等於6
的q
值。顯然答案爲5
和6
,程序的運行結果也是如此。很神奇吧?」
「你這米粉打碼的功夫更讓我驚奇……」小皮仔細看了一會,「原來如此。不過這DSL的語法確實看着比較累。」
「主要是我想作得簡單一些。其實使用C#的Lambda表達式也能夠實現像……」老明勾出幾條米粉擺出q == 5 || q == 6
表達式,「……這樣的語法,不過這樣會增長NMiniKanren實現的複雜度。何況這無非是前綴表達式或中綴表達式這種語法層面的差異而已,語義上並無變化。學習應先抓住重點,花裏胡哨的東西能夠放到最後再來琢磨。」
「嗯嗯。KRunner.Run
裏這個null
的參數是作什麼用的呢?」
「KRunner.Run
的第一個參數用來限制輸出結果的數量。null
表示輸出全部可能的結果。仍是上面例子的條件,咱們改爲限制只輸出1
個結果。」小皮用筷子改了下代碼:
// k提供NMiniKanren的方法,q是待求解的未知變量。 var res = KRunner.Run(1 /* 輸出1個結果 */, (k, q) => { // q == 5 或者 q == 6 return k.Any( k.Eq(q, 5), k.Eq(q, 6)); }); KRunner.PrintResult(res); // 輸出結果:[5]
「這樣程序只會輸出5一個結果。在一些包含遞歸的代碼中,可能會有無窮多個結果,這種狀況下須要限制輸出結果的數量來避免程序不會終止。」
「原來如此。不過這個例子太簡單了,有沒有其餘更好玩的例子。」
老明喝下一口湯,說:「好。時間不早了,咱們回公司找個會議室慢慢說。」
到公司後,老明的講課開始了……
首先,要先明確NMiniKanren支持的數據類型。後續代碼都要基於數據類型來編寫,因此規定好數據類型是基礎中的基礎。
簡單起見,NMiniKanren只支持四種數據類型:
string
:就是一個普普統統的值類型,僅有值相等判斷。int
:同string
。使用int
是由於有時候想少寫兩個雙引號……KPair
:二元組。可用來構造鏈表及其餘複雜的數據結構。若是你學過Lisp會對這個數據結構很熟悉。下面詳細說明。null
:這個類型只有null
一個值。表示空引用或者空數組。KPair
的定義爲:
public class KPair { public object Lhs { get; set; } public object Rhs { get; set; } // methods ... }
KPair
除了用做二元組(實際上是最少用的)外,更多的是用來構造鏈表。構造鏈表時,約定一個KPair
做爲一個鏈表的節點,Lhs
爲元素值,Rhs
爲一下個節點。當Rhs
爲null
時鏈表結束。空鏈表用null
表示。
public static KPair List(IEnumerable<object> lst) { var fst = lst.FirstOrDefault(); if (fst == null) { return null; } return new KPair(fst, List(lst.Skip(1))); }
使用
null
表示空鏈表其實並不合適,這裏純粹是爲了簡單而偷了個懶。
咱們知道,不少複雜的數據結構都是能夠經過鏈表來構造的。因此雖然NMiniKanren只有三種數據類型,但能夠表達不少數據結構了。
這時候小皮有疑問了:「C#自己已經自帶了List
等容器了,爲何還要用KPair
來構造鏈表?」
「爲了讓底層儘量簡潔。」老明說道,「咱們都知道,程序本質上分爲數據結構和算法。算法是順着數據結構來實現的。簡潔的數據結構會讓算法的實現顯得更清晰。相比C#自帶的List
,使用KPair
構造的鏈表更加清晰簡潔。按照構造的方式,咱們的鏈表定義爲:
null
;Lhs
,而且Rhs
是後續的鏈表。「鏈表相關的算法都會順着定義的這兩個分支實現:一個處理空鏈表的分支,一個處理非空鏈表的遞歸代碼。好比說判斷一個變量是否是鏈表的方法:
public static bool IsList(object o) { // 空鏈表 if (o == null) { return true; } // 非空鏈表 if (o is KPair p) { // 遞歸 return IsList(p.Rhs); } // 非鏈表 return false; }
「以及判斷一個元素是否是在鏈表中的方法:
public static bool Memeber(object lst, object e) { // 空鏈表 if (lst == null) { return false; } // 非空鏈表 if (lst is KPair p) { if (p.Lhs == null && e == null || p.Lhs.Equals(e)) { return true; } else { // 遞歸 return Memeber(p.Rhs, e); } } // 非鏈表 return false; }
「數據類型明確後,接下來咱們來看看NMiniKanren能作什麼。」
編寫NMiniKanren代碼是一個構造目標(Goal
類型)的過程。NMiniKanren解釋器運行時將求解使得目標成立的全部未知量的值。
顯然,有兩個平凡的目標:
k.Succeed
:永遠成立,未知量可取任意值。k.Fail
:永遠不成立,不管未知量爲什麼值都不成立。其中k
是KRunner
的一個實例。C#跟Java同樣不能定義獨立的函數和常量,因此咱們DSL須要的函數和常量就都定義爲KRunner
的方法或屬性。後面再也不對k
進行復述。
一個基本的目標是k.Eq(v1, v2)
。這也是NMiniKanren惟一一個使用值來構造的目標,它表示值v1
和v2
應該相等。也就是說,當v1
與v2
相等時,目標k.Eq(v1, v2)
成立;不然不成立。
這裏的相等,指的是值相等:
string
類型相等當且僅當值相等。KPair
類型相等當且僅當它們的Lhs
相等且Rhs
相等。從KPair
相等的定義,能夠推出由KPair
構造的數據結構(好比鏈表),相等條件爲當且僅當它們結構同樣且對應的值相等。
接下來咱們看幾個例子。
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Eq(q, 5); })); // 輸出[5]
直接q
等於5
。
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Eq(q, k.List(1, 2)); })); // 輸出[(1 2)]
k.List(1, 2)
至關於new KPair(1, new KPair(2, null))
,用來快速構造鏈表。
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Eq(k.List(1, q), k.List(1, 2)); })); // 輸出[2]
這個例子比較像一個方程了。q
匹配k.List(1, 2)
的第二項,也就是2
。
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Eq(k.List(2, q), k.List(1, 2)); })); // 輸出[]
因爲k.List(2, q)
的第一項和k.List(1, 2)
的第一項不相等,因此這個目標沒法成立,q
沒有值。
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Fail; })); // 輸出[]
目標沒法成立,q
沒有值。
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Succeed; })); // 輸出[_0]
目標恆成立,q
可取任意值。輸出_0
表示一個可取任意值的自由變量。
目標能夠看做布爾表達式,所以能夠經過「與或非」運算,用簡單的目標構形成複雜的「組合」目標。咱們把被用來構造「組合」目標的目標叫作該「組合」目標的子目標。
在前面的例子中,咱們只有一個未知量q
。q
既是未知量,也是程序輸出。
在處理更復雜的問題時,一般須要定義更多的未知量。定義未知量的方法是k.Fresh
:
// 定義x, y兩個未知量 var x = k.Fresh() var y = k.Fresh()
新定義的未知量和q
同樣,能夠用來構造目標:
// x == 2 k.Eq(x, 2) // x == y k.Eq(x, y)
使用「與」運算組合的目標,僅當全部子目標成立時,目標才成立。
使用方法k.All
來構造「與」運算組合的目標。
var g = k.All(g1, g2, g3, ...)
當且僅當g1
, g2
, g3
, ......,都成立時,g
才成立。
特別的,空子目標的狀況,即k.All()
,恆成立。
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.All( k.Eq(q, 1), k.Eq(q, 2)); })); // 輸出[] KRunner.PrintResult(KRunner.Run(null, (k, q) => { var x = k.Fresh(); var y = k.Fresh(); return k.All( k.Eq(x, 1), k.Eq(y, x), k.Eq(q, k.List(x, y))); })); // 輸出[(1 1)]
使用「或」運算組合的目標,只要一個子目標成立時,目標就成立。
使用方法k.Any
來構造「或」運算組合的目標。
var g = k.Any(g1, g2, g3, ...)
當g1
, g2
, g3
, ......中至少一個成立,g
成立。
特別的,空子目標的狀況,即k.Any()
,恆不成立。
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Any( k.Eq(q, 5), k.Eq(q, 6)); })); // 輸出[5, 6] KRunner.PrintResult(KRunner.Run(null, (k, q) => { var x = k.Fresh(); var y = k.Fresh(); return k.All( k.Any(k.Eq(x, 5), k.Eq(y, 6)), k.Eq(q, k.List(x, y))); })); // 輸出[(5 _0), (_0 6)]
MiniKanren(以及NMiniKanren)不支持「非」運算。支持「非」會讓miniKanren的實現複雜不少。
這或許使人驚訝。「與或非」在邏輯代數中一直像是連體嬰兒似的扎堆出現。而且「非」運算是單目運算符,看起來應該更簡單。
然而,「與」和「或」運算是在已知的兩(多)個集合中取交集或者並集,結果也是已知的。而「非」運算則是把一個已知的集合映射到可能未知的集合,遍歷「非」運算的結果可能會好久或者就是不可能的。
對於基於圖搜索和代入法求解的miniKanren來講,支持「非」運算須要對核心的數據結構和算法作較大改變。所以以教學爲目的的miniKanren沒有支持「非」運算。
不過,在必定程度上,也是有不完整替代方法的。
If是一個特殊的構造目標的方式。對應《The Reasoned Schemer》中的conda
。
var g = k.If(g1, g2, g3)
若是g1
且g2
成立,那麼g
成立;不然當且僅當g3
成立時,g
成立。
這個和k.Any(k.All(g1, g2), g3)
很像,但他們是有區別的:
k.Any(k.All(g1, g2), g3)
會解出全部讓k.All(g1, g2)
或者g3
成立的解k.If(g1, g2, g3)
若是k.All(g1, g2)
有解,那麼只給出使k.All(g1, g2)
成立的解;不然再求使得g3
成立的解。也能夠說,If是短路的。
這麼詭異的特性有什麼用呢?
它能夠部分地實現「非」運算的功能:
k.If(g, k.Fail, k.Succeed)
這個這裏先不詳細展開了,後面用到再說。
這是一個容易被忽略的問題。若是程序須要求出全部的解,那麼輸出順序影響不大。可是一些狀況下,求解速度很慢,或者解的數量太多甚至無窮,這時只求前幾個解,那麼輸出的內容就和輸出順序有關了。
由於miniKanren以圖遍歷的方式來查找問題的解,因此解的順序其實也是解釋器運行時遍歷的順序。先看以下例子:
KRunner.PrintResult(KRunner.Run(null, (k, q) => { var x = k.Fresh(); var y = k.Fresh(); return k.All( k.Any(k.Eq(x, 1), k.Eq(x, 2)), k.Any(k.Eq(y, "a"), k.Eq(y, "b")), k.Eq(q, k.List(x, y))); })); // 輸出[(1 a), (1 b), (2 a), (2 b)]
有兩個未知變量x
和y
,x
可能的取值爲1或2,y
可能的取值爲a或b。能夠看到,程序查找解的順序爲:
x
值爲1
y
值爲a,q=(1 a)
y
值爲b,q=(1 b)
x
值爲2
y
值爲a,q=(2 a)
y
值爲b,q=(2 b)
若是要改變這個順序,咱們有一個交替版的「與」運算k.Alli
:
KRunner.PrintResult(KRunner.Run(null, (k, q) => { var x = k.Fresh(); var y = k.Fresh(); return k.Alli( k.Any(k.Eq(x, 1), k.Eq(x, 2)), k.Any(k.Eq(y, "a"), k.Eq(y, "b")), k.Eq(q, k.List(x, y))); })); // 輸出[(1 a), (2 a), (1 b), (2 b)]
不過這個交替版也不是交替得很漂亮。下面增長x
可能的取值到3個:
KRunner.PrintResult(KRunner.Run(null, (k, q) => { var x = k.Fresh(); var y = k.Fresh(); return k.Alli( k.Any(k.Eq(x, 1), k.Eq(x, 2), k.Eq(x, 3)), k.Any(k.Eq(y, "a"), k.Eq(y, "b")), k.Eq(q, k.List(x, y))); })); // 輸出[(1 a), (2 a), (1 b), (3 a), (2 b), (3 b)]
一樣,「或」運算也有交替版。
正常版:
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Any( k.Any(k.Eq(q, 1), k.Eq(q, 2)), k.Any(k.Eq(q, 3), k.Eq(q, 4))); })); // 輸出[1, 2, 3, 4]
交替版:
KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Anyi( k.Any(k.Eq(q, 1), k.Eq(q, 2)), k.Any(k.Eq(q, 3), k.Eq(q, 4))); })); // 輸出[1, 3, 2, 4]
後面講到miniKanren實現原理時會解釋正常版、交替版爲何會是這種表現。
無遞歸,不編程!
遞歸給予了程序語言無限的可能。NMiniKanren也是支持遞歸的。下面咱們實現一個方法,這個方法構造的目標要求指定的值或者未知量是一個全部元素都爲1的鏈表。
一個值或者未知量的元素都爲1,用遞歸的方式表達是:
直譯爲代碼就是:
public static Goal AllOne_Wrong(this KRunner k, object lst) { var d = k.Fresh(); return k.Any( // 空鏈表 k.Eq(lst, null), // 非空 k.All( k.Eq(lst, k.Pair(1, d)), // 第一個元素是1 k.AllOne_Wrong(d))); // 剩餘部分的元素都是1 }
直接運行這段代碼,死循環。
爲何呢?由於咱們直接使用C#的方法來定義函數,C#在構造目標的時候,會運行最後一行的k.AllOne_Wrong(d)
,因而就陷入死循環了。
爲了不死循環,在遞歸調用的地方,須要用k.Recurse
方法特殊處理一下,讓遞歸的部分變爲惰性求值,防止直接調用:
public static Goal AllOne(this KRunner k, object lst) { var d = k.Fresh(); return k.Any( k.Eq(lst, null), k.All( k.Eq(lst, k.Pair(1, d)), k.Recurse(() => k.AllOne(d)))); }
隨便構造兩個問題運行一下:
KRunner.PrintResult(KRunner.Run(null, (k, q) => { var x = k.Fresh(); var y = k.Fresh(); return k.All( k.AllOne(k.List(1, x, y, 1)), k.Eq(q, k.List(x, y))); })); // 輸出[(1 1)] KRunner.PrintResult(KRunner.Run(null, (k, q) => { var x = k.Fresh(); var y = k.Fresh(); return k.All( k.AllOne(k.List(1, x, y, 0)), k.Eq(q, k.List(x, y))); })); // 輸出[]
k.Recurse
這種處理方法實際上是比較醜陋並且很差用的。特別是多個函數相互調用引發遞歸的狀況,極可能會漏寫k.Recurse
致使死循環。
聽到這裏,小皮疑惑道:「這個有點醜誒。剛剛網上瞄了下《The Reasoned Schemer》,發現人家的遞歸併不須要這種特殊處理。看起來直接調用就OK了,跟普通程序沒啥兩樣,很美很和諧。」
「由於《The Reasoned Schemer》使用Lisp的宏實現的miniKanren,宏的機制會有相似惰性計算的效果。」老明用擦白板的抹布拍了下小皮的腦殼,「惋惜你不會Lisp。若是你不努力提高本身,那醜一點也只能將就着看了。」
MiniKanren沒有直接支持數值計算。也就是說,miniKanren不能直接幫你解像2 + x = 5
的這種方程。若是要直接支持數值計算,須要實現不少數學相關的運算和變換,會讓miniKanren的實現變得很是複雜。MiniKanren是教學性質的語言,只支持了最基本的邏輯判斷功能。
「沒有‘直接’支持。」小皮敏銳地發現了關鍵,「也就是能夠間接支持咯?」
「沒錯!你想一想,0和1是咱們支持的符號,與和或也是咱們支持的運算符!」老明興奮起來了。
「二進制?」
「是的!任何一本計算機組成原理教程都會教你怎麼作!這裏就很少說了,你能夠本身回去試一下。」
「嗯嗯。我之前這門課學得還不錯,如今還記得大概是先實現半加器和全加器,而後構造加法器和乘法器等。」小皮幹勁十足,從底層開始讓他想起了小時候玩泥巴的樂趣。
「並且用miniKanren實現的不是通常的加法器和乘法器,是能夠反向運行的加法器和乘法器。」
「有意思,晚上下班回去就去試試。」小皮真心地說。正如他下班回家躺牀上後,就不再想動彈同樣真心實意。
(注:《The Reasoned Schemer》第7章、第8章會講到相關內容。)
「好了,NMiniKanren語言的介紹就先說到這裏了。」老明拍了拍手,看了看前面的例子,撇了撇嘴,「以C#的DSL方式實現出來果真醜不少,語法上都不一致了。不過核心功能都還在。」
「接下來就是最有意思的部分,NMiniKanren的原理了吧?」
「是的。不過在繼續以前,還有個問題。」
「啥問題?」
「中午米線都用來打碼了。如今肚子餓了,你要請我吃下午茶。」
NMiniKanren的源碼在:https://github.com/sKabYY/NMiniKanren
示例代碼在:https://github.com/sKabYY/NMiniKanren/tree/master/NMiniKaren.Tests