相信很多朋友對於邏輯式程式語言,都有一種最熟悉的陌生人的感覺。一方面,平時在書籍、在資訊網站,偶爾能看到一些吹噓邏輯式程式設計的話語。但另一方面,也沒見過周圍有人真正用到它(除了SQL)。
遙記當時看《The Reasoned Schemer》(一本講邏輯式程式語言的小人書),被最後兩頁的直譯器實現驚豔到了。看似如此複雜的計算邏輯,其實現竟然這麼簡潔。不過礙於當時水平有限,也就囫圇吞棗般看了過去。後來有一天,不知何故腦子靈光一閃,把圖遍歷和流計算模式聯絡在一起,瞬間明白了《The Reasoned Schemer》中的做法。動手寫了寫程式碼,果然如此,短短兩百來行程式碼,就完成了直譯器的實現,才發現原來如此簡單。很多時候,並非問題本身有多難,只是沒有想到正確的方法。
本系列將盡可能簡潔地說明邏輯式程式設計語音的原理,並實現一門簡單的邏輯式程式語言。考慮到C#的使用者較多,因此選擇用C#來實現。實現的這門語言就叫NMiniKanren。文章總體內容如下:
- NMiniKanren語言介紹
- 語言基礎
- 一道有趣的邏輯題:誰是凶手
- NMiniKanren執行原理
- 構造條件關係圖,遍歷分支
- 代入消元法解未知量
- 實現NMiniKanren
- 流計算模式簡介
- 代入消元法的實現
- 遍歷分支的實現
故事從兩個正在吃午餐的程式設計師說起。
老明和小皮是就職於同一家傳統企業的程式設計師。這天,兩人吃著午餐。老明邊吃邊刷著抖音,鼻孔時不時噴出幾條米粉。
小皮是一臉麻木地刷著求職網和資訊網,忽然幾個大字映入眼底:《新型邏輯式程式語言重磅出世,即將顛覆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方法; - 函式的第二個引數是我們將要求解的未知量;
- 函式的函式體是我們編寫的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支援的資料型別。後續程式碼都要基於資料型別來編寫,所以規定好資料型別是基礎中的基礎。
簡單起見,NMiniKanren只支援四種資料型別:
string
:就是一個普普通通的值型別,僅有值相等判斷。int
:同string
。使用int
是因為有時候想少寫兩個雙引號……KPair
:二元組。可用來構造連結串列及其他複雜的資料結構。如果你學過Lisp會對這個資料結構很熟悉。下面詳細說明。null
:這個型別只有null
一個值。表示空引用或者空陣列。
KPair型別
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能做什麼。”
目標(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
構造的資料結構(比如連結串列),相等條件為當且僅當它們結構一樣且對應的值相等。
接下來我們看幾個例子。
等於一個值
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(這個比較奇葩,可以先跳過)
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
值為1y
值為a,q=(1 a)
y
值為b,q=(1 b)
x
值為2y
值為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,用遞迴的方式表達是:
- 它是一個空連結串列
- 或者它的第一個元素是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