程序逻辑
程序逻辑是连接程序与逻辑的桥梁,它使用形式逻辑的规则来对程序的行为进行推理和验证。其核心思想是,程序的属性(如正确性)可以用逻辑公式来描述和证明。
第一步:从程序正确性问题到逻辑断言
想象一个非常简单的程序,比如一个将变量 x 的值加 1 的程序。我们如何能“证明”这个程序确实做了我们期望的事情?一种自然的方式是描述程序执行前和执行后程序状态应该满足的条件。
- 前置条件:描述程序开始执行前必须成立的条件。例如,对于
x = x + 1,一个简单的前置条件可以是“x的初始值为 5”,记作{x = 5}。 - 后置条件:描述程序成功执行结束后必须成立的条件。对于我们的例子,后置条件是“
x的值变为 6”,记作{x = 6}。
我们将这对条件放在程序代码的周围,写成:
{x = 5}
x = x + 1;
{x = 6}
这被称为一个霍尔三元组。它表达了一个逻辑断言:如果程序开始于满足前置条件的状态,并且程序成功终止,那么它终止时的状态必然满足后置条件。
第二步:为不同的程序结构定义推理规则
简单的赋值语句很容易理解。但程序包含复杂的结构,如顺序执行、条件分支和循环。程序逻辑为每种结构定义了严格的逻辑推理规则,使我们能够将大型程序的证明分解为小型部分的证明。
-
赋值公理:这是最基础的规则。对于赋值语句
x := E(其中E是一个表达式),其规则是:
{P[E/x]} x := E {P}
解释:如果希望赋值后条件P成立,那么赋值前,必须有一个条件成立,这个条件是将P中所有x的出现替换为表达式E后得到的结果。- 例子:为了在
x := x + 1后得到{x > 5},那么赋值前必须满足{x + 1 > 5},即{x > 4}。所以三元组{x > 4} x := x + 1 {x > 5}是有效的。
- 例子:为了在
-
顺序规则:用于处理连续执行的语句
S1; S2。
如果 {P} S1 {Q} 且 {Q} S2 {R},那么 {P} S1; S2 {R}
解释:我们找到一个中间条件Q。如果S1能将P变为Q,而S2能将Q变为R,那么顺序执行这两个语句就能将P变为R。 -
条件规则:用于处理
if语句。
如果 {P ∧ B} S1 {Q} 且 {P ∧ ¬B} S2 {Q},那么 {P} if B then S1 else S2 {Q}
解释:要证明条件语句结束后Q成立,我们需要分别证明:- 当条件
B为真时,执行S1能保证Q。 - 当条件
B为假时,执行S2也能保证Q。
并且,在进入条件语句之前,条件P必须成立。
- 当条件
第三步:处理循环——引入不变式
循环是程序推理中最具挑战性的部分,因为其执行次数在编写证明时可能是未知的。程序逻辑通过引入循环不变式 来解决这个问题。
对于循环 while B do S,其推理规则是:
如果 {P ∧ B} S {P},那么 {P} while B do S {P ∧ ¬B}
这里的 P 就是循环不变式。
- 循环不变式是什么? 它是一个逻辑条件,在循环的每次迭代开始和结束时都必须为真。它就像是循环的“稳态”属性。
- 如何工作?
- 初始化:在循环开始前,不变式
P必须成立(由{P}保证)。 - 保持:如果某次迭代开始时
P为真,并且循环条件B也为真,那么执行循环体S后,P应该仍然为真(由{P ∧ B} S {P}保证)。 - 终止:当循环结束时(即
B变为假),我们得到两个事实:不变式P仍然成立,并且条件B为假。因此后置条件是P ∧ ¬B。
- 初始化:在循环开始前,不变式
选择合适的不变式是证明循环正确性的关键。
第四步:从基础程序逻辑到其扩展
上面描述的是最基础的霍尔逻辑,适用于简单的命令式程序。程序逻辑的概念被广泛扩展,以处理更复杂的编程范式和属性:
- 面向对象程序逻辑:需要处理对象、类、继承和多态性,引入诸如“可见性”和“方法规范”等概念。
- 并发程序逻辑:用于推理同时执行的多个线程或进程。这需要处理竞态条件、死锁等问题。著名的扩展包括分离逻辑,它能优雅地处理指针和动态内存分配,是验证并发程序的有力工具。
- 函数式程序逻辑:由于函数式编程(基于
λ演算)的核心是表达式求值而非状态改变,其程序逻辑更侧重于基于类型的推理和等式推理,与 Curry-Howard 对应紧密相关。 - 广义上的应用:程序逻辑不仅是理论工具,也是许多实用技术的基础,如静态分析器、程序验证工具(例如 Microsoft 的 Dafny、Facebook 的 Infer)和编译器优化,这些工具在后台自动或半自动地应用这些逻辑规则来查找 bug 或证明程序属性。
总结来说,程序逻辑提供了一个形式化的框架,将程序代码的操作语义转化为可以进行数学证明的逻辑公式,从而使我们能够严格地论证程序是否按照我们的意图行事。