相信很多朋友對于邏輯式編程語言,都有一種最熟悉的陌生人的感覺,一方面,平時在書籍、在資訊網站,偶爾能看到一些吹噓邏輯式編程的話語,但另一方面,也沒見過周圍有人真正用到它(除了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
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/9626.html
標籤:C#
