我对Prolog的不满
摘要
Hillel Wayne的一篇博文,详细讲述了他对Prolog编程语言的不满,包括字符串问题、缺乏函数、数据类型有限以及cut操作等。
<p>对于我即将发布的<a href="https://leanpub.com/logic/" target="_blank">Logic for Programmers</a>新版本,我终于开始补上我在0.9版本中标记TODO的关于答案集编程和约束逻辑编程的章节。这让我重新体验了Prolog的一些痛点,现在我要吐槽一下。如果你想了解为什么Prolog很酷,请前往<a href="https://buttondown.com/hillelwayne/archive/a48fce5b-8a05-4302-b620-9b26f057f145/" target="_blank">这里</a>、<a href="https://www.metalevel.at/prolog" target="_blank">这里</a>、<a href="https://ianthehenry.com/posts/drinking-with-datalog/" target="_blank">这里</a>或<a href="https://logicprogramming.org/" target="_blank">这里</a>。</p>
<h3>没有标准化的字符串</h3>
<p>ISO "字符串"实际上只是原子或单字符原子的列表(或整数字符码的列表)。Prolog的各种实现都添加了自定义字符串操作符,但它们之间并不兼容,因此在SWI-Prolog中使用字符串编写的代码无法在Scryer Prolog中运行。</p>
<h3>没有函数</h3>
<p>代码逻辑完全通过<em>规则</em>来表达,这些规则是针对特定值返回真或假的谓词。例如,如果你想获取Prolog列表的长度,可以这样写:</p>
<div class="codehilite"><pre><span></span><code><span class="s s-Atom">?-</span> <span class="nf">length</span><span class="p">([</span><span class="s s-Atom">a</span><span class="p">,</span> <span class="s s-Atom">b</span><span class="p">,</span> <span class="s s-Atom">c</span><span class="p">],</span> <span class="nv">Len</span><span class="p">).</span>
<span class="nv">Len</span> <span class="o">=</span> <span class="mf">3.</span>
</code></pre></div>
<p class="empty-line" style="height:16px; margin:0px !important;"></p>
<p>这很酷,因为它允许双向性,或者说“反向”运行谓词。要生成长度为3的列表,你可以写<code>length(L, 3)</code>。但这也意味着,如果你想获取列表长度<em>加一</em>,你不能在一个表达式中完成,而必须写<code>length(List, Out), X is Out+1</code>。</p>
<p>有一段时间,我以为没有函数是双向性必要的代价,但后来我发现<a href="https://picat-lang.org/" target="_blank">Picat</a>有函数并且运行得很好。这本身就让我更倾向于在逻辑编程需求中使用Picat。</p>
<p>(双向性是Prolog的一个杀手级特性,可惜我很少遇到使用它的场景。)</p>
<h3>除了列表,没有标准化的集合类型</h3>
<p>除了原子(<code>abc</code>)和数字,只有两种数据类型:</p>
<ul>
<li>链表,如<code>[a,b,c,d]</code>。</li>
<li>复合项,如<code>dog(rex, poodle)</code>,它们<em>看似</em>是记录类型,但实际上是元组。你甚至可以用<code>=..</code>将复合项转换为链表:</li>
</ul>
<div class="codehilite"><pre><span></span><code><span class="s s-Atom">?-</span> <span class="nv">L</span> <span class="s s-Atom">=..</span> <span class="p">[</span><span class="s s-Atom">a</span><span class="p">,</span> <span class="s s-Atom">b</span><span class="p">,</span> <span class="s s-Atom">c</span><span class="p">].</span>
<span class="nv">L</span> <span class="o">=</span> <span class="nf">a</span><span class="p">(</span><span class="s s-Atom">b</span><span class="p">,</span> <span class="s s-Atom">c</span><span class="p">).</span>
<span class="s s-Atom">?-</span> <span class="nf">a</span><span class="p">(</span><span class="s s-Atom">b</span><span class="p">,</span> <span class="nf">c</span><span class="p">(</span><span class="s s-Atom">c</span><span class="p">))</span> <span class="s s-Atom">=..</span> <span class="nv">L</span><span class="p">.</span>
<span class="nv">L</span> <span class="o">=</span> <span class="p">[</span><span class="s s-Atom">a</span><span class="p">,</span> <span class="s s-Atom">b</span><span class="p">,</span> <span class="nf">c</span><span class="p">(</span><span class="s s-Atom">c</span><span class="p">)].</span>
</code></pre></div>
<p>没有像样的键值映射甚至结构体类型。同样,这个问题可以由各个发行版自行修复(但无法跨版本兼容),但这些修复从未感觉与语言的其他部分融为一体。</p>
<h3>没有布尔值</h3>
<p><code>true</code>和<code>false</code>不是值,而是控制流语句。<code>true</code>是一个空操作,<code>false</code>表示当前搜索路径是死胡同,因此回溯并重新开始。你不能显式地存储真和假作为值,而必须通过事实来隐式表示(例如<code>passed(test)</code>而不是<code>test.passed? == true</code>)。</p>
<p>这并没有让任何任务变得不可能,我通常都能找到变通方法来实现我想要的功能。但我确实认为这增加了不便!有时我想做点蠢事,比如“获取所有不满足至少三条规则的原子”,如果能将中间结果塞进一个布尔值容器里,事情会简单得多。</p>
<p>(这被称为“<a href="https://en.wikipedia.org/wiki/Negation_as_failure" target="_blank">失败即否定</a>”。我认为这可能是使Prolog成为图灵完备通用编程语言所必需的。Picat解决了Prolog的许多痛点,但仍然保留了失败即否定。ASP有常规否定,但并非图灵完备。)</p>
<h3>cut操作令人困惑</h3>
<div class="subscribe-form"></div>
<p>Prolog通过深度优先搜索寻找解,而“cut”(<code>!</code>)符号可以防止回溯超过某个点。这对于优化是必要的,但可能导致程序失效。</p>
<p>如果能避免使用cut,就不应该使用它,所以我假装cut不存在。因此,当我发现<a href="https://eu.swi-prolog.org/pldoc/doc_for?object=(-%3E)/2" target="_blank">条件语句</a>是用cut实现的时候,我感到很惊讶。因为cut是诡异的黑魔法,条件语句<em>有时</em>能按我的预期工作,有时却遗漏有效解,我完全无法预测。通常我发现完全避免条件语句更安全,但这意味着我的代码变得更长更乱。</p>
<h3>非cut也令人困惑</h3>
<p>上一节最初的例子是这样的:</p>
<div class="codehilite"><pre><span></span><code><span class="nf">foo</span><span class="p">(</span><span class="nv">A</span><span class="p">,</span> <span class="nv">B</span><span class="p">)</span> <span class="p">:-</span>
<span class="s s-Atom">\+</span> <span class="p">(</span><span class="nv">A</span> <span class="o">=</span> <span class="nv">B</span><span class="p">),</span>
<span class="nv">A</span> <span class="o">=</span> <span class="mi">1</span><span class="p">,</span>
<span class="nv">B</span> <span class="o">=</span> <span class="mf">2.</span>
</code></pre></div>
<p><code>foo(1, 2)</code>返回真,因此你可能会认为<code>f(A, B)</code>会返回<code>A=1, B=2</code>。但它返回<code>false</code>。然而下面的代码却按预期工作:</p>
<div class="codehilite"><pre><span></span><code><span class="nf">bar</span><span class="p">(</span><span class="nv">A</span><span class="p">,</span> <span class="nv">B</span><span class="p">)</span> <span class="p">:-</span>
<span class="nv">A</span> <span class="o">=</span> <span class="mi">1</span><span class="p">,</span>
<span class="nv">B</span> <span class="o">=</span> <span class="mi">2</span><span class="p">,</span>
<span class="s s-Atom">\+</span> <span class="p">(</span><span class="nv">A</span> <span class="o">=</span> <span class="nv">B</span><span class="p">).</span>
</code></pre></div>
<p>我<em>原本</em>以为这是因为<code>\+</code>是用cut实现的,并且<a href="https://www.amazon.com/Programming-Prolog-Using-ISO-Standard/dp/3540006788" targ
查看缓存全文
缓存时间: 2026/05/16 03:40
# 我对 Prolog 的一些抱怨
来源:https://buttondown.com/hillelwayne/archive/my-gripes-with-prolog
为了下一版《Logic for Programmers》(https://leanpub.com/logic/),我终于要补上 0.9 版时留了 TODO 的关于 Answer Set Programming 和 Constraint Logic Programming 的章节了。这让我重新体验了 Prolog 的一些痛点,现在就来吐槽一下。如果你更想了解 Prolog 的酷炫之处,可以点[这里](https://buttondown.com/hillelwayne/archive/a48fce5b-8a05-4302-b620-9b26f057f145/)、[这里](https://www.metalevel.at/prolog)、[这里](https://ianthehenry.com/posts/drinking-with-datalog/) 或 [这里](https://logicprogramming.org/)。
### 没有标准化的字符串
ISO 的“字符串”其实就是原子(atoms)或单字符串构成的列表(或者整数字符码的列表)。各个 Prolog 实现版本添加了自定义的字符串操作符,但彼此不兼容,所以用 SWI-Prolog 写的字符串代码在 Scryer Prolog 里无法运行。
### 没有函数
代码逻辑完全用*规则*(rules)来表达,也就是对某些值返回真或假的谓词。例如,如果要获取 Prolog 列表的长度,你需要这样写:
```prolog
?- length([a, b, c], Len).
Len = 3.
```
这很酷,因为它允许双向性(bidirectionality),即可以“逆向”运行谓词。要生成长度为 3 的列表,你可以写 `length(L, 3)`。但这同时也意味着,如果你想获取列表长度**加一**,就不能在一个表达式里完成,而必须写 `length(List, Out), X is Out+1`。
有段时间我以为“没有函数”是实现双向性的必要牺牲,但后来我发现 [Picat](https://picat-lang.org/) 既有函数,也运行得很好。仅仅这一点,就让我更倾向于用 Picat 来满足我对逻辑编程的需求。
(双向性是 Prolog 的杀手级特性,可惜我很少遇到真正需要它的场景。)
### 除了列表,没有标准化的集合类型
除了原子(`abc`)和数字,只有两种数据类型:
- 链表,比如 `[a,b,c,d]`。
- 复合项(compound terms),比如 `dog(rex, poodle)`,它们*看起来*像记录类型,但实际上是元组。你甚至可以用 `=..` 把复合项转换成链表:
```prolog
?- L =.. [a, b, c].
L = a(b, c).
?- a(b, c(c)) =.. L.
L = [a, b, c(c)].
```
没有真正的键值映射,甚至连结构体类型都没有。同样,各个发行版可以自行添加(但无法跨版本兼容),但这些东西从未与语言的其他部分真正融合。
### 没有布尔值
`true` 和 `false` 不是值,而是控制流语句。`true` 是空操作(noop),`false` 表示当前搜索路径是死路,因此需要回溯重来。你不能显式地把 true 和 false 作为值来存储,只能在事实(facts)中隐式体现(例如写 `passed(test)` 而不是 `test.passed? == true`)。
这并没有让任何任务变得不可能完成,我通常也能找到变通方法。但确实增加了不便!有时候我想做些傻事,比如“获取所有不满足这三条规则中至少一条的原子”,如果能把中间结果塞进一个布尔值袋子里,会容易得多。
(这被称为“否定即失败”(Negation as Failure,https://en.wikipedia.org/wiki/Negation_as_failure)。我认为这可能正是 Prolog 作为图灵完备通用编程语言的必要条件。Picat 解决了 Prolog 的许多痛点,但仍然保留了否定即失败。ASP 有常规否定,但它不是图灵完备的。)
### 截断(Cuts)令人困惑
Prolog 通过深度优先搜索寻找解,而“截断”(`!`)符号可以阻止回溯越过某个点。这对优化是必要的,但也可能导致程序出错。
如果能避免,就不应该使用截断,所以我一直假装截断不存在。因此,当发现[条件语句](https://eu.swi-prolog.org/pldoc/doc_for?object=(-%3E)/2)也是用截断实现时,我相当惊讶。因为截断是可怕的黑暗魔法——条件语句*有时*像我预期的那样工作,有时却漏掉有效的解,而我完全无法判断会是哪种情况。通常我觉得完全避开条件语句更安全,但这导致代码变得更冗长、更混乱。
### 非截断也令人困惑
上一节最初的例子是这样的:
```prolog
foo(A, B) :-
\+ (A = B),
A = 1,
B = 2.
```
`foo(1, 2)` 返回真,所以你可能会认为 `f(A, B)` 会返回 `A=1, B=2`。但它却返回了 `false`。而下面这个就能按预期工作:
```prolog
bar(A, B) :-
A = 1,
B = 2,
\+ (A = B).
```
我*以为*这是因为 `\+` 是用截断实现的,而 [Clocksin 的书](https://www.amazon.com/Programming-Prolog-Using-ISO-Standard/dp/3540006788) 暗示它是 `call(P), !, fail`,所以我把这个当成了“截断令人困惑”的主要例子。但后来我试了这个:
```prolog
?- member(A, [1,2,3]), \+ (A = 2), A = 3.
A = 3. % 这他喵是为什么?
```
用截断根本不可能得到这种效果!我觉得 `\+` 根本没用截断!现在我还得弄清楚为什么 `foo(A, B)` 不返回结果。是因为 [floundering](https://github.com/dtonhofer/prolog_notes/blob/master/other_notes/about_negation/floundering.md)?还是因为 `\+ P` 只在 `P` 失败时才成功,而 `A = B` 永远会成功?是封闭世界假设?还是别的什么原因?[^1]
[^1]: 作者注:以后再搞清楚。
### 超出默认查询范围时令人困惑
假设我有这样一个程序:
```prolog
tree(n, n1).
tree(n, n2).
tree(n1, n11).
tree(n2, n21).
tree(n2, n22).
tree(n11, n111).
tree(n11, n112).
branch(N) :- % 有两个子节点
tree(N, C1),
tree(N, C2),
C1 @< C2. % 排序
```
我想知道所有作为分支父节点的节点。通常的查询方式是:
```prolog
?- tree(A, N), branch(N).
A = n, N = n2; % 显示更多...
A = n1, N = n11.
```
这交互式地让我逐个请求每个结果。但通常我并不想要这样——我知道查询结果是有限的,我想一次获得所有结果,以便对其进行计数或处理。花了好一阵子我才搞明白,正确的做法是使用 `bagof(Template, Goal, Bag)`(https://www.swi-prolog.org/pldoc/man?predicate=bagof/3),它会“用 Template 的备选值统一 Bag”:
```prolog
?- bagof(A, (tree(A, N), branch(N)), As).
As = [n1], N = n11;
As = [n], N = n2.
```
等等靠,这仍然是一次给一个结果,因为 `N` 在 `bagof` 中是自由变量,所以它会对 `N` 回溯。这让我意外,但作为选项也许也不错。那么如何一次获得所有结果呢?
```prolog
?- bagof(A, N^(tree(A, N), branch(N)), As).
As = [n, n1]
```
唯一的区别是 `N^Goal`,它告诉 `bagof` 忽略 `N` 并将其结果分组。据我所知,这是 ISO 标准中唯一将 `^` 用于幂运算之外的地方。据说这是[存在量词](https://sicstus.sics.se/sicstus/docs/latest4/html/sicstus.html/ref_002dall_002dsum.html)?总的来说,每当我试图超出简单用例,特别是想以非交互方式做事时,就会遇到麻烦。
### 对符号项(symbol terms)的感情很复杂
我花了好久才意识到 `bagof` “能工作”的原因是中缀符号被映射为前缀复合项,使得 `a^b` 实际上是 `^(a, b)`,然后不同的谓词可以决定如何处理 `^(a, b)`。
这也是为什么你不能直接写 `A = B+1`:这将 `A` 统一为*复合项* `+(B, 1)`。`A+1 = B+2` 是*假*,因为 `1 \= 2`。你必须写 `A+1 is B+2`,因为 `is` 运算符才会把 `+(B, 1)` 解释为数学项。
(而且*那个*也会失败,因为 `is` 不是完全双向的。左边*必须*是单一变量。你必须导入 `clpfd` 并写 `A + 1 #= B + 2`。)
我不喜欢这样,但这么说有点虚伪,因为我欣赏这个想法,在其他语言中也不介意自定义符号。我想让我恼火的是,对于 `^(a, b)` 是什么并没有官方定义,纯粹是约定俗成。ISO Prolog 用 `-(a, b)`(也就是 `a-b`)作为“配对”(pair)的约定,而意识到这一点的唯一方法就是看到大量标准模块都在使用这个约定。但你完全可以在自己的代码中用 `-(a, b)` 表示其他含义,并且不会有任何警告提示不一致。
总之我吐槽了配对,所以也可以吐槽 `sort`。
### 排序,你醉了吗
这个问题纯粹是设计失误:
```prolog
?- sort([3,1,2,1,3], Out).
Out = [1, 2, 3]. % 什么鬼?
```
据一位专家在线解释,这是因为 `sort` 应该返回排序后的*集合*(set),而不是排序后的列表。如果你想保留重复项,就必须把所有值提升为 `-($key, $value)` 形式的复合项,然后使用 `keysort`(https://eu.swi-prolog.org/pldoc/doc_for?object=keysort/2),再提取值。而且,由于没有函数,这个过程至少需要三行代码。这也是实现自定义排序(比如按复合项的第二个值排序)的标准方法。
(大多数(但不是全部)发行版都有保留重复项的合并排序函数,比如 `msort`(https://eu.swi-prolog.org/pldoc/doc_for?object=msort/2)。SWI-Prolog 还有 `predsort`(https://eu.swi-prolog.org/pldoc/doc_for?object=predsort/3)可以按键排序,但会去掉重复项。)
### 求求了,让我用逗号而不是句号结束规则行吧
我不在乎这样会不会导致事实解析产生歧义,我只希望“交换两行”不再是语法错误。
---
我预料到明天这个时候,我可能会被 Cunningham 综合征击中,有篇 2000 字的文章会解释我的所有抱怨要么通过 XXX 轻松解决,要么是 Prolog 能做的最佳选择。即使是在写这篇文章的过程中,我也发现了一些之前问题的解决办法。比如我本来要抱怨无法从命令行运行 SWI-Prolog 查询,但在我尽职调查*终于*终于搞明白了:
```bash
swipl -t halt -g "bagof(X, Goal, Xs), print(Xs)" ./file.pl
```
虽然有点笨拙,但总比以前每次想验证脚本修改时都必须进入交互式会话要好。
(另外,回答集编程(Answer Set Programming)真的很酷。很期待在书里写它!)
相似文章
Prolog编程的陷阱
关于Prolog编程中常见陷阱的指南,强调使用纯声明式构造而非不纯的构造,如cut、全局状态和低级I/O。
我讨厌编译器
一篇表达对编译器不满的个人观点文章,可能讨论其复杂性或缺点。
用宝可梦解释 Prolog 基础
通过宝可梦属性相克作为示例,介绍 Prolog 编程,展示逻辑编程如何优雅地建模关系数据。
新博客文章:我写的一些傻乎乎的Z3脚本
Hillel Wayne分享了他编写的Z3脚本,讨论了逻辑性质方面的挑战以及他即将出版的书籍Logic for Programmers中的“糟粕”概念。
Prolog项目技巧
使用Prolog脚本自动发布Prolog项目的技巧:递增版本号、提交、打标签,并通过SWI-Prolog的包系统注册新包版本。