Lean 4 自动化内幕

3. Expr:Lean 4 的内部表示🔗

本章目标:理解 Expr 的构造方式、模式匹配惯用法,以及在元程序中操作表达式的基本技巧。

3.1. Expr 是什么🔗

Lean 4 中的每个项——数值、函数、命题、证明——在内核里都表示为 Expr 类型的值。当你写 fun n => n + 1,Lean 解析并精化后,内部存储的就是一棵 Expr 树。

理解 Expr 是写 tactic 的基础:你的 tactic 读取目标(一个 Expr),分析它的结构,构造新的 Expr 作为证明项。

3.2. Expr 的构造子🔗

Expr 定义在 Lean/Expr.lean 中,有以下核心构造子:

inductive Expr where
  | bvar    (deBruijnIndex : Nat)           -- 绑定变量(de Bruijn 索引)
  | fvar    (fvarId : FVarId)               -- 自由变量(局部上下文中的变量)
  | mvar    (mvarId : MVarId)               -- 元变量(未解决的目标/hole)
  | sort    (u : Level)                     -- Sort(Type u / Prop)
  | const   (declName : Name) (us : List Level)  -- 常量(定义/定理的引用)
  | app     (fn : Expr) (arg : Expr)        -- 函数应用
  | lam     (binderName : Name) (binderType : Expr) (body : Expr) (bi : BinderInfo)  -- λ
  | forallE (binderName : Name) (binderType : Expr) (body : Expr) (bi : BinderInfo)  -- ∀/→
  | letE    (declName : Name) (type : Expr) (value : Expr) (body : Expr) (nonDep : Bool)  -- let
  | lit     (l : Literal)                   -- 字面量(数值/字符串)
  | mdata   (data : MData) (expr : Expr)    -- 元数据注解
  | proj    (typeName : Name) (idx : Nat) (struct : Expr)  -- 结构体投影

看起来很多,但日常 tactic 编写中最常遇到的是 appconstfvarmvarlamforallE 这六个。

3.3. 表达式如何编码:几个例子🔗

以下树形结构是示意图,用于说明 app 的嵌套方式,而不是逐字符精确的 elaborated form。

3.3.1. 例 1:Nat.add 2 3🔗

app
├── app
│   ├── const `Nat.add []
│   └── lit (Literal.natVal 2)
└── lit (Literal.natVal 3)

Lean 4 中多参数函数是 curried 的:f a b 表示为 app (app f a) b

3.3.2. 例 2:fun n : Nat => n + 1🔗

lam `n (const `Nat [])
  (app (app (const `HAdd.hAdd [...]) (bvar 0)) (lit 1))
  BinderInfo.default

注意 body 中用 bvar 0 指代最近的绑定变量 n——这是 de Bruijn 索引。

3.3.3. 例 3:∀ n : Nat, n = n🔗

forallE `n (const `Nat [])
  (app (app (app (const `Eq [...]) (const `Nat [])) (bvar 0)) (bvar 0))
  BinderInfo.default

都用 forallE;如果 body 不引用绑定变量(即 bvar 0 不出现),那就是

3.4. 模式匹配 Expr:match_exprlet_expr🔗

手动 match Expr 构造子非常繁琐(要处理 app (app (const ...) ...) ...)。Lean 提供了两个语法糖:

注意match_exprlet_expr 是专门的模式匹配语法糖,不是普通函数名。你不能对它们用 #check(不像 forallTelescope 那样是一个可查类型的函数)。它们的作用是按 head constant 匹配表达式(如 EqAndOr),自动拆开嵌套的 app。对于 forallE 这类 binder 构造子,应该用普通的 Expr.isForall 或直接 match

3.4.1. match_expr🔗

match_expr goalType with
| Eq α lhs rhs =>
  -- goalType 是 @Eq α lhs rhs
  logInfo m!"This is an equality: {← ppExpr lhs} = {← ppExpr rhs}"
| And p q =>
  -- goalType 是 And p q
  logInfo m!"This is a conjunction"
| _ =>
  logInfo m!"Unknown shape"

match_expr 按 head constant 匹配:Eq α lhs rhs 匹配 @Eq α lhs rhs,自动解开所有 app

3.4.2. let_expr🔗

用于 do 块中的 pattern matching(类似 let some x := ... | return):

let_expr Eq α lhs rhs := goalType | throwError "not an equality"
-- 到这里 lhs, rhs 就是绑定好的 Expr

let_expr 在 tactic 代码中用得更多,因为失败时可以直接跳出。

3.5. 构造 Expr🔗

读取 Expr 用模式匹配,构造 ExprmkApp 系列函数:

3.5.1. 手动构造🔗

-- 构造 @Eq Nat a b
let eqExpr := mkApp3 (mkConst ``Eq) (mkConst ``Nat) a b

-- 构造 @Eq.refl Nat a  (即 rfl 的证明项)
let rflExpr := mkApp2 (mkConst ``Eq.refl) (mkConst ``Nat) a

常用构造函数:

  • mkConst n:常量引用

  • mkApp f a:单参数应用

  • mkApp2/3/4/5/6:多参数应用

  • mkAppN f #[a,b,c]:数组参数应用

  • mkLambda n bi t body:构造 λ 表达式

  • mkForall n bi t body:构造 ∀ 表达式

3.5.2. 用引号构造(quotation)🔗

TermElabM / TacticM 中,可以用 quotation + elabTerm 从语法直接构造:

let e ← elabTerm (← `(Nat.add 2 3)) none
-- e 是精化后的 Expr

这更方便,但有额外开销(要走一遍 elaboration)。在性能敏感的 tactic 中,手动 mkApp 更好。

3.6. de Bruijn 索引 vs 自由变量🔗

Expr 中有两种变量:

  • bvar n(bound variable):de Bruijn 索引,表示"向外数第 n 个 λ/∀ 绑定者"。索引 0 = 最近的绑定。

  • fvar id(free variable):局部上下文中的变量,用唯一 ID 标识。

在元程序中,我们通常借助 forallTelescopelambdaTelescope 等 API 打开绑定结构;这些 API 会把 body 中对应的 bvar 替换成当前局部上下文里的 fvar,因此日常 tactic 代码里更常直接操作 fvar。关键 API:

-- 进入 ∀ 类型,把绑定变量转为 fvar
forallTelescope goalType fun fvars body => do
  -- fvars : Array Expr  (每个都是 fvar)
  -- body  : Expr        (bvar 已被替换为 fvar)
  ...

-- 进入 λ 表达式
lambdaTelescope e fun fvars body => do
  ...

forallTelescopelambdaTelescope 是处理绑定结构的标准方式。它们:

  1. 创建新的 fvar 加入局部上下文

  2. 把 body 中的 bvar 替换为对应 fvar

  3. 回调结束后自动清理

注意fvar 只在创建它的 LocalContext 中有效。不要把一个 telescope 回调里的 fvar 泄漏到外面。

3.7. 实用工具函数🔗

MetaM 提供了大量操作 Expr 的工具:

inferType (e : Expr) : MetaM Expr         -- 推断 e 的类型
isDefEq (a b : Expr) : MetaM Bool          -- a 和 b 定义等价?(可能触发 unification)
whnf (e : Expr) : MetaM Expr               -- 弱头范式
isProof (e : Expr) : MetaM Bool            -- e 是证明项?
isProp (e : Expr) : MetaM Bool             -- e 的类型是 Prop?

3.7.2. 结构分析🔗

Expr.getAppFn (e : Expr) : Expr            -- 获取应用头(剥掉所有 app)
Expr.getAppArgs (e : Expr) : Array Expr    -- 获取所有参数
Expr.isApp (e : Expr) : Bool               -- 是 app?
Expr.isConst (e : Expr) : Bool             -- 是 const?
Expr.constName (e : Expr) : Name           -- const 的名字(前提:isConst)

3.7.3. 替换与遍历🔗

kabstract (e : Expr) (t : Expr) : MetaM Expr  -- 把 e 中匹配 t 的子项抽象为 bvar(支持 occurrences 控制)
replace (e : Expr) (f : Expr → Option Expr) : Expr  -- 自定义替换
Expr.foldlM (init : α) (f : α → Expr → MetaM α) (e : Expr) : MetaM α  -- 遍历

3.8. 小结🔗

  • Expr:Lean 中一切项的内部表示,树形结构

  • app:函数应用,多参数 = 嵌套 app(curried)

  • bvar / fvar:绑定变量 vs 自由变量;元程序中用 fvar

  • match_expr / let_expr:按 head constant 模式匹配,避免手动解 app

  • mkApp*:手动构造表达式;性能敏感时优于 elabTerm

  • forallTelescope:进入 ∀ 类型的标准方式

3.9. 常见失败模式与 debug🔗

3.9.1. 失败 1:match_expr 匹配不上,明明目标看起来是对的🔗

match_expr 按 head constant 匹配,但 whnf 没有自动调用。如果目标被 letabbrev 包了一层,需要先 whnf

-- [示意]
let target ← whnf (← goal.getType)  -- 先规约
match_expr target with
| Eq α lhs rhs => ...
| _ => throwError "not an equality"

3.9.2. 失败 2:Expr.constName panic🔗

constName 在表达式不是 const 时会 panic。安全做法是先检查:

-- [示意]
if e.isConst then
  let name := e.constName
  ...

或用 Expr.constName? 返回 Option Name

3.9.3. 失败 3:fvar 泄漏出 telescope 回调🔗

forallTelescope 里创建的 fvar 只在回调内有效。如果你把它存到外面的变量里再使用,会得到 unknown free variable 错误。

-- [示意] 错误做法
let mut leaked : Expr := default
forallTelescope goalType fun fvars body => do
  leaked := fvars[0]!  -- ❌ fvar 会在回调结束后失效
-- 在这里用 leaked 会出错

正确做法:把需要的结果在回调内计算完,只返回不含 fvar 的值。

3.9.4. 失败 4:手动 mkApp 参数个数错🔗

mkApp3 (mkConst Eq) a b 会构造出类型错误的表达式(Eq 需要 3 个参数:类型 α、lhs、rhs)。这类错误不会立即 panic,而是在后续 isDefEqcheck 时才报 type mismatch。

建议:用 mkEqmkEqRefl 等高层 API,而不是手动数参数。

3.10. 练习🔗

3.10.1. 练习 2.1(热身):用 #check @Eq 观察 Expr 结构🔗

在 VS Code 中运行:

@Eq : {α : Sort u_1} α α Prop#check @EqAnd : Prop Prop Prop#check @And@Eq.refl : {α : Sort u_1} (a : α), a = a#check @Eq.refl

观察每个常量需要几个参数。这能帮你理解为什么 match_exprEq α lhs rhs(3 个参数)而 And p q(2 个参数)。

3.10.2. 练习 2.2(热身):打印目标的 head constant🔗

写一个 tactic,打印当前目标的 head constant 名字:

import Lean
open Lean Elab Tactic Meta
elab "show_head" : tactic => do let goal getMainGoal goal.withContext do let target goal.getType let head := target.getAppFn if head.isConst then logInfo m!"Head constant: {head.constName}" else logInfo m!"Head is not a constant: { ppExpr head}" example (n : Nat) : n + 0 = n := n:n + 0 = n 'show_head' tactic does nothing Note: This linter can be disabled with `set_option linter.unusedTactic false`Head constant: Eqn:n + 0 = n -- 应输出: Head constant: Eq All goals completed! 🐙

和 Ch1 的 trace_goal 一样,这类只打印信息不改变目标的 tactic 可能触发 "tactic does nothing" 警告,可以忽略。

3.10.3. 练习 2.3(debug):找出 match_expr 失败的原因🔗

以下 tactic 想匹配 ∀ x, P x 形式的目标,但总是走到 _ 分支。为什么?

-- 这段代码有逻辑错误
import Lean
open Lean Elab Tactic Meta

elab "check_forall" : tactic => do
  let goal ← getMainGoal
  goal.withContext do
    let target ← goal.getType
    match_expr target with
    | ForAll _ _ => logInfo "Found a forall!"
    | _ => logInfo "Not a forall"

example : ∀ n : Nat, n = n := by
  check_forall   -- 为什么输出 "Not a forall"?
  intro n; rfl

提示:match_expr 匹配的是 head constant(如 EqAnd),而 不是一个常量——它是 Expr.forallE 构造子。要检查 ,应该用普通的 Expr.isForall 或直接 match 构造子。

3.10.4. 练习 2.4(综合):实现 show_structure tactic🔗

写一个 show_structure tactic,对当前目标做以下分类并打印:

  • 如果是等式 a = b:打印 "Equality: {a} = {b}"

  • 如果是合取 P ∧ Q:打印 "Conjunction: {P} ∧ {Q}"

  • 如果是 类型:打印 "Forall" 和绑定变量的类型

  • 否则:打印 "Other: {target}"

测试用例:

-- [可运行] 实现 show_structure 后测试
example : 1 + 1 = 2 := by show_structure; norm_num
example : True ∧ True := by show_structure; exact ⟨trivial, trivial⟩
example : ∀ n : Nat, n = n := by show_structure; intro n; rfl
example : True := by show_structure; trivial

下一章:用 ExprTacticM 写出你的第一个有用的 tactic。