语言实现破坏语言保证时,人们会感到困惑
摘要
TLA+ 语义保证无序更新,但 TLC 模型检查器通过要求有序赋值并添加如 PrintT 等有副作用的运算符来破坏这些保证,导致初学者感到困惑。
<p>以下面的 Python 程序为例:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># x = 1, y = 2</span>
<span class="n">x</span> <span class="o">=</span> <span class="mi">0</span>
<span class="n">y</span> <span class="o">=</span> <span class="n">x</span>
<span class="nb">print</span><span class="p">([</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">])</span>
</code></pre></div>
<p>它会打印 <code>[0, 0]</code>。如果我们交换两个赋值语句,则会打印 <code>[0, 1]</code>。每个赋值发生在独立的时间步骤中。几乎所有的命令式语言都是这样工作的。</p>
<p>现在来看下面的 TLA+ 片段:</p>
<div class="codehilite"><pre><span></span><code>\* x = 1, y = 2
/\ x' = 0
/\ y' = x
/\ PrintT(<<x', y'>>)
</code></pre></div>
<p>这会打印 <code><<0, 1>></code>。与命令式语言不同,TLA+ 将更新和时间步骤的概念分开。我们将 <code>x' = 0</code> 理解为“在<em>下一个</em>状态中,<code>x</code> 将为 0,但在当前状态中它仍然具有相同的值”。因此,在每个状态中,<code>x</code> 和 <code>x'</code> 本质上是不同的变量。因此,在 TLA+ 语义中,语句的顺序无关紧要,交换两个赋值不会改变打印的输出。这门语言非常巧妙!这意味着,除其他外,基本上没有步骤内的竞争条件。一个函数可以更新一个变量,而不会影响其他函数如何使用它。</p>
<p>好了,现在初学者不可避免地会遇到一个问题:</p>
<div class="codehilite"><pre><span></span><code>\* x = 1, y = 2
/\ x' = y'
/\ y' = x
/\ PrintT(<<x', y'>>)
</code></pre></div>
<p>这会崩溃,因为 <code>y'</code> 尚未定义。但如果交换两个赋值,这就能正常工作了,并打印 <code><<1, 1>></code>。显然,关于无序的说法完全是胡扯。</p>
<p>嗯,并非如此。TLA+ 语义仍然保证无序。问题在于验证器并没有完美地实现 TLA+ 语义。<code>y' > 0</code> 是一个完全合理的“赋值”,但有无穷多个可能的下一个状态!因此,主模型检查器 (TLC) 要求 <code>y' = some_value</code> 出现在任何其他使用 <code>y'</code> 之前,这意味着顺序现在很重要。</p>
<p>更大的问题是 <code>PrintT</code>。TLA+ 语义保证无序,因为语义不允许副作用。模型检查器添加了有副作用的运算符,如 <code>PrintT</code>、<code>Assert</code> 和 <code>IOExec</code>。这可能会导致保护语句出现问题。从理论上讲,以下两个脚本块是等价的:</p>
<div class="codehilite"><pre><span></span><code>/\ x = 0 \* guard statement
/\ P()
/\ x' = x + 1
/\ x' = x + 1
/\ P()
/\ x = 0 \* guard statement
</code></pre></div>
<p>当 <code>x = 1</code> 时,由于保护子句,这些不会导致新状态。但模型检查器逐行评估,这意味着在块 2 中,它会在到达 <code>x = 0</code> 并丢弃状态之前执行 <code>x' = x + 1</code> 和 <code>P()</code>。如果 <code>P</code> 是一个正常的 TLA+ 运算符,这没问题,但如果是 <code>PrintT</code> 或 <code>Assert</code>,它会先产生效果,导致奇怪的幽灵打印,这些打印不对应任何下一个状态。</p>
<p>“TLA+ 语义保证的内容”与“TLC 可能破坏这些保证的具体方式”之间的这种差异是人们感到困惑的主要来源!除此之外,许多这些运算符,如 <code>IOExec</code> 和 <code>TLCSet</code>,都被设计为逃生舱口。所以如果你需要它们,你已经是在做一些相当奇怪的事情了,这会让事情更加混乱。</p>
<p>除此之外,破坏保证的 TLC 运算符和常规安全的 TLA+ 运算符之间没有语法或视觉上的区分。在编译型语言中,你有编译指示和预处理器,它们让编译器做语言本身不能做的事情。但这些通常有视觉上不同的语法,所以你一看就知道这里有危险。</p>
<p>我想起了 Neel Krishnaswami 的精彩文章<a href="https://semantic-domain.blogspot.com/2013/07/what-declarative-languages-are.html" target="_blank">《什么是声明式语言》</a><sup id="fnref:laurie"><a class="footnote-ref" href="#fn:laurie">1</a></sup>:</p>
<blockquote>
<p>这也让我们可以预测,任何声明式语言中最不受欢迎的特性将是那些暴露操作模型并破坏声明式语义的特性。因此我们可以预测人们会不喜欢 (a) 正则表达式中的反向引用,(b) 文法中的有序选择,(c) 查询语言中的行 ID,(d) Prolog 中的 cut,(e) 约束语言中的约束优先级。</p>
</blockquote>
<p>Prolog 的 cut 具有视觉上不同的语法,但使用 cut 的谓词与<a href="https://www.metalevel.at/prolog/purity" target="_blank">逻辑纯</a>谓词在视觉上并不不同。但这比我们在 TLA+ 中遇到的情况稍微不那么棘手,因为 cut 仍然是语言语义的一部分。</p>
<p>(话说回来,不同的 Prolog 方言有不同的<a href="https://eu.swi-prolog.org/pldoc/man?section=printmsg" target="_blank">打印字符串</a>方式,这给 Prolog 添加了副作用,并且它们在视觉上与其他谓词没有区别。所以同样的问题!)</p>
<p>我实际上对此没有任何修复。我只是觉得这是一个引人入胜的<a href="https://en.wikipedia.org/wiki/Leaky_abstraction" target="_blank">泄漏抽象</a>的例子。也许我们可以写一个代码高亮器,高亮所有传递性地使用“奇怪”函数的函数之类的。</p>
<div class="footnote">
<hr />
<ol>
<li id="fn:laurie">
<p><a href="https://tratt.net/laurie/blog/2013/relative_and_absolute_levels.html" target="_blank">必读的回应文章</a> <a class="footnote-backref" href="#fnref:laurie" title="跳回正文中的脚注1">↩</a></p>
</li>
</ol>
</div>
查看缓存全文
缓存时间: 2026/05/16 03:38
# 当语言实现破坏语言保证时,人们会感到困惑
来源:https://buttondown.com/hillelwayne/archive/people-get-confused-when-language-implementations
以如下 Python 程序为例:
```
# x = 1, y = 2
x = 0
y = x
print([x, y])
```
它会打印 `[0, 0]`。如果我们调换两个赋值语句,则会打印 `[0, 1]`。每个赋值发生在独立的时间步中。几乎所有命令式语言都是这样运行的。
现在来看以下 TLA+ 片段:
```
\* x = 1, y = 2
/\ x' = 0
/\ y' = x
/\ PrintT(<<x, y>>)
```
它会打印 `<<0, 1>>`。与命令式语言不同,TLA+ 将“更新”与“时间步”的概念分离开来。我们将 `x' = 0` 理解为“在*下一*状态中,`x` 将为 0,但在当前状态中它仍保持原值”。因此,在每个状态中,`x` 和 `x'` 本质上是独立的变量。由此带来的结果是,在 TLA+ 语义中语句的顺序无关紧要,调换两个赋值语句并不会改变打印输出。这个语言就是这么聪明!
这意味着,除其他事项外,基本上不存在步内竞争条件。一个函数可以更新某个变量,而不会影响其他函数对该变量的使用。
好了,现在初学者不可避免会遇到这个问题:
```
\* x = 1, y = 2
/\ x' = y'
/\ y' = x
/\ PrintT(<<x, y>>)
```
这会崩溃,因为 `y'` 尚未定义。但如果我们调换两个赋值语句,它就能正常工作,并打印 `<<1, 1>>`。所以,之前关于“顺序无关”的说法显然是胡说八道。
嗯,也不尽然。TLA+ 语义仍然保证顺序无关。问题是验证器并没有完美实现 TLA+ 语义。`y' > 0` 是一个完全合理的“赋值”,但存在无穷多种可能的下一状态!因此,主模型检查器 (TLC) 要求 `y' = some_value` 必须出现在任何其他使用 `y'` 的操作之前,这就使得顺序变得重要了。
更大的问题出在 `PrintT` 上。TLA+ 语义保证顺序无关,是因为语义本身不允许副作用。而模型检查器加入了像 `PrintT`、`Assert` 和 `IOExec` 这样的有效运算符。这会导致守卫语句出现问题。理论上来讲,以下两个脚本块是等价的:
```
/\ x = 0 \* 守卫语句
/\ P()
/\ x' = x + 1
/\ x' = x + 1
/\ P()
/\ x = 0 \* 守卫语句
```
当 `x = 1` 时,由于守卫子句,它们都不会导致新状态。但模型检查器会逐行求值,这意味着在第二个块中,它会在遇到 `x = 0` 并丢弃该状态之前,先执行 `x' = x + 1` 和 `P()`。如果 `P` 是一个纯正的 TLA+ 运算符,这没问题;但如果它是 `PrintT` 或 `Assert`,则其副作用会先发生,导致产生一些与任何下一状态都不对应的幽灵打印输出。
这种“TLA+ 语义保证的内容”与“TLC 可能破坏这些保证的具体方式”之间的差异,是造成人们巨大困惑的根源!更糟糕的是,许多这样的运算符,如 `IOExec` 和 `TLCSet`,本身被设计为逃生舱。因此,如果你需要它们,说明你已经在做一些相当奇怪的事情,这会让情况更加令人困惑。
雪上加霜的是,在语法或视觉上,破坏保证的 TLC 运算符与常规安全的 TLA+ 运算符之间没有任何区别。在编译型语言中,有编译指示和预处理器,它们能让编译器做语言本身无法做到的事情。但这些通常具有视觉上不同的语法,看一眼就知道这里有坑。
我想起 Neel Krishnaswami 的精彩文章《声明式语言是什么》(https://semantic-domain.blogspot.com/2013/07/what-declarative-languages-are.html)¹ (https://buttondown.com/hillelwayne/archive/people-get-confused-when-language-implementations#fn:laurie) 中所言:
> 这也让我们可以预测,任何声明式语言中最不受欢迎的特性,将是那些暴露操作模型并破坏声明式语义的特性。因此我们可以预测,人们会不喜欢 (a) 正则表达式中的反向引用、(b) 语法中的有序选择、(c) 查询语言中的行 ID、(d) Prolog 中的 cut、(e) 约束语言中的约束优先级。
Prolog 的 cut 具有视觉上不同的语法,但使用 cut 的谓词与一个纯逻辑 (https://www.metalevel.at/prolog/purity) 谓词在视觉上并无区别。不过这比我们在 TLA+ 中遇到的情况要稍微简单一些,因为 cut 仍然是语言语义的一部分。(不过话说回来,不同的 Prolog 方言有不同的字符串打印方式 (https://eu.swi-prolog.org/pldoc/man?section=printmsg),这给 Prolog 增添了副作用,而且它们在视觉上与其他谓词无法区分。所以同样的问题!)
我实际上没有任何修复方案。我只是觉得这是一个关于泄漏抽象 (https://en.wikipedia.org/wiki/Leaky_abstraction) 的迷人例子。也许我们可以编写一个代码高亮器,高亮所有传递性使用了“奇怪”函数的函数。
相似文章
面向LLM时代的TLA+入门:用提示词取胜
介绍如何结合TLA+与Claude等LLM编写形式化规约,展示LLM如何在语法上提供帮助,同时专注于正确性。
编程语言如何影响 token 效率和正确性?
Dan Luu 批评了关于动态语言对 LLM 更节省 token 的说法,指出现有评估中的缺陷,并强调需要更好的基准测试方法。
语言模型如何失败:承诺性与持续性推理失败的词元级特征
本文通过词元级不确定性信号,刻画了语言模型在推理中失败的两种不同过程——承诺性失败与持续性不确定性,并展示了其对自一致性及失败检测策略的启示。
查询语言中的求值顺序与非终止性
一篇博客文章,讨论了类似λFS的函数式关系查询语言中的求值顺序与非终止性,并引用了在FLOPS 2026上发表的关于有限函数式编程的论文。
量化LLM推理中的无声失败:基于分类法的空洞收敛与失败模式转变分析
本文通过一个基于分类法的分析,对多个模型和基准测试中的30,000个思维链输出进行研究,证明了训练后量化可以无声地改变大语言模型的推理方式,即使任务准确性保持不变。