1. 程式人生 > >邏輯式程式語言極簡實現(使用C#) - 1. 邏輯式程式語言介紹

邏輯式程式語言極簡實現(使用C#) - 1. 邏輯式程式語言介紹

相信很多朋友對於邏輯式程式語言,都有一種最熟悉的陌生人的感覺。一方面,平時在書籍、在資訊網站,偶爾能看到一些吹噓邏輯式程式設計的話語。但另一方面,也沒見過周圍有人真正用到它(除了SQL)。 遙記當時看《The Reasoned Schemer》(一本講邏輯式程式語言的小人書),被最後兩頁的直譯器實現驚豔到了。看似如此複雜的計算邏輯,其實現竟然這麼簡潔。不過礙於當時水平有限,也就囫圇吞棗般看了過去。後來有一天,不知何故腦子靈光一閃,把圖遍歷和流計算模式聯絡在一起,瞬間明白了《The Reasoned Schemer》中的做法。動手寫了寫程式碼,果然如此,短短[兩百來行程式碼](https://github.com/sKabYY/palestra/blob/master/reasoned/mk.ss),就完成了直譯器的實現,才發現原來如此簡單。很多時候,並非問題本身有多難,只是沒有想到正確的方法。 本系列將盡可能簡潔地說明邏輯式程式設計語音的原理,並實現一門簡單的邏輯式程式語言。考慮到C#的使用者較多,因此選擇用C#來實現。實現的這門語言就叫NMiniKanren。文章總體內容如下: * NMiniKanren語言介紹 * 語言基礎 * 一道有趣的邏輯題:誰是凶手 * NMiniKanren執行原理 * 構造條件關係圖,遍歷分支 * 代入消元法解未知量 * 實現NMiniKanren * 流計算模式簡介 * 代入消元法的實現 * 遍歷分支的實現 故事從兩個正在吃午餐的程式設計師說起。 老明和小皮是就職於同一家傳統企業的程式設計師。這天,兩人吃著午餐。老明邊吃邊刷著抖音,鼻孔時不時噴出幾條米粉。 小皮是一臉麻木地刷著求職網和資訊網,忽然幾個大字映入眼底:《新型邏輯式程式語言重磅出世,即將顛覆IT界!》小皮一陣好奇,往下一翻,結果接著的是一些難懂的話,什麼“一階邏輯”,什麼“合一演算法”,以及鬼畫符似的公式之類。 小皮看得索然無味,但被勾引起來的對邏輯式程式設計的興趣彷彿~~澳洲~~森林大火一樣難以平息。於是伸手拍下老明高舉手機的左手,問道:“嘿!邏輯式程式設計有了解過麼?是個啥玩意兒?” “邏輯式程式設計啊……嘿嘿,前段時間剛好稍微瞭解了一下。”老明鼻孔朝天吸了兩口氣,“我說的稍微瞭解,是指實現了一門邏輯式程式語言。” “不愧是資深老IT,瞭解也比別人深入一坨坨……” “也就比你早來一年好不好……我是一邊看一本奇書一邊做的。Dan老師(Dan Friedman)寫的《The Reasoned Schemer》。這本書挺值得一看的,書中使用一門教學用的邏輯式程式語言,講解這門語言的特性、用法、以及原理。最後還給出了這門語言的實現。核心程式碼只用了兩頁紙。 ![](https://img2020.cnblogs.com/blog/576869/202006/576869-20200627220458829-1626404081.jpg) “所謂邏輯式程式設計,從使用上看是把宣告式程式設計發揮到極致的一種程式設計正規化。普通的程式語言,大部分還是基於指令式程式設計,需要你告訴機器每一步執行什麼指令。而邏輯式程式設計的理念是,我們只需要告訴機器我們需要的目標,機器會根據這個目標自動探索執行過程。 “**邏輯式程式設計的特點是可以反向執行。你可以像做數學題一樣,宣告未知量,列出方程,然後程式會為你求解未知量。**” ![](https://img2020.cnblogs.com/blog/576869/202006/576869-20200627220400214-349321078.png) “挺神奇的。聽起來有點像AI程式設計。不過這麼高階的東西怎麼沒有流行起來?感覺可以節省不少人力。”小皮忽然有種飯碗即將不保的感覺。 “嘿嘿……想得美。其實邏輯式程式設計,既不智慧,也不好用。你回憶一下你中學的時候是怎麼解方程組的?” “嗯……先盯一會方程組,看看它長得像不像有快捷解法的樣子。看不出來的話就用代入法慢慢算。這和邏輯式程式設計有什麼關係?” “**邏輯式程式設計並不智慧,它只是把某種類似代入法的通用演算法內建到直譯器裡**。邏輯式程式語言寫的程式執行時,不過是根據通用演算法進行求解而已。它不像人一樣會去尋找更快捷的方法,同時也不能解決超綱問題。 ![](https://img2020.cnblogs.com/blog/576869/202006/576869-20200628095521604-908166855.png) “**而且邏輯式程式語言的學習成本也不低**。如果你要用好這門語言,你得把它使用的通用演算法搞清楚。雖然你寫的宣告式的程式碼,但內心要時刻清楚程式的執行過程。**如果你拿它當個黑盒來用,那很可能你寫出來的程式的執行效率會非常低,甚至跑出一些莫名其妙的結果**。” “哦哦,要學會用它,還得先懂得怎麼實現它。這學習成本還挺高的。”小皮跟著吐槽,不過他知道老明表明上看似嫌棄邏輯式程式設計的實用性,私底下肯定玩得不亦樂乎,並且也喜歡跟別人分享。於是小皮接著道:“雖然應該是用不著,但感覺挺有意思的,再仔細講講唄。天天寫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!”老明打了個響指,“我們先用簡單的例子看看程式碼結構。”只見老明兩指輕輕夾住一隻筷子,勾出幾條米粉,快速在桌上擺出如下程式碼: ```CSharp // 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程式碼,它的宣告如下。”老明繼續撥動米粉: ```CSharp public class KRunner { public static IList Run(int? n, Func body) { ... } } ``` “其中,引數`n`是返回結果的數量限制,`n = null`表示無限制;引數`body`是一個函式: * 函式的第一個引數是一個`KRunner`例項,用於引用NMiniKanren方法; * 函式的第二個引數是我們將要求解的未知量; * 函式的函式體是我們編寫的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`個結果。”小皮用筷子改了下程式碼: ```CSharp // 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支援的資料型別。後續程式碼都要基於資料型別來編寫,所以規定好資料型別是基礎中的基礎。 簡單起見,NMiniKanren只支援四種資料型別: * `string`:就是一個普普通通的值型別,僅有值相等判斷。 * `int`:同`string`。使用`int`是因為有時候想少寫兩個雙引號…… * `KPair`:二元組。可用來構造連結串列及其他複雜的資料結構。如果你學過Lisp會對這個資料結構很熟悉。下面詳細說明。 * `null`:這個型別只有`null`一個值。表示空引用或者空陣列。 ### KPair型別 `KPair`的定義為: ```CSharp public class KPair { public object Lhs { get; set; } public object Rhs { get; set; } // methods ... } ``` `KPair`除了用作二元組(其實是最少用的)外,更多的是用來構造連結串列。構造連結串列時,約定一個`KPair`作為一個連結串列的節點,`Lhs`為元素值,`Rhs`為一下個節點。當`Rhs`為`null`時連結串列結束。空連結串列用`null`表示。 ```CSharp public static KPair List(IEnumerable lst) { var fst = lst.FirstOrDefault(); if (fst == null) { return null; } return new KPair(fst, List(lst.Skip(1))); } ``` > 使用`null`表示空連結串列其實並不合適,這裡純粹是為了簡單而偷了個懶。 ![](https://img2020.cnblogs.com/blog/576869/202006/576869-20200628095545819-189774916.png) 我們知道,很多複雜的資料結構都是可以通過連結串列來構造的。所以雖然NMiniKanren只有三種資料型別,但可以表達很多資料結構了。 這時候小皮有疑問了:“C#本身已經自帶了`List`等容器了,為什麼還要用`KPair`來構造連結串列?” “為了讓底層儘可能簡潔。”老明說道,“我們都知道,程式本質上分為資料結構和演算法。**演算法是順著資料結構來實現的**。簡潔的資料結構會讓演算法的實現顯得更清晰。相比C#自帶的`List`,使用`KPair`構造的連結串列更加清晰簡潔。按照構造的方式,我們的連結串列定義為: 1. 空連結串列`null`; 2. 或者是非空連結串列。它的第一個元素為`Lhs`,並且`Rhs`是後續的連結串列。 “連結串列相關的演算法都會順著定義的這兩個分支實現:一個處理空連結串列的分支,一個處理非空連結串列的遞迴程式碼。比如說判斷一個變數是不是連結串列的方法: ```CSharp public static bool IsList(object o) { // 空連結串列 if (o == null) { return true; } // 非空連結串列 if (o is KPair p) { // 遞迴 return IsList(p.Rhs); } // 非連結串列 return false; } ``` “以及判斷一個元素是不是在連結串列中的方法: ```CSharp 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能做什麼。” ## 目標(Goal) 編寫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`構造的資料結構(比如連結串列),相等條件為當且僅當它們結構一樣且對應的值相等。 接下來我們看幾個例子。 ### 等於一個值 ```CSharp KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Eq(q, 5); })); // 輸出[5] ``` 直接`q`等於`5`。 ### 等於一個連結串列 ```CSharp 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))`,用來快速構造連結串列。 ### 連結串列間的相等 ```CSharp 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`。 ### 無法相等的例子 ```CSharp 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`沒有值。 ### 不成立的例子 ```CSharp KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Fail; })); // 輸出[] ``` 目標無法成立,`q`沒有值。 ### 永遠成立的例子 ```CSharp KRunner.PrintResult(KRunner.Run(null, (k, q) => { return k.Succeed; })); // 輸出[_0] ``` 目標恆成立,`q`可取任意值。輸出`_0`表示一個可取任意值的自由變數。 ## 更多構造目標的方式 目標可以看作布林表示式,因此可以通過“與或非”運算,用簡單的目標構造成複雜的“組合”目標。我們把被用來構造“組合”目標的目標叫做該“組合”目標的子目標。 ### 定義未知量 在前面的例子中,我們只有一個未知量`q`。`q`既是未知量,也是程式輸出。 在處理更復雜的問題時,通常需要定義更多的未知量。定義未知量的方法是`k.Fresh`: ```CSharp // 定義x, y兩個未知量 var x = k.Fresh() var y = k.Fresh() ``` 新定義的未知量和`q`一樣,可以用來構造目標: ```CSharp // x == 2 k.Eq(x, 2) // x == y k.Eq(x, y) ``` ### 與 使用“與”運算組合的目標,僅當所有子目標成立時,目標才成立。 使用方法`k.All`來構造“與”運算組合的目標。 ```CSharp var g = k.All(g1, g2, g3, ...) ``` 當且僅當`g1`, `g2`, `g3`, ......,都成立時,`g`才成立。 特別的,空子目標的情況,即`k.All()`,恆成立。 #### 例 ```CSharp 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`來構造“或”運算組合的目標。 ```CSharp var g = k.Any(g1, g2, g3, ...) ``` 當`g1`, `g2`, `g3`, ......中至少一個成立,`g`成立。 特別的,空子目標的情況,即`k.Any()`,恆不成立。 #### 例 ```CSharp 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(這個比較奇葩,可以先跳過) If是一個特殊的構造目標的方式。對應《The Reasoned Schemer》中的`conda`。 ```CSharp 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是短路的。 這麼詭異的特性有什麼用呢? 它可以部分地實現“非”運算的功能: ```CSharp k.If(g, k.Fail, k.Succeed) ``` 這個這裡先不詳細展開了,後面用到再說。 ## 控制輸出順序 這是一個容易被忽略的問題。如果程式需要求出所有的解,那麼輸出順序影響不大。但是一些情況下,求解速度很慢,或者解的數量太多甚至無窮,這時只求前幾個解,那麼輸出的內容就和輸出順序有關了。 因為miniKanren以圖遍歷的方式來查詢問題的解,所以解的順序其實也是直譯器執行時遍歷的順序。先看如下例子: ```CSharp 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`: ```CSharp 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個: ```CSharp 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)] ``` 同樣,“或”運算也有交替版。 正常版: ```CSharp 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] ``` 交替版: ```CSharp 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,用遞迴的方式表達是: 1. 它是一個空連結串列 2. 或者它的第一個元素是1,且剩餘部分的元素都為1 直譯為程式碼就是: ```CSharp 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`方法特殊處理一下,讓遞迴的部分變為惰性求值,防止直接呼叫: ```CSharp 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)))); } ``` 隨便構造兩個問題執行一下: ```CSharp 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) 示例程式碼在:[https://github.com/sKabYY/NMiniKanren/tree/master/NMiniKaren.Tests](https://github.com/sKabYY/NMiniKanren/tree/master/NMiniKaren