跳转至

一个简单的 Coq 车祸课程:一阶逻辑

约 1926 个字 144 行代码 预计阅读时间 8 分钟

好久没更新,今天还是摸个简单的。这是前几天自己对着文档和各种教程摸出来的东西,随便做个总结。这里的几个例子都来源于 Andrej Bauer 的油管视频

特别简单的一阶逻辑证明:Frobenius rule

我们先写出逻辑形式:

\[ (\exists x \in A, q \wedge p(x)) \iff (q \wedge (\exists x \in A, p(x))) \]

从直觉的解释上讲,证明这玩意并不困难。当然我们只要表明说,存在的这个 \(x\) 是一样的。下面我们用 Coq 写出这个形式:

Theorem frobenius (A : Set) (p : A -> Prop) (q : Prop):
  (exists x : A, q /\ p x) <-> (q /\ exists x : A, p x).

这看上去其实和逻辑表达式没什么两样,是吧?为了表明我们下面要证明它,我们用 Proof. 作为下一行的内容,后面的东西就是真正的证明。

如果你偷懒,发现这东西在一阶逻辑内是很自然的,那么不妨直接使用 firstorder 的策略给出结果,整个形式就是:

Theorem frobenius (A : Set) (p : A -> Prop) (q : Prop):
  (exists x : A, q /\ p x) <-> (q /\ exists x : A, p x).
Proof.
  firstorder.
Qed.

特别完美,而且能过检查。但是作为车祸课程,不如展开写一下证明。

当我们看到一个当且仅当语句的时候,我们很自然地会想到先证明充分性再证明必要性。对于 Coq 来说也是如此。为了表明我们要将其分开来证,我们先使用 split 策略,得到的子目标如下:

2 subgoals

A : Set
p : A -> Prop
q : Prop

========================= (1 / 2)

(exists x : A, q /\ p x) -> q /\ (exists x : A, p x)

========================= (2 / 2)

q /\ (exists x : A, p x) -> (exists x : A, q /\ p x)

可以发现,它只把第一个子目标写清楚了,第二个子目标只是简单地标在下面。然后对于这种蕴含语句,我们的证明策略往往是将 \(\vDash (p \to q)\) 转变成 \(p \vDash q\) 来做。这就意味着,要把前面的东西引入成新假设。因此我们使用 intros 策略。

这时的子目标如下:

2 subgoals

A : Set
p : A -> Prop
q : Prop
H : exists x : A, q /\ p x

========================= (1 / 2)

q /\ (exists x : A, p x)

========================= (2 / 2)

q /\ (exists x : A, p x) -> (exists x : A, q /\ p x)

注意到,在横线上面的假设里面多了一个 H,横线下面的目标则发生了变化。

那么,怎么用这种假设呢?考虑平时的证明思路,往往是把存在命题做类似斯科伦化的想法,取出一个 x0 来满足这个东西。这里用的是 destruct H 来表达这个事情,把假设变成了:

x : A
H : q /\ p x

对于与命题,当然我们也可以拆开看,因此我们再 destruct H 一次,这就把 H 变成了:

H : q
H0 : p x

稍微回顾一下。现在,我们的证明到了这一步:

Theorem frobenius (A : Set) (p : A -> Prop) (q : Prop):
  (exists x : A, q /\ p x) <-> (q /\ exists x : A, p x).
Proof.
  split.
  intros.
  destruct H.
  destruct H.

这样写看起来还是有点复杂了,所以事实上我们可以在 intros 里面干掉很多事情。先回滚证明的进度,然后把 intros 改成 intros [x0 H],这时我们注意到假设变成了:

x0 : A
H : q /\ p x0

哈,这是不是意味着 intros 事实上可以完成 destruct 的部分工作?答案是肯定的,我们更进一步地把它改成 intros [x0 [H1 H2]],就能直接得到:

x0 : A
H1 : q
H2 : p x0

于是我们用下面的代码完成了和上面一样的操作:

Theorem frobenius (A : Set) (p : A -> Prop) (q : Prop):
  (exists x : A, q /\ p x) <-> (q /\ exists x : A, p x).
Proof.
  split.
  intros [x0 [H1 H2]].

再看一眼目标窗口,我们剩下的东西是:

2 subgoals

A : Set
p : A -> Prop
q : Prop
x0 : A
H1 : q
H2 : p x0

========================= (1 / 2)

q /\ (exists x : A, p x)

========================= (2 / 2)

q /\ (exists x : A, p x) -> (exists x : A, q /\ p x)

现在我们把假设拆解得差不多了,我们开始拆解目标。当然咯,目标是一个与公式,我们就想要把两个分开证明,接着用 split 策略,我们的第一个目标就只剩下一个 q 了,于是我们用 assumption 告诉 Coq 这个东西已经在假设里了,这就完成了第一个目标。

第二个目标要证明存在性,这就要我们告诉 Coq 这个存在的东西就是 x0。用 exists x0 来完成这一步。这时就只剩下 p x0 了,以 assumption 收尾。

当然,assumption 也可以用 exact H2 替代,这不过是显式地告诉 Coq 这东西到底是哪个假设。

好,我们已经摆平了第一个子目标。现在我们的代码大概长这样:

Theorem frobenius (A : Set) (p : A -> Prop) (q : Prop):
  (exists x : A, q /\ p x) <-> (q /\ exists x : A, p x).
Proof.
  split.
  intros [x0 [H1 H2]].
  split.
  assumption.
  exists x0.
  assumption.

后半部分的证明没什么难度,或者说和前面类似,这里直接给出证明完之后的版本:

Theorem frobenius (A : Set) (p : A -> Prop) (q : Prop):
  (exists x : A, q /\ p x) <-> (q /\ exists x : A, p x).
Proof.
  split.
  intros [x0 [H1 H2]].
  split.
  assumption.
  exists x0.
  assumption.
  intros [H1 [x0 H2]].
  exists x0.
  split; assumption.
Qed.

我们用 Qed 来标识证明的结束。这里还需要说明的一点是 split; assumption. 这个句子,它事实上就是

split.
assumption.
assumption.

的简写,分号表明对于分裂之后的两个子目标使用相同的策略。

对偶命题和排中律

把原来那个东西对偶化一下,我们得到的结果是:

\[ (\forall x \in A, q \vee p(x)) \iff (q \vee (\forall x \in A, p(x))) \]

看上去很显然,是……吗?不妨直接用 firstorder 策略做一下。嘿,您猜怎么着,挂了!但是,为什么?虽然 firstorder 并非万能,但是我们不妨思考一下按照常规的思路证明这个东西需要什么。首先,要么 \(q\) 成立,要么 \(q\) 不成立;如果……这里事实上已经出现了问题:我们引入了排中律。因此,这个命题只有在排中律的前提下才能得到证明。为了表明这一点,我们使用 Coq 写一个比较明白的版本:

Definition lem := forall p, p \/ ~p.

Definition dual_frobenius := forall (A : Set) (p : A -> Prop) (q : Prop), 
  (forall x : A, q \/ p x) <-> (q \/ forall x : A, p x).

Theorem lem_to_dual_frobenius : lem -> dual_frobenius.
Proof.

好,这样我们的证明就可以正式开始了。从此往下,我们将不再避免使用 firstorder 策略完成证明,一方面,验证一阶逻辑不是 Coq 的重点,另一方面,一阶逻辑的解算本身也已经相对成熟。

现在我们的子目标长这样:

1 subgoal


========================= (1 / 1)

lem -> dual_forbenius

第一步是将它展开,使用 unfold,得到的子目标变为:

(forall p : Prop, p \/ ~ p) -> 
forall (A : Set) (p : A -> Prop) (q : Prop), 
(forall x : A, q \/ p x) <-> (q \/ forall x : A, p x)

然后用 firstorder 整理一下,得到:

1 subgoal

H : forall p : Prop, p \/ ~ p
A : Set
p : A -> Prop
q : Prop
H0 : forall x : A, q \/ p x

========================= (1 / 1)

q \/ (forall x : A, p x)

然后我们要做的就是分类讨论:要么 q,要么 ~q,这由 H 的排中律保证。因此,将 H 应用于 q 然后 destruct,分别用 firstorder 简化得到结果。

最终的结果为:

Theorem lem_to_dual_frobenius : lem -> dual_frobenius.
Proof.
  unfold lem, dual_frobenius.
  firstorder.
  destruct (H q); firstorder.
Qed.

很好玩的是,这条对偶的 Frobenius rule 和排中律正好是等价的。这里写出一个证明:

Theorem dual_frobenius_to_lem : dual_frobenius -> lem.
Proof.
  unfold dual_frobenius, lem.
  firstorder.
  apply H.
  firstorder.
Qed.

不妨观察一下每一步发生了什么。事实上,在第一次 firstorder 之后,它已经变成了下面的样子:

1 subgoal

H : forall (A : Set) (p : A -> Prop) (q : Prop),
    (forall x : A, q \/ p x) <-> q \/ (forall x : A, p x)
p : Prop

========================= (1 / 1)

p \/ ~p

下一步我们要干嘛?我们想要做的事情是把 H 中的 p 取成恒假命题……稍等,为了避免混淆,我们不妨先把假设中的 p 重命名为 p0

rename p into p0.

接下来的步骤在字面上看上去很简单,一个 apply H 就几乎完成了。事实上,这里用到了 tactic-unification 的策略,参见官方文档对 apply 的描述。根据指引,我们在 Theorem 之前加上 Set Debug "tactic-unification". 一行观察一下。

设置完之后,再转到 apply 这行之后,观察一下信息面板中的输出。非常长,不妨直接拉到最后第一个成功的地方:

[tactic-unification] Starting unification: ?M1406 \/ 
                                           (forall x : ?M1404, ?M1405 x) ~= 
p0 \/ ~ p0

[tactic-unification] ?M1406 \/ (forall x : ?M1404, ?M1405 x) ~= p0 \/ ~ p0

...

[tactic-unification] Leaving unification with success

这才是真正起到作用的部分。它事实上类似于对所有假设和目标做了模式匹配,这里成功的部分是对 H 的右半段做了匹配。它发现,取 qp0,取 forall x : A, p x~ p0 的时候,正好能够完成匹配。在发现这点的能力上,它几乎要比人类还强。

于是,它发现,欲证目标,只需将如此应用之后的 H 的左半边证明,那就只剩下了 p0 -> p0 \/ False,用 firstorder 直接结束了证明。

挖个小坑,这些 ?M1404 之类的东西事实上是 de Bruijn index,后面大概会抽出一点时间讲讲这个东西的用处和实现。

到现在,虽然我们已经证明了一些一阶逻辑的结论,但诚如前面所说,Coq 的威力远不止于一阶逻辑。本文只是为初学者快速上手提供便利,若要深入学习,还需花费更多的精力。