深入 Coq 14:程序提取
Coq 不仅是证明助手,也是一门带有计算语义的函数式语言。通过 Extraction 机制,Gallina 程序可以被翻译成 OCaml、Haskell 或 Scheme 的可运行代码,同时抹去所有停留在 Prop 宇宙中的证明项。本文覆盖 Extraction 的核心命令、类型映射指令、内联控制、性能陷阱,以及一个从证明到编译运行的完整示例。
前置阅读:本系列前三篇(01 开发环境与项目结构、02 目标窗口与 tactic 交互模型、03 Gallina 核心语法速查)以及第 09 篇(归纳证明)提供了本文所需的 Gallina 和 tactic 基础。
Extraction 机制概览
Extraction 的基础是 Curry-Howard 同构的计算部分:类型为 A : Type 或 A : Set 的项携带运行时数据,而类型为 P : Prop 的项纯粹是逻辑断言,不携带可观察的计算内容。提取器(extractor)遍历 Gallina 的 proof term,遇到 Prop 宇宙中的构造子时直接擦除,只保留 Type/Set 宇宙的骨架,再将剩余的 CIC 项翻译成目标语言的 AST。
提取后得到的代码是纯粹的语义等价物:若 Coq 端证明了 sort_correct,提取后的 sort 函数在 OCaml 里运行时同样满足该性质——前提是提取映射本身是可信的,且没有引入不可信公理。
Coq 8.x 内置三个提取后端:
| 目标语言 | 加载命令 | 输出格式 |
|---|---|---|
| OCaml | 内置,无需加载 | .ml / .mli |
| Haskell | Require Extraction. |
.hs |
| Scheme | Require Extraction. |
.scm |
OCaml 后端最成熟,性能最好,也是 CompCert、Bedrock2 等项目选用的提取目标。本文以 OCaml 为主,Haskell 为辅做对比。
Extraction 与 Recursive Extraction
基本用法
1 | |
Extraction ident 只输出 ident 本身的定义;若依赖项未被显式列出,Coq 会假定它们已在目标语言中存在。Recursive Extraction ident 则递归收集所有依赖,适合自包含的单文件输出。
简单示例
1 | |
提取结果(OCaml):
1 | |
A : Type 被擦除为多态类型变量 'a1;nat 保留为归纳类型,因为它在 Type 宇宙中。
提取到独立模块
实际项目中通常将整个 Coq 文件提取为一个 OCaml 模块:
1 | |
在 _CoqProject 里列出 Verified.v,运行 coq_makefile 生成 Makefile 后,make 会自动触发提取并生成 verified.ml。
Prop 与 Type:擦除的边界
为什么证明消失
Coq 的宇宙层级决定了哪些项会被提取:
1 | |
提取器的规则很简单:
- 类型在
Prop中的项 → 擦除(替换为__或直接删除) - 类型在
Set或Type中的项 → 保留并翻译
因此一个形如 {n : nat | n > 0} 的 sigma 类型,其中的证明部分 n > 0 : Prop 会被擦除,只保留 n : nat。
1 | |
提取结果:
1 | |
H : n > 0 变成了 __(OCaml 中 Coq 的占位类型),参数名为 _,函数体直接返回 n。这个行为是预期的——证明项在运行时没有计算意义。
证明相关性与 Prop 的特殊性
Coq 中 Prop 内的两个居民(两个证明)在提取时被视为同一对象,这来自 proof irrelevance 的语义承诺。因此依赖 Prop 居民做分支的代码在提取后行为是透明的:所有分支都被擦除。
1 | |
若需在提取代码中保留分支,谓词必须放在 Type 宇宙中(使用 sumbool)。
1 | |
Extract Inductive 与 Extract Constant
将 Coq 类型映射到目标语言类型
默认情况下,nat 提取为:
1 | |
这是一个一元(unary)编码,计算复杂度极差:加法 O(n),乘法 O(n²)。对于任何涉及实际数值计算的程序,必须用 Extract Inductive 将 nat 替换为机器整数。
1 | |
三个参数依次是:目标语言类型名、构造子的映射列表、消去子(case 分析函数)。
标准库提供了多个预置映射文件:
| 文件 | 作用 |
|---|---|
ExtrOcamlBasic |
bool、option、list、pair 映射到 OCaml 内置 |
ExtrOcamlNatInt |
nat → int |
ExtrOcamlZInt |
Z → int |
ExtrOcamlString |
string → OCaml string |
ExtrHaskellBasic |
Haskell 对应版本 |
Extract Constant
Extract Constant 将单个 Coq 常量映射到目标语言的具体实现:
1 | |
这在替换了 nat 为 int 之后尤其重要:若只替换类型而不替换运算,加法仍然走原来的递归实现,映射不完整。
对于无法在 Coq 内部实现、只能靠目标语言提供的功能(如文件 I/O、随机数),Extract Constant 配合 Parameter 声明是标准做法:
1 | |
Extraction Inline 与 NoInline
提取器默认对小函数进行内联,有时会让生成代码变得臃肿,有时则期望强制内联来消除中间层。
1 | |
Extraction Inline 对辅助函数特别有用:若 Coq 端有大量短小的 helper,提取后内联可以消除函数调用开销。Extraction NoInline 适用于需要保留函数边界以便在 OCaml 端打 patch 或替换实现的情形。
Print Assumptions:验证提取基础
提取代码的可信性依赖于证明的可信性。若某个定理依赖了 Classical 公理或 Axiom 声明,提取出的代码在逻辑上有额外假设,可能与目标语言语义不对应。
1 | |
Closed under the global context 表示该定理不依赖任何公理,完全在 CIC 的构造性框架内证明,提取代码是可信的。
若输出列出了公理,例如:
1 | |
则提取出的代码隐含了排中律,程序行为在可计算性语义下不再有完整保证。对于追求可信提取的项目,应在开发过程中定期检查 Print Assumptions,确保核心定理无公理依赖。
完整示例:归并排序的提取与运行
Coq 端定义
1 | |
上面的 split_list_length 需要先证明一个引理——split_list 的两个分片长度均严格小于原列表(当原列表长度 ≥ 2 时)。这是 Program Fixpoint 终止证明所需的。完整代码见下方。
终止性引理
1 | |
提取指令
1 | |
生成的 OCaml 代码(节选)
1 | |
编译与运行
1 | |
性能陷阱
一元 nat 的代价
未做任何映射时,nat 的提取形式是链表结构。Nat.add 1000 1000 在 OCaml 中需要做 1000 次 S 构造。Nat.mul 100 100 的复杂度是 O(n²)。任何需要在提取代码中做实际数值运算的程序,必须通过 ExtrOcamlNatInt 或等价的 Extract Inductive 指令将 nat 映射到机器整数。
失败路径:
1 | |
正确路径:
1 | |
Haskell 后端的惰性求值陷阱
Haskell 使用惰性求值,而 Coq 的计算语义是严格的(call-by-value)。当把严格的 Coq 程序提取到 Haskell 时,以下情况会产生语义差异:
递归定义中若某分支在 Haskell 端不会被求值(因为惰性),即便 Coq 端已证明终止,Haskell 端也不会触发对应的计算——这通常没有问题。但若提取的函数依赖副作用顺序(通过 IO monad 模拟),严格与惰性的差异会导致执行顺序不同。
解决方案是在 Haskell 提取时显式使用 seq 强制求值,或在 Coq 端将有顺序依赖的操作建模为状态机而非裸函数。
依赖类型与运行时擦除的边界
带依赖类型的 sigma 类型 {x : A | P x} 在提取后变为裸类型 A,证明部分完全消失。这意味着提取出的 OCaml 代码不再携带运行时的合法性保证;若在 OCaml 端直接修改了提取代码,并传入不满足 P x 的值,不会有任何运行时错误,但函数行为未定义(在 Coq 语义下)。
依赖类型只在 Coq 的静态检查层生效。提取的用途是生成经过静态验证的高效代码,而非在运行时保持不变式检查。
一个常见错误与修复
错误:对 Prop 类型做 match 并期望提取出分支
1 | |
提取器选择第一个分支作为擦除后的占位,右分支的计算内容(返回 1)在提取后不可见。
修复:改用 sumbool
1 | |
sumbool 的定义是 Inductive sumbool (A B : Prop) : Set := left : A -> {A}+{B} | right : B -> {A}+{B},它居住在 Set 中,因此两个构造子都会被提取。
类似地,sumor、sig(在 Type 中)、sigT 都是在提取场景下携带计算分支的常用类型。
Show Proof 与提取前的验证
在证明某个将被提取的函数的性质时,Show Proof 可以用来确认 proof term 的结构,从而预判提取结果:
1 | |
add_O_r 的类型是等式命题(Prop),整个 proof term 在提取时会被完整擦除。若需要将某个类型为 Type 的构造性结果提取出来,Show Proof 是在 Qed 之前检查 proof term 是否包含期望计算结构的有效工具。
练习
练习 1:定义一个 Coq 函数 insert_sorted : nat -> list nat -> list nat,将一个自然数插入有序列表并保持有序,同时证明结果确实有序(使用 Sorted 或自定义谓词)。用 ExtrOcamlNatInt 和 ExtrOcamlBasic 将其提取为 OCaml,在 OCaml 顶层(utop 或 ocaml)中测试 insert_sorted 4 [1;2;3;5;6] 的输出。
练习 2:将如下 Coq 定义提取到 Haskell,并用 ghc 编译:
1 | |
观察未加映射时 fib 30 的运行时间,再加入 ExtrHaskellNatInt(或等价指令)后重新编译,比较两次运行时间。解释差异来源。
练习 3:定义一个函数 safe_div : nat -> {m : nat | m > 0} -> nat,对第一个参数除以第二个参数(sigma 类型保证分母非零)。提取到 OCaml,观察 sigma 类型中的证明部分如何被擦除,并说明提取后的 OCaml 函数在类型层面失去了哪些静态保证。
参考资料:
- Coq 官方文档 Extraction of programs in Objective Caml and Haskell,Coq 8.19 Reference Manual 第 25 章
- Pierre Letouzey. A New Extraction for Coq. TYPES 2002.
- Xavier Leroy et al. CompCert C Compiler——CompCert 项目是 Coq 提取到 OCaml 的最大规模工程案例
- 本系列第 01 篇(开发环境)、第 03 篇(Gallina 核心语法)、第 09 篇(归纳证明)提供 Extraction 所需前置知识
