证明中断不是随机的。Coq 报错信息有规律,每种卡住模式都有对应的诊断手段和修复路径。本文整理六类高频卡住模式,逐一给出错误现场、根因和处理方法,附诊断工具用法。

诊断工具速览

下列诊断命令不参与证明,只用于观察状态,本文各节均会用到。

1
2
3
4
5
6
7
Set Printing All.        (* 显示所有隐式参数、强制转换、记号展开 *)
Set Printing Universes. (* 在类型中显示宇宙层级标注 *)
Check @term. (* 显示 term 的完整类型,含所有显式参数 *)
About ident. (* 显示 ident 的来源、类型、透明度等元信息 *)
Print ident. (* 显示 ident 的定义或公理 *)
Show Proof. (* 在证明过程中打印当前 proof term 的骨架 *)
Print Assumptions thm. (* 列出 thm 依赖的全部公理和假设 *)

Set Printing All 是最常用的第一步:Coq 的记号、隐式参数和强制转换会把真实项目遮住,打开这个选项后目标才算"裸眼可读"。

模式一:目标中出现未预期的存在变量

错误现场

evar 留在目标里时,报错分三个时机,文本各不相同:

1
2
3
4
5
6
7
8
(* 时机一:refine / _ 留下的洞无法从上下文推断 *)
Error: Cannot infer this placeholder of type nat.

(* 时机二:apply ex_intro 之后见证始终没被定下来 *)
Error: Unable to find an instance for the variable n.

(* 时机三:前面都没报,一路拖到 Qed 才炸 *)
Error: Attempt to save an incomplete proof (unresolved existential variables).

或者根本不报错,目标里就明摆着一个洞:

1
2
3
4
1 goal

============================
?Goal

存在变量(existential variable,evar)在目标或上下文中以 ?x?Goal 的形式出现。某些情况下 Coq 不报错,而是让 evar 留在目标里,直到某个 tactic 试图匹配它才失败。

根因分析

evar 的主要来源有三个。

第一,eapply 留下了未解决的参数。eapply f 会为 f 的每个无法立即推断的参数生成 evar,这些 evar 必须在后续 tactic 中被实例化。

1
2
3
4
5
Example evar_demo : exists n : nat, n = 0.
Proof.
eapply ex_intro. (* 此时目标变为 ?n = 0,?n 是 evar *)
reflexivity. (* reflexivity 把 ?n 实例化为 0 *)
Qed.

第二,refine 留下了占位符。refine 允许在项中留 _,每个 _ 产生一个 evar 子目标。

第三,某些 tactic 组合在非预期的顺序下,产生了循环依赖的 evar,Coq 无法自动解开。

修复方法

instantiate 显式给出 evar 的值:

1
2
3
4
5
Proof.
eapply ex_intro.
instantiate (1 := 0). (* 将第一个 evar 实例化为 0 *)
reflexivity.
Qed.

或者改用 apply 加完整参数,完全避免 evar:

1
2
3
4
Proof.
apply (ex_intro _ 0).
reflexivity.
Qed.

诊断步骤:出现奇怪 evar 时,先执行 Show Proof. 查看当前 proof term,evar 在输出中以 ?evar_N 标注,结合上下文可以判断哪一步引入了它。

1
2
3
4
5
6
Lemma check_evar : 1 + 1 = 2.
Proof.
Show Proof. (* 当前是 ?Goal,proof term 还是空的 *)
reflexivity.
Show Proof. (* 变成 @eq_refl nat 2 *)
Qed.

模式二:宇宙不一致错误

错误现场

1
2
Error: The term "T" has type "Type@{i}" while it is expected to have type
"Type@{j}" (Universe inconsistency: Cannot enforce j < i because i < j).

或者更简短的版本:

1
Error: Universe inconsistency.

根因分析

Coq 的类型层级是 Prop : Type(1)Set : Type(1)Type(i) : Type(i+1),层级从 i ≥ 1 起——没有 Type(0)Set 也不是 Type(0) 的别名,它是与 Prop 并列的 base sort。宇宙不一致的核心原因是试图把某个 Type(i) 的值放进需要 Type(j) 的位置,而约束解不出来。

这里有个反直觉的地方要先排掉:

1
2
(* 这个是合法的,Fail 会报 The command has not failed! *)
Definition ok : Type := Type.

原因是每次写 Type 都会分配一个新的宇宙变量,上面这行的约束是 i < j,可解。从用户视角看 Coq 里就是 Type : Type。真正炸掉需要让常量把宇宙固定住:

1
2
3
4
Definition U := Type.
Fail Definition inconsistent : U := U.
(* U := Type@{u} : Type@{u+1},这行要求 Type@{u+1} <= Type@{u},
即 u+1 <= u,无解 *)

另一个典型场景是多态函数被实例化到过高的层级:

1
2
Definition identity (A : Type) (x : A) := x.
(* identity 本身是宇宙多态的,但某些用法会强制层级关系 *)

修复方法

打开宇宙层级显示,先观察:

1
2
3
Set Printing Universes.
Check @identity.
(* 输出: forall (A : Type@{u}), A -> A *)

如果需要量化所有宇宙层级,使用 Universe Polymorphism

1
2
Set Universe Polymorphism.
Definition poly_id (A : Type) (x : A) : A := x.

如果错误来自 PropType 的混用,检查是否把 Prop 中的命题当作计算层的数据使用。Prop 的居民不能流入 Set/Type 的非 Prop 数据类型:

1
2
3
4
(* Prop 到 Type 的提取是受限的 *)
Definition extract_from_prop (P : Prop) (proof : P) : bool :=
(* 这里不能直接用 proof 构造 bool 值 *)
true. (* 这样写绕过了,但失去了 proof 的内容 *)

实践中,宇宙不一致错误多出现在尝试定义涉及"类型的类型"的结构时。检查点是:Type 出现在等号右边的类型中,而不是参数类型中。

模式三:无法合一(Unable to unify)

错误现场

1
2
3
4
Error: Unable to unify "f a b" with "f b a".
(* 或 *)
Error: In environment ...
The term "X" has type "A" while it is expected to have type "B".

根因分析

Unable to unify X with Y 背后通常有三种原因。

第一类是参数顺序错误,也是最常见的。某个函数的参数顺序与直觉不符:

1
2
3
4
5
6
7
8
9
(* Nat.add 的参数顺序 *)
Check Nat.add. (* : nat -> nat -> nat *)

Lemma order_mistake : Nat.add 1 2 = Nat.add 2 1.
Proof.
(* reflexivity 会失败,因为 1+2 ≠ 2+1 在定义层是不同的 *)
(* 需要 Nat.add_comm *)
apply Nat.add_comm.
Qed.

第二类是隐式参数不匹配。打开 Set Printing All 后才能看到真实的参数列表:

1
2
3
4
Set Printing All.
Check @eq_refl.
(* @eq_refl : forall (A : Type) (x : A), @eq A x x *)
(* 而不是简写的 eq_refl : ?x = ?x *)

隐式参数被自动推断时,如果上下文不够,推断结果可能与期望不符。用 @ 前缀可以显式指定所有参数:

1
2
3
4
(* 当 apply eq_refl 失败时,改用 *)
apply @eq_refl.
(* 或者 *)
exact (@eq_refl nat 0).

第三类是强制转换(coercion)隐藏了类型差异。Coq 会自动应用已注册的强制转换,这可能让目标看起来满足,实际上类型路径不同:

1
2
3
(* 检查是否有强制转换在起作用 *)
Set Printing Coercions.
(* 现在目标里会显示所有强制转换路径 *)

修复方法

诊断的标准流程是:

  1. Set Printing All 展开所有记号和隐式参数;
  2. Check @f 查看函数的完整签名;
  3. 对比目标中的实际类型和 tactic 提供的类型,找到不匹配点;
  4. changechange_no_check 把目标改写成等价但更便于 tactic 匹配的形式(convert_concl_no_check 在 8.14 已被删除,替代品就是 change_no_check)。
1
2
3
4
5
6
(* change 显式替换等价目标 *)
Lemma example : 1 + 1 = 2.
Proof.
change (S (S 0) = S (S 0)). (* 展开定义后的等价形式 *)
reflexivity.
Qed.

模式四:对错误变量做 induction 导致泛化不足

错误现场

这一类的特征是 induction 本身不报错,卡在后面用归纳假设的那一步。Coq 没有「归纳假设用不上」这种专门的报错,实际看到的是普通的合一失败——apply IH

1
Error: Unable to unify "..." with "...".

rewrite IH 则报

1
Error: Found no subterm matching "..." in the current goal.

真正的诊断信号不在报错文本里,而在归纳步的目标形态:归纳假设被某个已经 intro 进上下文的变量钉死在一个具体值上,而当前目标需要它取另一个值。

根因分析

判据不是「上下文里有没有别的变量」——forall n m, n + m = m + n 就是个反例,intros n m. induction n. 完全能证完(第 14 篇里正是这么写的),因为归纳步里 m 始终取同一个值,钉死它不影响推进。

真正会卡住的是归纳步需要对那个变量取别的值的情形。经典例子是单射性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Require Import PeanoNat.

Fixpoint double (n : nat) : nat :=
match n with
| O => O
| S k => S (S (double k))
end.

(* 卡住的写法 *)
Lemma double_injective_stuck : forall n m, double n = double m -> n = m.
Proof.
intros n m. induction n as [|n' IH].
(* 归纳步目标:double (S n') = double m -> S n' = m
要用 IH 就得让它作用在 m 的前驱上,但 m 已经被 intro 钉死 *)
Abort.

m 在归纳步里必须变成 m'(它的前驱),而 intros n m 已经把它固定了。修法是让 m 留在目标里,在每个分支各自 intro

1
2
3
4
5
6
Lemma double_injective : forall n m, double n = double m -> n = m.
Proof.
intros n. induction n as [|n' IH]; intros [|m'] H; simpl in H; try discriminate.
- reflexivity.
- f_equal. apply IH. injection H. auto.
Qed.

generalize dependent 是同一件事的另一种写法:变量已经 intro 进来了,用它推回目标再做归纳。

泛化的判断准则

问一句话就够:**归纳步里,这个变量需不需要取跟当前不同的值?**需要就把它留在目标里(或用 revert / generalize dependent 推回去),不需要就随便 intro。像 n + m = m + n 这种 m 全程不动的,泛化与否都能过;像 double_injective 这种要对 m 的前驱递归的,不泛化必卡。

模式五:不透明定义阻塞化简

错误现场

1
2
Error: Cannot coerce add_zero_qed to an evaluable reference.
(* 或者 simpl 执行后目标没有变化,不报错但也不化简 *)

根因分析

Coq 中定义的透明度分三档:

  • Definition(透明):simplunfold 都可以展开;
  • Opaque:只能用于类型检查,不能被 simpl/unfold 展开;
  • Qed 结尾的引理:对外部是不透明的,simpl 无法展开其定义体。

后一点是高频陷阱:用 Qed 封闭的证明,其 proof term 对后续证明不透明,只有类型(命题)可见。用 Defined 结尾的证明则是透明的,proof term 可被计算:

1
2
3
4
5
6
7
8
9
(* 用 Qed 封闭:后续无法展开 body *)
Lemma add_zero_qed : forall n, n + 0 = n.
Proof. intros n. induction n; simpl; [reflexivity | f_equal; exact IHn]. Qed.

(* 用 Defined 封闭:后续可以 unfold *)
Lemma add_zero_def : forall n, n + 0 = n.
Proof. intros n. induction n; simpl; [reflexivity | f_equal; exact IHn]. Defined.

Definition test_unfold : 3 + 0 = 3 := add_zero_def 3.

修复方法

如果定义被标记为 Opaque 但需要展开,用 Transparent 临时解除:

1
2
3
4
5
6
7
8
9
10
Opaque Nat.add.

Lemma opaque_example : 1 + 1 = 2.
Proof.
Transparent Nat.add. (* 临时开放 *)
simpl.
reflexivity.
Qed.

(* 之后可以再 Opaque Nat.add. 恢复 *)

如果问题是 Qed 封闭的引理无法展开,有两条路:

  1. 把引理改为 Defined 结尾(需要该引理的 proof term 有计算意义);
  2. 不展开定义体,而是直接用引理本身做重写:rewrite add_zero_qed

About ident 会显示透明度信息:

1
2
3
4
5
About Nat.add.
(* 输出包含: Nat.add is transparent
若先执行 Opaque Nat.add. 则变成:
Nat.add is basically transparent but considered opaque for reduction
—— 这个措辞正好区分「Qed 产生的真 opaque」和「被 oracle 标成 opaque 的 transparent 定义」 *)

Print ident 则显示定义体本身,用于确认定义是否是预期的内容。

模式六:缺少 Require Import 导致 tactic 静默失败

错误现场

这一类没有明显错误信息,症状是 tactic 不报错但也不工作,或报出莫名其妙的找不到名字:

1
2
3
4
Error: The reference omega was not found in the current environment.
(* 或者 *)
Error: The reference lia was not found in the current environment.
(* 或者 tactic 执行了但没有解决任何目标 *)

根因分析

Coq 的标准库分模块,许多 tactic 和引理需要显式导入对应的库才可用。常见的遗漏:

功能 需要导入
lia(线性算术) Require Import Lia.
omega(旧版线性算术) 无——8.12 废弃、8.14 已从标准库删除,8.20 里只有 lia
ringfield Require Import Ring.Require Import Field.
auto 的扩展引理 视需要导入对应库
List 相关引理 Require Import List. Import ListNotations.
Nat 模块 Require Import Arith.Require Import PeanoNat.

另一类静默失败来自 Hint 数据库没有导入。autoeauto 依赖 Hint 数据库,如果相关引理的 Hint 注册在未导入的模块里,auto 会默默失败。

修复方法

诊断顺序:

  1. 确认 tactic 名称拼写正确;
  2. Locate tactic_name. 查找该 tactic 来自哪个模块;
  3. 在文件顶部加入对应的 Require Import
  4. 对于 auto 失败,用 info_autodebug auto 打印搜索轨迹,确认 Hint 数据库内容。
1
2
3
4
5
6
7
(* 查找 lia 的来源 *)
Locate "lia".
(* 如果找不到,说明没有 Require Import Lia *)

Require Import Lia.
Lemma lia_example : forall n : nat, n + 0 = n.
Proof. intros n. lia. Qed.

Hint 数据库,Print HintDb core. 可以列出 core 数据库中的所有引理,用于排查 auto 为何找不到某个引理:

1
2
Print HintDb core.
(* 列出 auto 使用的核心引理集 *)

诊断工作流总结

面对卡住的证明,按以下顺序排查通常能定位根因。

第一步,Set Printing All 展开目标,排除记号和隐式参数的干扰,确认目标的真实形态。

第二步,Show Proof. 打印当前 proof term,检查是否有 evar 或意外的 admit 留在骨架里。

1
2
3
4
5
6
7
Lemma debug_example : forall n : nat, n = n.
Proof.
intros n.
Show Proof. (* 显示 fun n : nat => ?Goal *)
reflexivity.
Show Proof. (* 显示 fun n : nat => @eq_refl nat n *)
Qed.

第三步,对类型错误,Check @term 显示完整类型签名,About ident 显示透明度和来源模块。

第四步,对宇宙错误,加 Set Printing Universes 后重新触发错误,读取层级约束冲突点。

第五步,证明完成后,Print Assumptions 检查是否意外依赖了公理或 admit

1
2
Print Assumptions lia_example.
(* 如果只输出 Closed under the global context 说明无 admit(无句号) *)

练习

练习 1:下面这个证明会因为泛化不足而卡住,修改它使其通过。注意 rev_append 的第二个参数在归纳步里必须取别的值:

1
2
3
4
5
6
7
8
9
10
Require Import List.
Import ListNotations.

Lemma rev_append_spec : forall A (l acc : list A),
rev_append l acc = rev l ++ acc.
Proof.
intros A l acc.
induction l.
(* 此处 IHl 里的 acc 已被钉死,够不够用? *)
Abort.

提示:先判断归纳步需不需要让 acc 取不同的值,再决定 revert acc 还是原样 intro。顺带一提,8.20 里 rev 的长度引理叫 length_rev,旧名 rev_length 只剩一个 only parsing 的兼容记号。

练习 2:下面这行不会触发宇宙不一致,Fail 会报 The command has not failed!。先解释为什么它是合法的,再改造出一个真会炸的版本:

1
2
Fail Definition universe_ok : Type :=
forall (T : Type), T -> T.

提示:每次写 Type 都会分配新的宇宙变量,所以内层 Type@{i} 和整体 Type@{i+1} 的约束是可解的。想让约束无解,得先用 Definition U := Type. 把宇宙固定在常量上。用 Set Printing Universes 观察标注。

练习 3:定义一个以 Defined 结尾的函数,验证它可以被 unfold 展开,而同名的以 Qed 结尾的版本不能:

1
2
3
4
5
6
7
8
9
10
11
12
13
Require Import Arith.   (* ring 需要它 *)

Lemma double_def (n : nat) : n + n = 2 * n.
Proof. ring. Defined.

Lemma double_qed (n : nat) : n + n = 2 * n.
Proof. ring. Qed.

(* 对比 Print double_def. 与 Print double_qed. 的输出:
前者给出完整 proof term,后者只给
*** [ double_qed : forall n : nat, n + n = 2 * n ]
不给 body —— 这是观察 Qed/Defined 差异最直接的方式。
注意别指望 unfold double_def:它是命题的证明,不会出现在别的目标里。 *)

参考资料