Theorem Proving in Lean 4
by Jeremy Avigad, Leonardo de Moura, Soonho Kong and Sebastian Ullrich, with contributions from the Lean Community
このテキストは読者がLean 4を使うことを前提にしています。Lean 4をインストールするには、Lean 4 Manualの節Quickstartをご覧ください。このテキストの最初のバージョンはLean 2用に書かれました。Lean 3用のバージョンはこちらで入手可能です。
この翻訳について
translated by aconite(2章~12章), Haruhisa Enomoto(1章)
この翻訳は有志による非公式翻訳です。翻訳に際して、表現を大きく変えた箇所や、分かりやすさを期すため記述やコード例を追加した箇所があります。また、用語の訳が一般的でない可能性があります。誤りを含む可能性もあります。必要に応じて原文Theorem Proving in Lean 4 (GitHub)もご覧ください。
原文のライセンスはApache License 2.0であり、それに基づいて原文を翻訳・公開しています。
この翻訳のソースはGitHubリポジトリから入手可能です。また、ページ右上端のGitHubマークを押してGitHubリポジトリに移動することもできます。この翻訳の全ページとソースはApache License 2.0の下で公開されています。
誤字脱字、内容の誤りの指摘、フォークからのPull Request、フォークによる翻訳の改変等は歓迎いたします。基本的に、ご指摘はGitHubリポジトリのIssuesで受け付けます。
翻訳に際して、機械翻訳サービスDeepL翻訳を参考にしました。
バージョン情報
この翻訳は原文のcommit 81b028359684646f2db48e3909ee81b4fad51dfb (Date: Fri Mar 1 14:54:59 2024 -0600)に基づいています。
Introduction (イントロダクション)
Computers and Theorem Proving (コンピュータと定理証明)
Formal verification(形式検証)では、論理的・計算的手法を使って、厳密な数学用語で表現された主張を立証する。この主張には、通常の数学の定理の他にも、ハードウェアやソフトウェアの一部、 ネットワークプロトコル、機械的・ハイブリッドシステムがその仕様を満たしているという主張も含まれる。実際には、数学的主張の検証をすることと、システムの正しさを検証することの間には、明確な区別はない。というのも、形式検証では、ハードウェアやソフトウェアのシステムを数学的な言葉で記述する必要があり、その時点で、その正しさに関する主張を立証することは、定理証明の一形態となる。逆に、数学の定理の証明には長い計算が必要な場合があり、その場合、定理の真偽を検証するには、その計算が想定通りに実行されることを検証する必要がある。
数学的な主張を裏付けるための最も信頼性のある方法は証明を与えることであり、20世紀の論理学の発展により、従来の証明方法のすべてではないにせよ、そのほとんどが、いくつかの基礎的な体系における小さな公理と規則の集合に還元できることが明らかになった。この還元のもとで、コンピューターが主張の立証に役立つ方法は2つある: 1つは、そもそも証明を「発見」する手助けをすること、もう1つは証明とされるものが正しいかどうかを「検証」する手助けをすることである。
Automated theorem proving(自動定理証明)は、この「発見」の側面に焦点を当てている。resolution theorem provers(導出原理に基づく証明器)、tableau theorem provers(タブロー定理証明器)、fast satisfiability solvers(高速充足可能性ソルバー)などは、命題論理や一階論理における公式の正しさを証明する手段を提供する。また、整数や実数に対する線形式や非線形式など、特定の言語や領域に対する探索手続きや決定手続きを提供するシステムもある。SMT(satisfiability modulo theories, 充足可能性モジュロ理論)のようなアーキテクチャは、領域一般的な探索手法と領域固有の手続きを組み合わせたものである。数式処理システムや高度な数学ソフトウェア・パッケージは、数学的計算を実行したり、数学的上界または下界を確立したり、数学的対象を見つけたりする手段を提供する。計算は証明と見なすこともでき、これらのシステムも数学的主張の立証に役立つ。
自動推論システムは、そのパワーと効率性を追求する結果、しばしば健全性の保証を犠牲にしている。このようなシステムにはバグがあることもあり、システムが導いた結果が正しいことを保証するのは難しい。対照的に、interactive theorem proving(対話型定理証明)は、定理証明の「検証」の側面に焦点を当て、全ての主張が適切な公理的基礎づけにおける証明によって保証されることを要求する。その基準は非常に高く、推論の全てのルールと計算の全てのステップは、それ以前の定義や定理に訴えることで正当化されなければならず、それら全ては最終的には基本的な公理や規則にまで遡る。実際、このようなシステムのほとんどは、他のシステムに伝達したり、独立にチェックしたりできる、完全に精緻化された「証明オブジェクト」を提供する。このような証明を構築するためには、通常ユーザーからの入力やインタラクションがより多く必要となるが、それによってより深く複雑な証明を得ることができる。
Lean Theorem Prover(Lean定理証明器)は、これら対話型定理証明と自動定理証明との間のギャップを埋めることを目的としている。このために、ユーザーとのインタラクションと完全に明記された公理的証明の構築が可能であるフレームワークの内部で、自動化されたツールと方法が使えるようにする。そのゴールは、数学的推論と複雑なシステムについての推論の両方をサポートし、両方の領域における主張を検証することである。
Leanの基礎となる論理は計算的解釈を持ち、Leanはプログラミング言語とみなすこともできる。もっと言えば、Leanは厳密な意味論を持つプログラムを書くためのシステムであり、プログラムが計算する関数について推論を行うためのシステムである。Leanはまた、独自のmetaprogramming language(メタプログラミング言語)として機能するメカニズムを持つ。つまり、Leanそのものを使って自動化を実装したり、Leanの機能を拡張したりすることができる。Leanのこのような側面は、無料のオンラインテキストFunctional Programming in Leanで解説されるが、システムの計算的側面はここでも登場するだろう。
About Lean (Leanについて)
Leanプロジェクトは、2013年にMicrosoft Research RedmondのLeonardo de Mouraによって立ち上げられた、現在進行中の長期的な取り組みであり、自動化への潜在的可能性の多くは時間をかけて徐々に実現されるだろう。LeanはApache License 2.0の下でリリースされている。これは寛容なオープンソースライセンスであり、他の人がコードと数学ライブラリを自由に利用し、拡張することを許可している。
Leanをあなたのコンピュータにインストールするには、Quickstartにある指示を参照するのがよいだろう。LeanのソースコードとLeanのビルド方法は、https://github.com/leanprover/lean4/から入手できる。
このチュートリアルは、Lean 4として知られるLeanの現在のバージョンについて記述する。
About this Book (この本について)
この本は、Leanで証明を記述し検証する方法を学ぶために意図されている。そのために必要な背景情報の多くは、Lean特有のものではない。まず最初に、Leanがベースにしている論理システムを学ぶ。これはdependent type theory(依存型理論)の一種で、通常の数学の定理のほとんどを証明するのに十分強力であり、またそれを自然な方法で行うのに十分な表現力を持っている。より具体的には、LeanはCalculus of Constructionsとして知られるシステムのうち、帰納型を持つものの一種に基づいている。Leanは依存型理論で数学的対象を定義し数学的主張を表現できるだけでなく、依存型理論を証明を書くための言語としても使用できる。
完全に詳述された公理的証明は非常に複雑であるため、定理証明の課題は、できるだけ多くの細部をコンピュータに埋めさせることである。2章 Dependent Type Theory (依存型理論)では、これを行う様々な方法を学ぶことができる。例えば、項の書き換えや、項や式を自動的に単純化するLeanの自動化手法などである。同様に、elaborationやtype inference(型推論)の方法も、柔軟な代数的推論をサポートするために使うことができる。
最後に、システムとのコミュニケーションに使用する言語や、複雑な理論やデータを管理するためにLeanが提供するメカニズムなど、Lean特有の機能について学ぶ。
本文中には、以下のようなLeanのコード例が見られる:
theorem and_commutative (p q : Prop) : p ∧ q → q ∧ p :=
fun hpq : p ∧ q =>
have hp : p := And.left hpq
have hq : q := And.right hpq
show q ∧ p from And.intro hq hp
(訳者注: 以下の文章は、文章中にあるようにVS Codeのコマンドを用いて翻訳元英語版を開いた際の話だと思われます。) この本をVS Codeの中で読んでいると、"try it!"というボタンが表示される。このボタンを押すと、コード例を正しくコンパイルするのに十分な周囲の非表示コードとともに、エディタにコード例がコピーされる。読者はコード例を自由に修正することができ、それに応じてLeanは読者が入力した結果をチェックし、継続的にフィードバックを提供する。この後の章を読み進めながら、自分でコード例を実行し、自分自身で書いたコードを試してみることをお勧めする。本書はVS Code上で「Lean 4: Open Documentation View」コマンドで開くことができる。
Acknowledgments (謝辞)
This tutorial is an open access project maintained on Github. Many people have contributed to the effort, providing corrections, suggestions, examples, and text. We are grateful to Ulrik Buchholz, Kevin Buzzard, Mario Carneiro, Nathan Carter, Eduardo Cavazos, Amine Chaieb, Joe Corneli, William DeMeo, Marcus Klaas de Vries, Ben Dyer, Gabriel Ebner, Anthony Hart, Simon Hudon, Sean Leather, Assia Mahboubi, Gihan Marasingha, Patrick Massot, Christopher John Mazey, Sebastian Ullrich, Floris van Doorn, Daniel Velleman, Théo Zimmerman, Paul Chisholm, Chris Lovett, and Siddhartha Gadgil for their contributions. Please see lean prover and lean community for an up to date list of our amazing contributors.
Dependent Type Theory (依存型理論)
Dependent Type Theory(依存型理論)は強力で表現力の高い言語であり、複雑な数学的主張を表現したり、複雑なハードウェアやソフトウェアの仕様を記述したり、これら両方について自然で統一された方法で推論したりすることができる。LeanはCalculus of Constructionsとして知られる依存型理論の一種に基づいている。そして、Leanは「非累積的宇宙の可算無限階層」と「帰納型」を備えている。この章が終わるころには、これらが意味するところを理解できているだろう。
Simple Type Theory (単純型理論)
「型理論」という名前は「全てのexpression(式 あるいは 項)はそれに関連した型を持つ」という事実に由来する。例えば、適切な文脈の下で項 x + 0
は自然数型を持ち、項 f
は自然数を受け取り自然数を返す関数型を持つ。正確な定義を述べると、Leanにおいて「自然数」とは「任意精度の符号なし整数」のことである。
Leanでは項を次のように宣言する。
-- def <項の名前(識別子)> : <項の型> := <項の定義式>
def m : Nat := 1 -- `m` は自然数型を持つ
def b1 : Bool := true -- `b1` はブール型を持つ
def b2 : Bool := false
項の型をチェックするには次のようにする。
def m : Nat := 1
def b1 : Bool := true
def b2 : Bool := false
-- #check <項>
#check m -- output: Nat
#check b1 && b2 -- `&&` は「かつ」 output: Bool
#check b1 || b2 -- `||` は「または」 output: Bool
項を評価する(項の値を計算する)には次のようにする。
def m : Nat := 1
def b1 : Bool := true
def b2 : Bool := false
-- #eval <項>
#eval 5 * 4 -- 20
#eval m + 2 -- 3
#eval b1 && b2 -- false
/-
と -/
はコメントブロックを形成し、その中のテキストは無視される。同様に、--
から行末までもコメントとみなされ、コメントは無視される。コメントブロックは入れ子にすることができる。そのため、多くのプログラミング言語と同じように、コードの塊を「コメントアウト」することができる。
def
キーワードは現在の作業環境で新しい名前付きの項を宣言(定義)する。上の例では、def m : Nat := 1
は値が 1
である Nat
型の新しい項 m
を定義している。#check <項の名前>
コマンドはその項が持つ型を報告するようLeanに要求する。Leanでは、システムに情報を問い合わせる補助コマンドは基本的にハッシュ記号(#
)から始まる。#eval <項の名前>
コマンドはその項を評価するようLeanに要求する。いくつかの項を自分で宣言し、いくつかの項を自分で型チェックしてみてほしい。def
を使って新しいオブジェクトを宣言することは、システム上で実験するための良い方法である。
Simple Type Theory(単純型理論)が強力なのは、他の型から新しい型を作ることができるからである。例えば、a
と b
が型なら、a -> b
は a
から b
への関数の型を表し、a × b
は a
の要素と b
の要素からなるペアの型(a
と b
の直積型)を表す。ここで、×
はUnicode記号であることに注意してほしい。LeanではUnicodeを使用する。Unicodeを適切に使用することで読みやすさを向上させることができる。また、全ての現代的なエディタはUnicodeをサポートしている。Leanの標準ライブラリでは、型を表すギリシャ文字や、->
をよりコンパクトにしたUnicode記号 →
をよく見かける。
→
や ×
などを使った例を掲載する。
#check Nat → Nat -- `→` は "\to" あるいは "\r" と打つと入力できる
#check Nat -> Nat -- `->` は `→` のASCII表記
#check Nat × Nat -- `×` は "\times" と打つと入力できる
#check Prod Nat Nat -- `Prod Nat Nat` は `Nat × Nat` のASCII表記
#check Nat → Nat → Nat
#check Nat → (Nat → Nat) -- これは1つ上と同じである。つまり、`→` は右結合的である
#check Nat × Nat → Nat
#check (Nat → Nat) → Nat -- 関数を受け取る関数の型
#check Nat.succ -- Nat → Nat
#check (0, 1) -- Nat × Nat
#check Nat.add -- Nat → Nat → Nat
#check Nat.succ 2 -- Nat
#check Nat.add 3 -- Nat → Nat
#check Nat.add 5 2 -- Nat
#check (5, 9).1 -- Nat
#check (5, 9).2 -- Nat
#eval Nat.succ 2 -- 3
#eval Nat.add 5 2 -- 7
#eval (5, 9).1 -- 5
#eval (5, 9).2 -- 9
#eval Nat.add (10, 7).1 (10, 7).2 -- 17
今一度、自分でいくつかの例を作り、これらのコマンドを試してほしい。
基本的な構文を見てみよう。Unicodeの矢印 →
は \to
、\r
、\->
と打つことで入力できる。ASCIIの代替記号 ->
も使用できる。したがって、Nat -> Nat
と Nat → Nat
は同じ意味である。どちらの式も、入力として自然数を受け取り、出力として自然数を返す関数の型を表す。直積型を表すUnicode記号 ×
は \times
と打つと入力できる。一般に、α
、β
、γ
のような小文字のギリシャ文字を使って型を表す。これらは \a
、\b
、\c
と打つと入力できる。
以下に上述した記号の入力方法をまとめる。
入力 | 記号 |
---|---|
\to | → |
\r | → |
-> | → |
\times | × |
\a | α |
\b | β |
\g | γ |
上記の例について注意すべきことがさらにいくつかある。まず、項 x
に対する関数 f
の適用は f x
と表記される(例: Nat.succ 2
)。次に、型の記述において →
は右結合的である。例えば、Nat.add
の型は Nat → Nat → Nat
であり、これは Nat → (Nat → Nat)
と等価である。したがって、Nat.add
を、自然数を受け取り「自然数を受け取り自然数を返す関数」を返す関数とみなすことができる。型理論においては、Nat.add
を自然数のペアを入力として受け取り、自然数を出力として返す関数として表現するよりも、一般的にこちらの方が便利である。つまり、Nat.add
の型を Nat × Nat → Nat
とするよりも、Nat → Nat → Nat
とする方が便利である。これにより、例えば Nat.add
関数を「部分適用」することができる。上記の例は Nat.add 3
は Nat → Nat
型を持つことを示している。すなわち、Nat.add 3
は2番目の引数 n
を「待つ」関数を返す。したがって、Nat.add 3
は Nat.add 3 n
と書くのと等価である。
m : Nat
と n : Nat
があれば、(m, n)
は Nat × Nat
型を持つ m
と n
の順序対を表すことが分かっただろう。この記法は自然数のペアを作る方法を与えてくれる。逆に、p : Nat × Nat
とすると、p.1 : Nat
、p.2 : Nat
となる。この記法を使うとペアの2つの成分を取り出すことができる。
Types as objects (項としての型)
型そのもの(Nat
や Bool
など)も項であるとみなすことは、Leanの依存型理論が単純型理論を拡張するのに使う手法の1つである。そうみなすためには、Nat
や Bool
などの各型も型を持っていなければならない。
#check Nat -- Type
#check Bool -- Type
#check Nat → Bool -- Type
#check Nat × Bool -- Type
#check Nat → Nat -- ...
#check Nat × Nat → Nat
#check Nat → Nat → Nat
#check Nat → (Nat → Nat)
#check Nat → Nat → Bool
#check (Nat → Nat) → Nat
上の各式が Type
型の項であることがわかるだろう。型を表す新しい定数を宣言することもできる:
def α : Type := Nat
def β : Type := Bool
def F : Type → Type := List
def G : Type → Type → Type := Prod
#check α -- Type
#check F α -- Type
#check F Nat -- Type
#check G α -- Type → Type
#check G α β -- Type
#check G α Nat -- Type
上の例が示すように、Type → Type → Type
型を持つ関数の例、すなわち直積 Prod
はすでに見た:
def α : Type := Nat
def β : Type := Bool
#check Prod α β -- Type
#check α × β -- Type
#check Prod Nat Nat -- Type
#check Nat × Nat -- Type
任意の型 α
が与えられたとき、型 List α
は型 α
の項からなるリストの型を表す。
def α : Type := Nat
#check List α -- Type
#check List Nat -- Type
Leanの全ての式(項)が型を持っていることを考えれば、「Type
そのものはどのような型を持っているのか」と問うのは自然なことである。
#check Type -- Type 1
では Type 1
の型を予想し、それから実際に Type 1
の型をチェックしてみよう。
#check Type 1
#check Type 2
#check Type 3
#check Type 4
予想は当たっただろうか。この実験において、我々は「Leanは型の無限階層の上に成り立っている」というLeanの型付けシステムの最も精緻な側面の一つに突き当たっている。
Type 0
は Nat
のような「小さい」あるいは「普通の」型たちからなる宇宙だと思ってほしい。Type 1
は Type 0
を項にもつより大きい宇宙であり、Type 2
は Type 1
を項にもつより大きい宇宙である。この列に限りはない。つまり、任意の自然数 n
に対して、型 Type n
が存在する。Type
とは Type 0
の略称である:
#check Type
#check Type 0
次の表は、今議論されていることを理解するのに役立つだろう。右方向に進むと「宇宙」がより大きいものへと変化し、下方向に進むと「度」と呼ばれるものが変化する。
sort | Prop (Sort 0) | Type (Sort 1) | Type 1 (Sort 2) | Type 2 (Sort 3) | ... |
type(型) | True | Bool | Nat -> Type | Type -> Type 1 | ... |
term(項) | trivial | true | fun n => Fin n | fun (_ : Type) => Type | ... |
いくつかの演算子は型の宇宙に対してpolymorphic(多相)である必要がある。例えば、List α
は、α
がどの型宇宙にいようと意味をなすべきである。多相な関数 List
の型は次のように表記される:
#check List -- List.{u} (α : Type u) : Type u
ここで、u
は宇宙レベルを表す変数である。コマンド #check List
の出力は、α
が型 Type n
を持つなら、List α
も Type n
を持つことを表している。関数 Prod
も多相である:
#check Prod -- Prod.{u, v} (α : Type u) (β : Type v) : Type (max u v)
universe
コマンドを使うと、明示的に宇宙変数を宣言することができる。宇宙変数を使うと多相な項を定義することができる:
universe u
def F (α : Type u) : Type u := Prod α α -- `Type u` に属する型 `α` を受け取ると、`α` と `α` の直積型を返す関数
#check F -- Type u → Type u
次のように {}
を用いて宇宙パラメータを指定することもできる。そうすれば universe
コマンドの使用を回避できる。
def F.{u} (α : Type u) : Type u := Prod α α
#check F -- Type u → Type u
Function Abstraction and Evaluation (関数抽象と評価)
Leanでは、キーワード fun
(あるいは λ
) を使うと、式から関数を作ることができる。
-- 括弧は省略可能
-- fun (<入力引数の名前> : <入力引数の型名>) => <関数の出力を定義する式>
-- λ (<入力引数の名前> : <入力引数の型名>) => <関数の出力を定義する式>
#check fun (x : Nat) => x + 5 -- Nat → Nat
#check λ (x : Nat) => x + 5 -- `λ` と `fun` は同じ意味
#check fun x => x + 5 -- `x` は `Nat` 型だと推論される
#check λ x => x + 5 -- `x` は `Nat` 型だと推論される
要求されたパラメータを通すことにより、ラムダ関数を評価することができる。
#eval (λ x : Nat => x + 5) 10 -- 15
もし入力引数 x : α
があり、さらに式 t : β
が作れるなら、式 fun (x : α) => t
(λ (x : α) => t
) は型 α → β
を持つ。このように、「入力引数」と「出力を定義する式」を結びつけて新しい関数を作るプロセスはlambda abstraction(ラムダ抽象)として知られている。fun (x : α) => t
は型 α
から型 β
への、任意の値 x
を値 t
に写す関数だと考えてほしい。
他のいくつかの例を挙げる:
#check fun x : Nat => fun y : Bool => if not y then x + 1 else x + 2
#check fun (x : Nat) (y : Bool) => if not y then x + 1 else x + 2
#check fun x y => if not y then x + 1 else x + 2 -- Nat → Bool → Nat
Leanはこの3つの例を全く同じ式であると解釈する。最後の例では、入力の型が省略されているにも関わらず、#check
コマンドは期待した型を返してくれている。これはLeanが式 if not y then x + 1 else x + 2
から入力の型を推論したからである。
関数の操作の数学的に一般的な例は、ラムダ抽象を用いて記述することができる:
def f (n : Nat) : String := toString n
def g (s : String) : Bool := s.length > 0
#check fun x : Nat => x -- Nat → Nat
#check fun x : Nat => true -- Nat → Bool
#check fun x : Nat => g (f x) -- Nat → Bool
#check fun x => g (f x) -- Nat → Bool
これらの式の意味を考えてみよう。式 fun x : Nat => x
は Nat
上の恒等関数を表す。式 fun x : Nat => true
は常に true
を返す定数関数を表す。式 fun x : Nat => g (f x)
は f
と g
の合成を表す。一般的に、入力引数の型注釈を省略して、Leanに型を推論してもらうことができる。例えば、fun x : Nat => g (f x)
の代わりに fun x => g (f x)
と書くことができる。
入力引数として関数を与えることもできる:
#check fun (g : String → Bool) (f : Nat → String) (x : Nat) => g (f x)
-- (String → Bool) → (Nat → String) → Nat → Bool
何なら型を入力引数として与えることもできる:
#check fun (α β γ : Type) (g : β → γ) (f : α → β) (x : α) => g (f x)
例えば、最後の式は3つの型 α
、β
、γ
と2つの関数 g : β → γ
と f : α → β
を受け取り、g
と f
の合成を返す。(この関数の型が意味をなすようにするには「依存積」の概念が必要になるが、それは後ほど説明される。)
ラムダ抽象の一般的な形式は fun x : α => t
である。ここで、変数 x
はbounded variable(束縛変数)と呼ばれる: この x
は実際単なるプレースホルダーであり、その「スコープ」は式 t
の中に限定され、t
を超えて広く及ぶことはない。例えば、定数 b
が既に宣言されていたとする。この後に項 fun (b : β) (x : α) => b
を作ったとしても、その中の b
は既存の定数 b
には影響しない。ラムダ抽象内の束縛変数名はどのように変えてもいい。実際、項 fun (b : β) (x : α) => b
と項 fun (u : β) (z : α) => u
は全く同じ関数である。
束縛変数の名前を変えることで互いに同じだと分かる式たちのことをalpha equivalent(α-同値)と呼ぶ。そして、α-同値なものは「同じ」だとみなされる。Leanはα-同値を認識している。
項 t : α → β
を 項 s : α
に適用すると、項 t s : β
が得られることに注意してほしい。型を入力として受け取るようにし、分かりやすさのため束縛変数をリネームすると、以前の例は次のように書き換えられる:
#check (fun x : Nat => x) 1 -- Nat
#check (fun x : Nat => true) 1 -- Bool
def f (n : Nat) : String := toString n
def g (s : String) : Bool := s.length > 0
#check
(fun (α β γ : Type) (u : β → γ) (v : α → β) (x : α) => u (v x)) Nat String Bool g f 0
-- Bool
期待通り、式 (fun x : Nat => x) 1
は Nat
型を持つ。もっと詳しいことが言える: 1
に式 (fun x : Nat => x)
を適用したら、値 1
が出力されるべきである。そして、実際にそうである:
def f (n : Nat) : String := toString n
def g (s : String) : Bool := s.length > 0
#eval (fun x : Nat => x) 1 -- 1
#eval (fun x : Nat => true) 1 -- true
#eval (fun (α β γ : Type) (u : β → γ) (v : α → β) (x : α) => u (v x)) Nat String Bool g f 0 -- true
これらの項がどのように評価されているのかは後ほど学ぶ。今のところ、次の依存型理論の重要な特徴に注目してほしい: 全ての項が計算上の動作を持ち、またnormalization(正規化)の概念をサポートしている。原則として、同じ値に簡約される2つの項はdefinitionally equalであると呼ばれる。Leanの型チェッカーはdefinitionally equalである2つの項を「同じ」とみなす。Leanは2つの項の間の相等関係を認識しサポートするために最善を尽くす。
Leanは完全なプログラミング言語である。バイナリ実行ファイルを生成するコンパイラと、対話型のインタプリタがある。コマンド #eval
で式を評価することができ、#eval
は関数をテストする方法として好まれている。
Definitions (定義)
def
キーワードは名前付きの新しい項を宣言する重要な方法を提供することを思い出してほしい。
-- def <関数の名前> (<入力引数の名前> : <入力引数の型>) : <出力の型> := <出力を定義する式>
def double (x : Nat) : Nat :=
x + x
他のプログラミング言語における関数の働きを知っていると、この定義の仕方がより馴染み深くなるかもしれない。double
は Nat
型の入力パラメータ x
を取り、呼び出すと x + x
を出力する関数である。したがって、出力の型は Nat
型である。この関数は次のように使うことができる:
def double (x : Nat) : Nat :=
x + x
#eval double 3 -- 6
def
を名前付きのラムダ抽象だと考えることもできる。次の例は直前の例と同じ結果を得る:
def double : Nat → Nat :=
fun x => x + x
#eval double 3 -- 6
型を推論するのに十分な情報があるなら、型宣言を省略することができる。型推論はLeanの重要な部分である:
def double :=
fun (x : Nat) => x + x
#eval double 3 -- 6
改めて、定義に用いる一般的な文法は次のように書ける。
def foo : α := bar
ここで α
は式 bar
により定義される出力の型である。Leanは通常、型 α
が何であるかを推論できるため、α
を省略しても問題ないことが多い。しかし、α
を明示的に書くことはしばしば良いことである。型の明示は書き手の意図を明確にする。そして、Leanは bar
が持つ型と α
が一致するかをチェックしてくれる。もし一致しなければ、Leanはエラーを返す。
右辺 bar
はラムダ式に限らずどんな式でもよい。したがって、def
は次のように単に値に名前を付けるために使ってもよい:
def pi := 3.141592654
def
は複数の入力パラメータを受け取ることができる。2つの自然数を足し合わせる関数を作ってみよう:
def add (x y : Nat) :=
x + y
#eval add 3 2 -- 5
パラメータリストは2つ以上に分けて書くこともできる:
def double (x : Nat) : Nat :=
x + x
def add (x : Nat) (y : Nat) :=
x + y
#eval add (double 3) (7 + 9) -- 22
最後の行では、add
への第1引数を作るために double
関数を呼び出したことに注意してほしい。
def
の中では他の面白い式を使うこともできる:
def greater (x y : Nat) :=
if x > y then x
else y
#eval greater 7 6 -- 7
#eval greater 99 100 -- 100
#eval greater 5 5 -- 5
greater
関数がどんな働きをするかはおそらく推測できるだろう。
入力として他の関数を受け取る関数を定義することもできる。次の定義 doTwice
は第1引数として与えられた関数 f
を2回呼び出し、f
に第2引数 x
を入力して得られた出力を再び f
に入力し、そこから得られた出力を返す:
def double :=
fun (x : Nat) => x + x
def square :=
fun (x : Nat) => x * x
def doTwice (f : Nat → Nat) (x : Nat) : Nat :=
f (f x)
#eval doTwice double 2 -- 8
#eval doTwice square 3 -- 81
少し抽象的な例を見てみよう。次のように型を入力引数として指定することができる:
def compose (α β γ : Type) (g : β → γ) (f : α → β) (x : α) : γ :=
g (f x)
compose
は入力引数として任意の2つの関数 g
、f
を受け取る。ただし、g
と f
はともにただ1つの入力を受け取る関数でなければならない。さらに g : β → γ
と f : α → β
は、f
の出力の型と g
の入力の型が一致していなければならないという制約を意味する。g
と f
が以上の制約を満たすなら、compose
は意味をなす。そうでなければ2つの関数は合成不可能である。
compose
は型 α
を持つ第3引数 x
をとる。x
は f
に入力され、f
からの出力は g
に入力される。g
は型 γ
の項を返す。したがって、compose
関数の返り値の型も γ
である。
compose
は任意の型 α β γ
について機能するという意味で非常に普遍的である。これは任意の2つの関数 g f
がともにただ1つの入力を受け取り、f
の出力の型と g
の入力の型が一致しているならば、compose
は g
と f
を合成できることを意味する。以下に例を挙げる:
def double :=
fun (x : Nat) => x + x
def square :=
fun (x : Nat) => x * x
def compose (α β γ : Type) (g : β → γ) (f : α → β) (x : α) : γ :=
g (f x)
#eval compose Nat Nat Nat double square 3 -- 18
#eval compose Nat Nat Nat square double 3 -- 36
Local Definitions (ローカルな定義)
キーワード let
を使うと「ローカルな」定義を導入することができる。式 let a := t1; t2
は t2
の中に現れる全ての a
を t1
で置き換えたものとdefinitionally equalである。
#check let y := 2 + 2; y * y -- Nat
#eval let y := 2 + 2; y * y -- 16
def double_square (x : Nat) : Nat :=
let y := x + x; y * y
#eval double_square 2 -- 16
y * y
の中に現れる全ての y
を x + x
で置き換えることにより、double_square x
は項 (x + x) * (x + x)
とdefinitionally equalであることが分かる。let
文を繋げることで、複数のローカルな定義を組み合わせることもできる:
#check let y := 2 + 2; let z := y + y; z * z -- Nat
#eval let y := 2 + 2; let z := y + y; z * z -- 64
改行を挟めば ;
を省略することができる。
def double_square (x : Nat) : Nat :=
let y := x + x
y * y
式 let a := t1; t2
の意味は (fun a => t2) t1
の意味と非常に似ているが、この2つは同じではないことに注意してほしい。let a := t1; t2
において、t2
の中に現れる a
は t1
の省略形だと考えるべきである。一方 (fun a => t2) t1
では、a
は変数である。したがって、fun a => t2
は a
の値に依存せずに意味をなさなければならない。let
は強力な省略手法であり、let a := t1; t2
とは表現できても (fun a => t2) t1
とは表現できない式が存在する。練習問題として、次の例において、なぜ foo
は型チェックをパスするが bar
は型チェックをパスしないのかを理解してみよう。
def foo := let a := Nat; fun x : a => x + 2
/-
def bar := (fun a => fun x : a => x + 2) Nat
-/
Variables and Sections (変数とセクション)
次の3つの関数の定義を考えてみよう:
def compose (α β γ : Type) (g : β → γ) (f : α → β) (x : α) : γ :=
g (f x)
def doTwice (α : Type) (h : α → α) (x : α) : α :=
h (h x)
def doThrice (α : Type) (h : α → α) (x : α) : α :=
h (h (h x))
#print compose
#print doTwice
#print doThrice
Leanでは、variable
コマンドを使うとこのような定義をよりコンパクトにすることができる。variable (<変数の名前> : <変数の型名>)
と書くと具体的な関数・定数定義と独立して変数名に型を付けることができる:
variable (α β γ : Type)
def compose (g : β → γ) (f : α → β) (x : α) : γ :=
g (f x)
def doTwice (h : α → α) (x : α) : α :=
h (h x)
def doThrice (h : α → α) (x : α) : α :=
h (h (h x))
#print compose
#print doTwice
#print doThrice
variable
を使うと、Type
に限らない任意の型を変数に与えることができる:
variable (α β γ : Type)
variable (g : β → γ) (f : α → β) (h : α → α)
variable (x : α)
def compose := g (f x)
def doTwice := h (h x)
def doThrice := h (h (h x))
#print compose
#print doTwice
#print doThrice
#print <定義の名前>
コマンドを使うと、指定した定義に関する情報が表示される。#print
コマンドを使うと、上の3つの定義グループが同じ3つの関数を定義していることを確認することができる。
variable
コマンドは、このコマンドで宣言された変数を参照する定義に、該当変数を束縛変数(入力引数)として挿入するようLeanに指示する。Leanは賢いので、各定義の中でどの変数が明示的あるいは暗黙のうちに使われているかを特定することができる。例えば、Leanは doTwice
の中で変数 h
と x
のみならず、α
が使われていることを特定することができる。したがって、α
、β
、γ
、g
、f
、h
、x
は指定した型を持つ固定された項であると考えて定義を書けば、後はLeanが自動的に定義を抽象化してくれるのである。
一度 variable
を使って変数が宣言されれば、その変数は宣言されたところから現在のファイルの最後まで有効である。しかしながら、変数のスコープを制限した方が使いやすいときもある。変数のスコープを制限するために、Leanはキーワード section
を提供する:
section useful
variable (α β γ : Type)
variable (g : β → γ) (f : α → β) (h : α → α)
variable (x : α)
#check α -- セクション内で変数 `α` は参照可能。
def compose := g (f x)
def doTwice := h (h x)
def doThrice := h (h (h x))
end useful
#check compose -- セクション内で定義された関数はセクション外でも参照可能。
-- #check α -- エラー。セクション外で変数 `α` は参照不可能。
例で示した通り、variable
を用いてセクション内で宣言された変数は、セクション外ではもはや参照不可能である。
セクション内の行をインデントする必要はない。また、セクションに名前を付ける必要もない。つまり、無名の section
と end
を使うことができる。ただし、セクションに名前を付けた場合は、同じ名前を使ってセクションを閉じる必要がある。また、セクションは入れ子にすることができ、入れ子になったセクションを使うと段階的に新しい変数を宣言することができる。
Namespaces (名前空間)
キーワード namespace
を使うと、入れ子にできる階層的な名前空間を作ることができ、その中に定義を入れて定義をグループ化することができる:
namespace Foo
def a : Nat := 5
def f (x : Nat) : Nat := x + 7
def fa : Nat := f a
def ffa : Nat := f (f a)
#check a
#check f
#check fa
#check ffa
#check Foo.fa
end Foo
-- #check a -- error
-- #check f -- error
#check Foo.a
#check Foo.f
#check Foo.fa
#check Foo.ffa
open Foo
#check a
#check f
#check fa
#check Foo.fa
名前空間 Foo
内で宣言した全ての識別子(定義の名前)は、宣言時の名前の頭に "Foo.
" を付けたフルネームを持つ。名前空間 Foo
の中では、"Foo.
" を省略した短い名前で識別子を参照することができる。しかし、名前空間 Foo
の外では、"Foo.
" を省略しないフルネームを使って識別子を参照しなければならない。セクションとは違い、名前空間には名前を付ける必要がある。Leanにおいて、ただ1つの無名の名前空間はルートレベルに存在する。これ以外に無名の名前空間の存在は許されない。
open <名前空間名>
コマンドを使うと、"<名前空間名>.
" を省略した短い名前を使えるようになる。モジュール(外部ファイル)をインポートしたときは、短い名前を使うためにそのモジュールが含む名前空間を開きたくなるかもしれない。一方で、開きたい名前空間A内のある識別子 A.bar
と他の名前空間B内の使用したい識別子 B.bar
が衝突するときは、名前空間を開かず、そのような定義をフルネームで保護されたままにしておきたいと思うかもしれない。このように、名前空間は作業環境において名前を管理する方法を提供する。
例えば、Leanはリストに関する定義と定理を名前空間 List
の中でグループ化している。
#check List.nil
#check List.cons
#check List.map
open List
を使うと、これらの定義に短い名前でアクセスできるようになる:
open List
#check nil
#check cons
#check map
セクションのように、名前空間は入れ子にすることができる:
namespace Foo
def a : Nat := 5
def f (x : Nat) : Nat := x + 7
def fa : Nat := f a
namespace Bar
def ffa : Nat := f (f a)
#check fa
#check ffa
end Bar
#check fa
#check Bar.ffa
end Foo
#check Foo.fa
#check Foo.Bar.ffa
open Foo
#check fa
#check Bar.ffa
namespace <名前空間名>
を用いることで、名前空間は閉じた後再び開くことができる。インポート元の名前空間をインポート先で開くことさえできる。
namespace Foo
def a : Nat := 5
def f (x : Nat) : Nat := x + 7
def fa : Nat := f a
end Foo
#check Foo.a
#check Foo.f
namespace Foo
def ffa : Nat := f (f a)
end Foo
#check Foo.ffa
閉じられた名前空間は、例え他のファイルの中であっても、再び開くことができる:
namespace Foo
def a : Nat := 5
def f (x : Nat) : Nat := x + 7
def fa : Nat := f a
end Foo
#check Foo.a
#check Foo.f
namespace Foo
def ffa : Nat := f (f a)
end Foo
セクションと同様、入れ子になった名前空間は開かれた順に閉じられなければならない。名前空間とセクションは異なる役割を持つ: 名前空間はデータを整理し、セクションは定義に挿入される変数の宣言を整理する。セクションは set_option
や open
のようなコマンドのスコープを区切るのにも便利である。
しかしながら、多くの側面で namespace ... end
ブロックは section ... end
ブロックと同様に振る舞う。特に、名前空間内で variable
コマンドを使った場合、そのスコープは名前空間内に限定される。同様に、名前空間内で open
コマンドを使った場合、open
コマンドの効果はその名前空間が閉じられたときに切れる。
What makes dependent type theory dependent? (何が依存型理論を依存たらしめているのか?)
「型はパラメータ(引数)に依存することができる」、これが簡潔な説明である。既に良い例を見てきた: 型 List α
は引数 α
に依存し、この依存性こそが List Nat
と List Bool
を区別する。別の例として、型 Vector α n
について考えてみよう。Vector α n
は型 α
の項からなる長さ n
のベクトル(動的配列)の型である。この型は2つのパラメータ、ベクトルの要素の型 α : Type
とベクトルの長さ n : Nat
に依存する。
今、リストの先頭に新しい要素を挿入する関数 cons
を作りたいとしよう。cons
はどんな型を持つだろうか。このような関数は多相であってほしい: cons
関数は Nat
、Bool
、ひいては任意の型 α
に対して同様に動作してほしい。これは、cons
は最初の引数として型 α
をとるべきであることを意味する。そうすれば、任意の型 α
に対して、cons α
は型 α
の項からなるリスト List α
のための挿入関数となる。さらに、挿入する要素 a : α
と a
が挿入されるリスト as : List α
が引数として必要だろう。これらの引数があれば、cons
は a
を as
の先頭に挿入した新しいリストを作り、出力することができる。したがって cons α a as : List α
という形の定義が適切だと考えられる。
cons α
が型 α → List α → List α
を持つべきなのは明らかである。それでは、型を与える前の cons
はどんな型を持つべきだろうか。cons
の持つ型として最初に思いつくのは Type → α → List α → List α
だろう。しかしこれは意味をなさない: α
は Type
型の引数を参照すべきだが、Type → α → List α → List α
内の α
は何も参照しない。言い換えれば、α
と List α
の意味は型 Type
の引数によって決まるが、Type → α → List α → List α
は型 Type
の引数に関する情報を持たない。つまり、α
と List α
は最初の引数 α : Type
に依存しているのである。実際に cons
の型を #check
で確認してみよう。
def cons (α : Type) (a : α) (as : List α) : List α :=
List.cons a as
#check cons Nat -- Nat → List Nat → List Nat
#check cons Bool -- Bool → List Bool → List Bool
#check cons -- (α : Type) → α → List α → List α
これはdependent function type(依存関数型 あるいは 依存積型 あるいは パイ(Π)型)あるいはdependent arrow type(依存矢印型)の一例である。α : Type
と β : α → Type
があるとき、β
は α
上の型の族だと思ってほしい。つまり、項 β : α → Type
を任意の a : α
に β a : Type
を割り当てる関数だと考えるのである。このとき、型 (a : α) → β a
は任意の a : α
に型 β a
の項 f a
を割り当てる関数 f
の型を表す。言い換えると、関数 f
によって返される値 f a
の型 β a
は入力 a
に依存して変わる。(a : α) → β a
という記法は、入力に依存して出力の型が変わるような関数の型を記述することができるのである。
(a : α) → β
という依存関数型の記法は、任意の式 β
に対して、たとえ β
が引数 a : α
に依存して変わるときでも、意味をなすのである。これは α → β
という単純関数型の記法は β
が引数 a : α
に依存して変わるときには意味をなさないことと対照的である。つまり、依存関数型は単純関数型より表現の幅が広い。β
が a
に依存しないときは、型 (a : α) → β
と 型 α → β
に違いはない。実際、依存型理論において、そしてLeanにおいて、α → β
という記法は、β
が a : α
に依存しないときの (a : α) → β
の略記に過ぎない。
リストの例に戻る。#check
コマンドを使うと、以下の List
に関する関数の型をチェックすることができる。
記号 @
、それから括弧 ()
と波括弧 {}
の違いについてはすぐ後で説明する。
#check @List.cons -- {α : Type u_1} → α → List α → List α
#check @List.nil -- {α : Type u_1} → List α
#check @List.length -- {α : Type u_1} → List α → Nat
#check @List.append -- {α : Type u_1} → List α → List α → List α
β
が α
に依存できるようにすることで依存関数型 (a : α) → β a
が関数型 α → β
を一般化するのと同様に、dependent Cartesian product type(依存直積型 あるいは 依存和型 あるいは シグマ型) (a : α) × β a
は直積型 α × β
を一般化する。依存直積型は β
が a
に依存することを可能にする。依存直積型はシグマ(Σ)型とも呼ばれ、(a : α) × β a
は Σ a : α, β a
とも書かれる。Leanにおいて、⟨a, b⟩
あるいは Sigma.mk a b
と書くと依存ペアを作ることができる。⟨
は \langle
または \<
と打つと入力でき、⟩
は \rangle
または \>
と打つと入力できる。
universe u v
def f (α : Type u) (β : α → Type v) (a : α) (b : β a) : (a : α) × β a :=
⟨a, b⟩
def g (α : Type u) (β : α → Type v) (a : α) (b : β a) : Σ a : α, β a :=
Sigma.mk a b
#print f
#print g
def h1 (x : Nat) : Nat :=
(f Type (fun α => α) Nat x).2
#eval h1 5 -- 5
def h2 (x : Nat) : Nat :=
(g Type (fun α => α) Nat x).2
#eval h2 5 -- 5
def i : Type := -- iは ``Nat`` 型のこと
(f Type (fun α => α) Nat 5).1
def test : i := 5 + 5
#eval test -- 10
上記の f
と g
は同じ関数である。
Implicit Arguments (暗黙の引数)
次のようなリストの実装 Lst
があるとする:
universe u
def Lst (α : Type u) : Type u := List α
def Lst.cons (α : Type u) (a : α) (as : Lst α) : Lst α := List.cons a as
def Lst.nil (α : Type u) : Lst α := List.nil
def Lst.append (α : Type u) (as bs : Lst α) : Lst α := List.append as bs
#check Lst -- Lst.{u} (α : Type u) : Type u
#check Lst.cons -- Lst.cons.{u} (α : Type u) (a : α) (as : Lst α) : Lst α
#check Lst.nil -- Lst.nil.{u} (α : Type u) : Lst α
#check Lst.append -- Lst.append.{u} (α : Type u) (as bs : Lst α) : Lst α
このとき、次のように Nat
の項からなるリストを作ることができる:
universe u
def Lst (α : Type u) : Type u := List α
def Lst.cons (α : Type u) (a : α) (as : Lst α) : Lst α := List.cons a as
def Lst.nil (α : Type u) : Lst α := List.nil
def Lst.append (α : Type u) (as bs : Lst α) : Lst α := List.append as bs
#check Lst -- Type u_1 → Type u_1
#check Lst.cons -- (α : Type u_1) → α → Lst α → Lst α
#check Lst.nil -- (α : Type u_1) → Lst α
#check Lst.append -- (α : Type u_1) → Lst α → Lst α → Lst α
#check Lst.cons Nat 0 (Lst.nil Nat) -- Lst Nat
#eval Lst.cons Nat 0 (Lst.nil Nat) -- [0]
def as : Lst Nat := Lst.nil Nat
def bs : Lst Nat := Lst.cons Nat 5 (Lst.nil Nat)
#check Lst.append Nat as bs -- Lst Nat
#eval Lst.append Nat as bs -- [5]
Lst
とそのコンストラクタ Lst.cons
、Lst.nil
、Lst.append
は型について多相であるため、これらを使うときは毎回型 Nat
を引数として与えなければならない。しかし、この情報は冗長である。Lst.cons Nat 5 (Lst.nil Nat)
について考えてみよう。第二引数が 5 : Nat
であることから、α
が Nat
であることは容易に推論できる。同様に、Lst.cons Nat 5 (Lst.nil Nat)
の Lst.nil
の引数が Nat
であることも、Lst.cons
の引数の情報 (as : Lst α)
と α
が Nat
であることを照らし合わせれば分かる。
言わば、たとえ Lst.cons Nat 5 (Lst.nil Nat)
が虫に食われて Lst.cons _ 5 (Lst.nil _)
となっていたとしても、各 _
に入る型が何であるか推論できるのである。
これは依存型理論の中心的な特徴である: 項は多くの情報を持つ。そしていくつかの失われた情報は文脈から推論できる。Leanでは、アンダースコア _
を使うことで、ここの情報を自動で埋めてほしいとシステムに指示することができる。これは「implicit argument(暗黙の引数)」と呼ばれている。次に例を挙げる:
universe u
def Lst (α : Type u) : Type u := List α
def Lst.cons (α : Type u) (a : α) (as : Lst α) : Lst α := List.cons a as
def Lst.nil (α : Type u) : Lst α := List.nil
def Lst.append (α : Type u) (as bs : Lst α) : Lst α := List.append as bs
#check Lst -- Type u_1 → Type u_1
#check Lst.cons -- (α : Type u_1) → α → Lst α → Lst α
#check Lst.nil -- (α : Type u_1) → Lst α
#check Lst.append -- (α : Type u_1) → Lst α → Lst α → Lst α
#check Lst.cons _ 0 (Lst.nil _) -- Lst Nat
def as : Lst Nat := Lst.nil _
def bs : Lst Nat := Lst.cons _ 5 (Lst.nil _)
#check Lst.append _ as bs -- Lst Nat
見事に推論に成功している。
しかし、アンダースコアをいちいち入力するのはやはり面倒である。関数が一般的に文脈から推論できる引数をとる場合、「この引数は(デフォルトでは)暗黙のうちに推論してほしい引数である」とLeanに指示することができる。次のように波括弧 {}
で引数をくくると、その引数を暗黙の引数とすることができる:
universe u
def Lst (α : Type u) : Type u := List α
def Lst.cons {α : Type u} (a : α) (as : Lst α) : Lst α := List.cons a as
def Lst.nil {α : Type u} : Lst α := List.nil
def Lst.append {α : Type u} (as bs : Lst α) : Lst α := List.append as bs
#check Lst.cons 0 Lst.nil -- Lst Nat
def as : Lst Nat := Lst.nil
def bs : Lst Nat := Lst.cons 5 Lst.nil
#check Lst.append as bs -- Lst Nat
変わったのは、関数の定義において α : Type u
を波括弧で囲んだことだけである。他の例も挙げる:
universe u
def ident {α : Type u} (x : α) := x
#check ident -- ?m → ?m
#check ident 1 -- Nat
#check ident "hello" -- String
#check @ident -- {α : Type u_1} → α → α
この例では、関数 ident
の第1引数が暗黙の引数になっている。こうすることで、型の指定が隠され、見かけ上 ident
が任意の型の引数を取るように見える。実際、ident
と同じ機能を持つ関数 id
は標準ライブラリ内でこのように定義されている。今回は、名前の衝突を避けるため、伝統的でない名前 ident
を用いた。
variable
コマンドを使ったときも、変数を暗黙の引数として指定することができる。
universe u
section
variable {α : Type u}
variable (x : α)
def ident := x
end
#check ident -- {α : Type u_1} → α → α
#check ident 4 -- Nat
#check ident "hello" -- String
この ident
の定義は一つ上の定義と同じ効果を持つ。
Leanには暗黙の引数を推論するための非常に複雑なメカニズムがあり、それを使うと関数型や述語、さらには証明を推論できることを見ていく。このような「穴」または「プレースホルダー」を埋めるプロセスは、elaborationとしてよく知られている。暗黙の引数の存在により、ある式の意味を正確に確定させるための情報が不十分である状況が起こりうる。id
や List.nil
のような表現は、文脈によって異なる意味を持つことがあるため、polymorphic(多相的)であると言われる。
式 e
の型 T
は、(e : T)
と書くことで指定できる。これはLeanのelaboratorに、暗黙の引数の解決を試みるとき、e
の型として T
を使うように指示する。以下の4行目と5行目では、id
と List.nil
の型を思い通りに指定するために、このメカニズムが使われている:
#check List.nil -- List ?m
#check id -- ?m → ?m
#check (List.nil : List Nat) -- List Nat
#check (id : Nat → Nat) -- Nat → Nat
Leanでは数字はオーバーロードされる。しかし数字の型が推論できない場合、Leanはデフォルトでその数字の型は自然数であると仮定する。次の例の1行目にはその機能の実例が示されている。3行目では数字の型が指定されているため、1行目と2行目とは異なり 2
の型が Int
であると推論されている。
#check 2 -- Nat
#check (2 : Nat) -- Nat
#check (2 : Int) -- Int
しかし、ある関数の引数を暗黙の引数として宣言しておきながら、その引数を明示的に与えたいという状況に陥ることがある。foo
がそのような関数である場合、@foo
という表記は、すべての引数を明示的にした同じ関数を表す。
#check @id -- {α : Sort u_1} → α → α
#check @id Nat -- Nat → Nat
#check @id Bool -- Bool → Bool
#check @id Nat 1 -- Nat
#check @id Bool true -- Bool
最初の #check
コマンドは、何のプレースホルダーも与えていない状態での恒等関数 id
そのものの型を表していることに注意してほしい。さらに、#check
コマンドの表示は、第1引数が暗黙の引数であること波括弧を用いて示している。
Propositions and Proofs (命題と証明)
第2章で、Leanにおいて項と関数を定義する方法を見てきた。この章では、依存型理論の言語を用いて数学的な主張と証明を書く方法を学ぶ。
Propositions as Types (型としての命題)
依存型理論の言語で定義された項に関する命題を証明するための戦略の一つは、定義に用いる言語の上に命題に用いる言語と証明に用いる言語を重ねることである。しかし、複数の言語を使う必要はない。 依存型理論は、命題と証明を同じ一般的な枠組みで表現するのに十分な柔軟さと表現力を兼ね備えている。
例えば、命題を表す新しい型 Prop
を導入することができる。さらに、他の命題から新しい命題を構築するコンストラクタを導入することができる。
def Implies (p q : Prop) : Prop := p → q
#check And -- Prop → Prop → Prop
#check Or -- Prop → Prop → Prop
#check Not -- Prop → Prop
#check Implies -- Prop → Prop → Prop
variable (p q r : Prop)
#check And p q -- Prop
#check Or (And p q) r -- Prop
#check Implies (And p q) (And q p) -- Prop
それから、各要素 p : Prop
に対して、p
の証明の型 Proof p
を導入できる。
「公理」とは、Proof p
のような命題の証明の型を持った定数である。
def Implies (p q : Prop) : Prop := p → q
structure Proof (p : Prop) : Type where
proof : p
#check Proof -- Proof (p : Prop) : Type
axiom and_comm (p q : Prop) : Proof (Implies (And p q) (And q p))
variable (p q : Prop)
#check and_comm p q -- and_comm p q : Proof (Implies (p ∧ q) (q ∧ p))
#check and_comm q p -- and_comm q p : Proof (Implies (q ∧ p) (p ∧ q))
公理だけでなく、既存の証明から新しい証明を作るためのルールも必要である。例えば、命題論理の証明系の多くには、"modus ponens(モーダス・ポネンス)"という推論規則がある。
modus ponens :
Implies p q
の証明とp
の証明があれば、そこからq
の証明が得られる。
Leanではモーダス・ポネンスを次のように表現できる。
def Implies (p q : Prop) : Prop := p → q
structure Proof (p : Prop) : Type where
proof : p
axiom modus_ponens : (p q : Prop) → Proof (Implies p q) → Proof p → Proof q
一般的に、命題論理のための自然演繹のシステムは次の推論規則も採用している:
含意導入 :
p
を仮定するとq
の証明が得られるとする。このとき、Implies p q
の証明が得られる。
含意導入はLean上で次のように表現できる。
def Implies (p q : Prop) : Prop := p → q
structure Proof (p : Prop) : Type where
proof : p
axiom implies_intro : (p q : Prop) → (Proof p → Proof q) → Proof (Implies p q)
以上の手法は、命題と証明の合理的な構築方法を提供する。この手法において、ある式 t
が命題 p : Prop
の正しい証明であることを確定させるには、t
が Proof p
という型を持つことをチェックすればよい。
いくつかの簡略化が可能である。まず、命題 p : Prop
があるとき、p
自体を型として解釈することができる。さらに、型 p
を p
の証明の型と解釈する。つまり、型 p
と型 Proof p
を同一視する。すると、「t
は p
の証明である」という主張をシンプルに t : p
と書くことができる。
この簡略化により、毎回 Proof
と書く手間が省ける。
さらにこの手法を発展させる。命題 p
と q
の間の含意 Implies p q
は、p
の任意の要素にq
の要素を一つ割り当てる関数 p → q
と同一視できる。結果として、Implies
という結合子の導入は不要である。含意の型 Implies p q
の代わりに、依存型理論の関数の型 p → q
を使えばよいのである。
これがCalculus of Constructionsのアプローチであり、Leanはこのアプローチを採用している。自然演繹の証明系における含意に関する規則が、関数のラムダ抽象と適用に関する規則と正確に対応しているという事実は、Curry-Howard isomorphism(カリー=ハワード同型)の一例であり、proposition-as-types(型としての命題)パラダイムとして知られている。実は、型 Prop
は前章で説明した型階層の最下層である型 Sort 0
の糖衣構文である(Prop
と Sort 0
は全く同じ意味である)。さらに言えば、型 Type u
は型 Sort (u+1)
の糖衣構文に過ぎない。Prop
はいくつか特別な特徴を持っているが、他の型宇宙と同様に、アローコンストラクタの下で閉じている。つまり、p q : Prop
ならば p → q : Prop
である(α β : Type
ならば α → β : Type
であるのと同様である)。
「型としての命題」について考えるには、少なくとも2通りの方法がある。論理学や数学について構成主義的な立場をとる人にとっては、「型としての命題」は「命題とはどういうものか」を忠実に表現している。命題 p
は p
の証明を構成するデータの型を表している。p
の証明とは、単に正しく型付けされた項 t : p
である。
このような主義に傾倒していない人にとって、「型としての命題」はむしろ単純なコーディング・トリックだと考えることができる。各命題 p
に対して、 p
が偽なら p
に空の型を関連付ける。p
が真なら p
にただ一つの項 *
を持つ型を関連付ける。後者のとき、p
(に関連付けられた型)を inhabited(有項) と呼び、型 p
が持つ項 *
をinhabitant(住人) と呼ぶ。このとき、たまたま、関数の適用と抽象化の規則が、Prop
のどの要素が有項かを追跡するのに便利だったのである。つまり、項 t : p
を構築することが、p
が真であることを保証してくれるのである。
このとき、p
の住人 t
とは「p
が真であるという事実」のことだと考えることができる。そうすると、p → q
の証明とは「p
が真であるという事実」を受け取って「q
が真であるという事実」を返す関数のことだと考えることができる。
実際、Leanのカーネルは (fun x => t) s
と t[s/x]
(項 t
の中の全ての x
を s
で置き換えた項) をdefinitionally equalとみなすのと同様に、任意の p : Prop
に対して任意の2つの項 t1 t2 : p
をdefinitionally equalとみなす。t1 t2 : p
をdefinitionally equalとみなすことはproof irrelevance(証明無関係)と呼ばれ、前段落の解釈と矛盾しない。つまり、証明 t : p
は依存型理論の言語の中で普通の項として扱うことができるが、t
は p
が真であるという事実以上の情報は持っていないということである。
以上で提案した「型としての命題」パラダイムについて考えるための2つの方法は、根本的なところで異なっている。構成主義的な観点からすると、証明は抽象的な数学的対象であり、依存型理論において適切な項で「表現」される。対照的に、前述のコーディング・トリックの観点からすると、項 t : p
そのものは何も面白いものを示さない。むしろ、項を書き下し、その項がきちんと型付けされていることを確認することで、問題の命題が真であることを保証するのである。つまり、項「そのもの」が証明なのである。
以下の説明では、ある項がある命題の証明を「構築する」「生成する」「返す」と表現したり、単にある項がある命題の証明「である」と表現したり、この両方の表現を使うことにする。これは、計算機科学者が、あるプログラムがある関数を「計算する」と言いながら、時にはそのプログラムがその関数「である」と言うことで、構文論と意味論の区別を曖昧にすることがあるのと似たようなものである。
いずれにせよ、本当に重要なのは次のことである: 依存型理論の言語で数学的な命題 p
を形式的に表現するには、項 p : Prop
を構築する必要がある。命題 p
を「証明」するには、項 t : p
を構築する必要がある。証明支援系Leanの仕事は、このような項 t
を構築する手助けをし、そして t
が適切な形をとっていて正しい型を持つことを検証することである。
Working with Propositions as Types (「型としての命題」を実践する)
「型としての命題」パラダイムにおいては、→
と命題だけを含む定理はラムダ抽象と関数適用を使って証明することができる。Leanでは、theorem
コマンドを使うと新しい定理を導入できる。
variable {p : Prop}
variable {q : Prop}
theorem t1 : p → q → p := fun hp : p => fun hq : q => hp
この証明を、型 α → β → α
(α
と β
は型 Type
の項) の項 fun x : α => fun y : β => x
と比較してほしい。fun x : α => fun y : β => x
は引数 x : α
と引数 y : β
をとり、x
を返す。
p → q → p
の証明は同じ形をとる。唯一の違いは p
と q
が Type
ではなく Prop
の項であることだけである。
直観的には、我々の p → q → p
の証明は命題 p : Prop
と命題 q : Prop
が正しいことを前提とし、最初の前提から p
が正しいことを(自明に)結論づける。
theorem
コマンドは def
コマンドと全く同じである。つまり、命題と型の対応の下で、定理 p → q → p
を証明することは、型 p → q → p
の要素を定義することと全く同じである。実際、Leanのカーネルの型チェッカーにとって、theorem
コマンドと def
コマンドの間に違いはない。
しかしながら、定義と定理の間にはいくつかの実用的な違いがある。通常、定理の「定義」を展開する必要はない。証明無関係の原則により、ある定理の任意の2つの証明はdefinitionally equalである。一度定理の証明が完成したら、通常はその定理の証明が存在することだけが分かればよく、その証明が何であるかは重要ではない。この事実をふまえ、Leanは証明にirreducibleとタグ付けする。ファイルを処理するとき、このタグはパーサー(より正確にはelaborator)に対して、「このタグが付いたものを展開する必要はない」というヒントとして機能する。実際、Leanは一般的に証明の処理とチェックを平行して行うことができる。これは、ある証明の正しさを検証する際に、他の証明の詳細を知る必要がないからできることである。
定義と同様に、#print
コマンドは定理の証明を表示する。
variable {p : Prop}
variable {q : Prop}
theorem t1 : p → q → p := fun hp : p => fun hq : q => hp
#print t1 -- ∀ {p q : Prop}, p → q → p := fun {p q} hp hq => hp
ラムダ抽象 hp : p
と hq : q
は p → q → p
の証明における一時的な前提と見なせることに注意してほしい。また、Leanでは、最後の項 hp
の型を、show
文で明示的に指定することができる。
variable {p : Prop}
variable {q : Prop}
theorem t1 : p → q → p :=
fun hp : p =>
fun hq : q =>
show p from hp -- show <型> from <項>
このような情報を追加することで、証明の分かりやすさを向上させ、証明を書く際の誤りを発見しやすくすることができる。show
コマンドは型に注釈をつける以上のことはしない。内部的には、これまで見てきた t1
の表現は全て同じ項を生成している。
通常の定義と同様に、theorem
コマンドにおいても、ラムダ抽象された変数をコロンの左側に持ってくることができる。
variable {p : Prop}
variable {q : Prop}
theorem t1 (hp : p) (hq : q) : p := hp
#print t1 -- p → q → p
定理 t1
は関数適用と同様に他の項に適用することができる。
variable {p : Prop}
variable {q : Prop}
theorem t1 (hp : p) (hq : q) : p := hp
axiom hp : p
theorem t2 : q → p := t1 hp
ここで、axiom
宣言は与えられた型の項の存在を無条件に認めるため、axiom
コマンドの使い方によっては論理的整合性を損なう可能性がある。例えば、axiom
コマンドにより空の型 False
が項を持つことを認めることさえ可能である。
axiom unsound : False
-- `False`(偽)からは任意の命題を示すことができる
theorem ex : 1 = 0 := -- 本来は偽の命題
False.elim unsound
「公理」hp : p
を宣言することは、hp
の存在を無条件に認め、hp
により p
が真であると宣言することと等価である。p
が真だと主張する公理 hp : p
に定理 t1 : p → q → p
を適用すると、定理 t1 hp : q → p
が得られる。
定理 t1
は次のように書けることを思い出そう。
theorem t1 {p q : Prop} (hp : p) (hq : q) : p := hp
#print t1 -- ∀ {p q : Prop}, p → q → p := fun {p q} hp hq => hp
t1
の型は ∀ {p q : Prop}, p → q → p
だと表示される。これは、「任意の命題のペア p q
について、p → q → p
が成立する」と読める。
この結果を用いると、t1
の全ての引数をコロンの右側に持っていくことができる。
theorem t1 : ∀ {p q : Prop}, p → q → p :=
fun {p q : Prop} (hp : p) (hq : q) => hp
p
と q
が variable
コマンドを使って宣言されているなら、Leanは自動的に p
と q
を全称化する。
variable {p q : Prop}
theorem t1 : p → q → p := fun (hp : p) (hq : q) => hp
#print t1 -- ∀ {p q : Prop}, p → q → p := fun {p q} hp hq => hp
「型としての命題」対応に従って、p
は正しいという前提 hp
を別の変数として宣言することができる。
variable {p q : Prop}
variable (hp : p)
theorem t1 : q → p := fun (hq : q) => hp
#print t1 -- ∀ {p q : Prop}, p → q → p := fun {p q} hp hq => hp
Leanはこの証明が hp
を使っていることを検出し、自動的に hp : p
を前提に追加する。どの例でも #print t1
は ∀ p q : Prop, p → q → p
を表示する。この型は ∀ (p q : Prop) (hp : p) (hq : q), p
とも書けることに注意してほしい。
t1
を全称化すれば、t1
を様々な命題のペアに適用させることで、一般的な定理 t1
の様々な例を得ることができる。
theorem t1 (p q : Prop) (hp : p) (hq : q) : p := hp
variable (p q r s : Prop)
#check t1 p q -- p → q → p
#check t1 r s -- r → s → r
#check t1 (r → s) (s → r) -- (r → s) → (s → r) → r → s
variable (h : r → s)
#check t1 (r → s) (s → r) h -- (s → r) → r → s
再び、「型としての命題」対応を利用すると、r → s
型の変数 h
を「r → s
は真である」という前提とみなすことができる。
別の例として、前章で説明した合成関数を、今度は型の代わりに命題を使って考えてみよう。
variable (p q r s : Prop)
theorem t2 (h₁ : q → r) (h₂ : p → q) : p → r :=
fun h₃ : p =>
show r from h₁ (h₂ h₃)
命題論理の定理として見ると、t2
は何を表現しているだろうか?
この例で使ったように、前提として使える証明項の名前にはUnicode数字添字を使うのが便利である。これらは\0
、\1
、\2
、...と打つと入力できる。
Propositional Logic (命題論理)
Leanでは標準的な論理的結合子と記法の全てが定義されている。命題論理の結合子は次のように表す:
Ascii | Unicode | エディターでの入力方法 | 定義 |
---|---|---|---|
True | True | ||
False | False | ||
Not | ¬ | \not , \neg | Not |
/\ | ∧ | \and | And |
\/ | ∨ | \or | Or |
-> | → | \to , \r , \imp | |
<-> | ↔ | \iff , \lr | Iff |
これらは Prop
型の項(命題)を取り、Prop
型の新しい項(命題)を返す。
variable (p q : Prop)
#check p → q → p ∧ q
#check ¬p → p ↔ False
#check p ∨ q → q ∨ p
演算の順序は次の通り: 単項否定 ¬
が一番最初に結合し、次に ∧
、∨
、→
、最後に ↔
が結合する。例えば、a ∧ b → c ∨ d ∧ e
と書かれていたら、それは (a ∧ b) → (c ∨ (d ∧ e))
のことである。
引数の型が Prop
であっても他の型であっても、→
は右から順に結合していくことを忘れないでほしい。つまり、p q r : Prop
とすると、p → q → r
という式は p → (q → r)
と同じである。これは、「カリー化」された p ∧ q → r
である。
前節ではラムダ抽象を →
の「導入則」とみなすことができることを説明した。ラムダ抽象が含意命題を「導入」あるいは構築する方法だとすると、関数適用は「含意の除去則」だとみなせる。つまり、関数適用は証明の中で含意を「除去する」あるいは使う方法である。他の命題論理の結合子はLeanのライブラリのファイル Init/Prelude.lean
(ライブラリの階層構造についてはImporting Files (ファイルのインポート)を参照のこと)で定義されている。それぞれの結合子には正規化された導入則と除去則が存在する。
Conjunction (連言)
式 And.intro h1 h2
は証明 h1 : p
と証明 h2 : q
を使って p ∧ q
の証明を構築する。And.intro
は一般的に「連言の導入則」と表現される。次の例では、And.intro
を使って p → q → p ∧ q
の証明を作る。
variable (p q : Prop)
example (hp : p) (hq : q) : p ∧ q := And.intro hp hq
#check fun (hp : p) (hq : q) => And.intro hp hq
example
コマンドは、定理に名前を付けたり、永続する文脈に定理を保存したりすることなく、定理を記述するのに使う。基本的には、example
コマンドは与えられた項が与えられた型を持っているかどうかをチェックするだけである。実例を示すのに便利で、よく使うコマンドである。
式 And.left h
は証明 h : p ∧ q
から p
の証明を作る。同様に、And.right h
は証明 h : p ∧ q
から q
の証明を作る。これらは一般的に「左連言除去則」と「右連言除去則」として知られている。
variable (p q : Prop)
example (h : p ∧ q) : p := And.left h
example (h : p ∧ q) : q := And.right h
ここまでの知識を使って、次のように p ∧ q → q ∧ p
を証明することができる。
variable (p q : Prop)
example (h : p ∧ q) : q ∧ p :=
And.intro (And.right h) (And.left h)
連言導入と連言除去は直積ペアの構築と射影の操作に似ていることに注意してほしい。p : Prop
、q : Prop
、hp : p
、hq : q
のとき、And.intro hp hq
は型 p ∧ q : Prop
を持つ。一方、p : Type
、q : Type
、hp : p
、hq : q
のとき、Prod hp hq
は型 p × q : Type
を持つ。
この類似性は「カリー=ハワード同型対応」の別の例である。この類似性によると、今作った証明は直積ペアの要素を入れ替える関数に似ていることになる。しかし、含意と関数型コンストラクタとは対照的に、Leanでは ∧
と ×
は別々に扱われている。
9章 Structures and Records (構造体とレコード)で、Leanにはstructures(構造体)と呼ばれる型があることを学ぶ。構造体 S
は適切な引数の列から S
の要素を構築する単一で正規のconstructor(コンストラクタ)によって定義される。任意の p q : Prop
に対して、p ∧ q
は構造体の一例である。構造体 p ∧ q
の要素を構築する正規の方法は、適切な引数 hp : p
と hq : q
に And.intro
を適用することである。
Leanでは、関連する型が帰納型であり、文脈から型推論できる場合、anonymous constructor(無名コンストラクタ)表記 ⟨arg1, arg2, ...⟩
を使うことができる。特に、And.intro hp hq
の代わりに ⟨hp, hq⟩
と書くことがよくある。
variable (p q : Prop)
variable (hp : p) (hq : q)
#check (⟨hp, hq⟩ : p ∧ q)
角括弧 ⟨ ⟩
は \<
\>
と打つことで入力できる。
他にもLeanには便利な構文機能がある。項 e
が(パラメータをとる可能性のある)帰納型 Foo
を持つとき、e.bar
は Foo.bar e
の略記である。この記法は名前空間を開くことなく関数にアクセスする便利な方法を提供する。例えば、次の2つの項は全く同じである:
variable (xs : List Nat)
#check List.length xs
#check xs.length
結果として、h : p ∧ q
があるとき、And.left h
の代わりに h.left
と書け、And.right h
の代わりに h.right
と書ける。従って、上記の例は次のように簡潔に書ける:
variable (p q : Prop)
example (h : p ∧ q) : q ∧ p :=
⟨h.right, h.left⟩
簡潔さと難解さは紙一重であり、このように情報を省略することは時として証明を読みにくくする。しかし、上のような簡単な例で、h
の型と構築したい型がはっきりしている場合、この記法は簡潔で効果的である。
And.
のような構築を繰り返すことは普通である。Leanはネストされた角括弧を外すことができる。このとき、各引数は右から結合する。したがって、次の2つの証明は等価である:
variable (p q : Prop)
example (h : p ∧ q) : q ∧ p ∧ q :=
⟨h.right, ⟨h.left, h.right⟩⟩
example (h : p ∧ q) : q ∧ p ∧ q :=
⟨h.right, h.left, h.right⟩
これも便利である。
Disjunction (選言)
式 Or.intro_left q hp
は 証明 hp : p
から p ∨ q
の証明を作る。同様に、Or.intro_right p hq
は証明 hq : q
から p ∨ q
の証明を作る。これらは「左選言導入則」と「右選言導入則」に相当する。
variable (p q : Prop)
example (hp : p) : p ∨ q := Or.intro_left q hp
example (hq : q) : p ∨ q := Or.intro_right p hq
「選言除去則」は少し複雑である。p
から r
が導かれることと、 q
から r
が導かれることの両方を示せば、p ∨ q
から r
を証明できるという考えを使う。つまり、これは場合分けによる証明である。式 Or.elim hpq hpr hqr
の中で、Or.elim
は3つの引数 hpq : p ∨ q
、hpr : p → r
、
hqr : q → r
を取り、r
の証明を作る。次の例の中で、p ∨ q → q ∨ p
を証明するのに Or.elim
を使う。
variable (p q r : Prop)
example (h : p ∨ q) : q ∨ p :=
Or.elim h
(fun hp : p =>
show q ∨ p from Or.intro_right q hp)
(fun hq : q =>
show q ∨ p from Or.intro_left p hq)
ほとんどの場合、Or.intro_right
の第1引数と Or.intro_left
の第1引数はLeanによって自動的に推論される。Leanは Or.intro_right _
の略記として Or.inr
を、Or.intro_left _
の略記として Or.inl
を提供する。したがって、上記の証明はより簡潔に書ける:
variable (p q r : Prop)
example (h : p ∨ q) : q ∨ p :=
Or.elim h (fun hp => Or.inr hp) (fun hq => Or.inl hq)
この簡潔な式の中に、Leanが hp
と hq
の型を推論するのに十分な情報が含まれていることに注意してほしい。しかし、型注釈を用いた長い記述を用いることは、証明を読みやすくし、エラーを発見してデバッグするのに役立つ。
Or
は2つのコンストラクタを持つ、つまり単一で正規のコンストラクタを持たないため、Or
の構築のために無名コンストラクタを使うことはできない。しかし、Or.elim h
の代わりに h.elim
と書くことはできる:
variable (p q r : Prop)
example (h : p ∨ q) : q ∨ p :=
h.elim (fun hp => Or.inr hp) (fun hq => Or.inl hq)
繰り返しになるが、このような略記が読みやすさを向上させるか低下させるか、書き手が判断する必要がある。
Negation and Falsity (否定と恒偽)
否定 ¬p
は p → False
と定義される。したがって、¬p
の証明は p
から矛盾を導くことで得られる。同様に、式 hnp hp
は hp : p
と hnp : ¬p
から False
の証明を作る。
次の例ではこれらの規則の両方を使って (p → q) → ¬q → ¬p
の証明を作る(記号 ¬
は \not
あるいは \neg
と打つことで入力できる)。
variable (p q : Prop)
example (hpq : p → q) (hnq : ¬q) : ¬p :=
fun hp : p =>
show False from hnq (hpq hp)
結合子 False
は単一の除去則 False.elim
を持つ。False.elim
は矛盾からは任意の命題が導かれるという事実を表現している。この規則はex falso (ex falso sequitur quodlibetの略記)あるいはprinciple of explosion(爆発律)と呼ばれる。
variable (p q : Prop)
example (hp : p) (hnp : ¬p) : q := False.elim (hnp hp)
恒偽から導かれる任意の命題 q
は暗黙の引数であり、自動的に型推論される。矛盾する前提から任意の命題を導くパターンは非常によく見られ、absurd
で表現される。
variable (p q : Prop)
example (hp : p) (hnp : ¬p) : q := absurd hp hnp
次は ¬p → q → (q → p) → r
の証明である:
variable (p q r : Prop)
example (hnp : ¬p) (hq : q) (hqp : q → p) : r :=
absurd (hqp hq) hnp
ちなみに、False
が除去則しか持たないように、True
は導入則である True.intro : True
しか持たない。つまり、True
は単に真であり、True.intro
という正規の証明を持っている。
Logical Equivalence (論理的同値)
式 Iff.intro h1 h2
は h1 : p → q
と h2 : q → p
から p ↔ q
の証明を作る。 式 Iff.mp h
は h : p ↔ q
から p → q
の証明を作る。同様に、Iff.mpr h
は h : p ↔ q
から q → p
の証明を作る。以下は p ∧ q ↔ q ∧ p
の証明である。
variable (p q : Prop)
theorem and_swap : p ∧ q ↔ q ∧ p :=
Iff.intro
(fun h : p ∧ q =>
show q ∧ p from And.intro (And.right h) (And.left h))
(fun h : q ∧ p =>
show p ∧ q from And.intro (And.right h) (And.left h))
#check and_swap p q -- p ∧ q ↔ q ∧ p
variable (h : p ∧ q)
example : q ∧ p := Iff.mp (and_swap p q) h
無名コンストラクタ記法を使って、p → q
の証明と q → p
の証明から p ↔ q
の証明を構築することができる。また、mp
と mpr
について .
に関する記法が使える。これらを使うと、上記の例は次のように簡潔に書くことができる:
variable (p q : Prop)
theorem and_swap : p ∧ q ↔ q ∧ p :=
⟨ fun h => ⟨h.right, h.left⟩, fun h => ⟨h.right, h.left⟩ ⟩
example (h : p ∧ q) : q ∧ p := (and_swap p q).mp h
Introducing Auxiliary Subgoals (補助的なサブゴールの導入)
そろそろ長い証明を書く際に役に立つ機能 have
の紹介をする頃合いだろう。have
は証明の中で補助的なサブゴールを導入する。次は前節から抜粋した短い例である。
variable (p q : Prop)
example (h : p ∧ q) : q ∧ p :=
have hp : p := h.left
have hq : q := h.right
show q ∧ p from And.intro hq hp
内部的には、式 have h : p := s; t
は項 (fun (h : p) => t) s
を作る。つまり、s
は p
の証明であり、t
は 前提 h : p
の下で欲しい結論の証明であり、s
と t
はラムダ抽象と関数適用によって組み合わせられる。have
は、長い証明を構築する際に、最終的なゴールに至るための踏み台として使えるため、非常に便利である。
Leanは、ゴールからbackward reasoning(後ろ向き推論)する構造化された方法もサポートしている。これは通常の数学における「Aを示すにはBを示せば十分である」という証明を模した手法である。次の例は、前の証明の最後の2行を単に並べ替えたものである。
variable (p q : Prop)
example (h : p ∧ q) : q ∧ p :=
have hp : p := h.left
suffices hq : q from And.intro hq hp
show q from And.right h
suffices hq : q
を使った後は2つのゴールを示す必要がある。まず、q ∧ p
を示すには q
を示せば十分であることを実際に示す必要がある。そのためには追加された前提 hq : q
を使って元のゴール q ∧ p
を証明すればよい。 最後に、q
を示す必要がある。
Classical Logic (古典論理)
これまで見てきた導入則と除去則は全て構成的論理(直観主義論理)のものである。これは「型としての命題」対応に基づいた論理的結合子の計算論的理解を反映したものである。通常の古典論理では、以上の導入則と除去則に加え、排中律 p ∨ ¬p
を認める。この原則を使うには、名前空間 Classical
を開く必要がある。
open Classical
variable (p : Prop)
#check em p -- p ∨ ¬p
直感的には、構成的論理の「Or」は非常に強い主張であり、p ∨ q
を主張することは、どちらが正しいかを知っていることに等しい。リーマン予想を RH
と表すと、古典論理を採用している数学者は、RH
と ¬RH
のどちらが正しいのか分からないうちに RH ∨ ¬RH
を主張することを厭わない。構成的論理を採用すると、このような主張の仕方はできない。
排中律の帰結として、二重否定除去則が得られる。
open Classical
theorem dne {p : Prop} (h : ¬¬p) : p :=
Or.elim (em p)
(fun hp : p => hp)
(fun hnp : ¬p => absurd hnp h)
¬p
を仮定すると False
が導かれるとき、二重否定除去を使うと命題 p
を証明することができる。なぜなら、仮定 ¬p
から False
を導いたことは、¬¬p
を証明したことと同義だからである。つまり、二重否定消去を使えば、構成的論理では一般には不可能な、矛盾による証明を行うことができる。練習として、逆を、つまり dne
から em
が証明できることを示してみよう。
古典論理の公理はまた、em
により正当化される追加の証明パターンを使えるようにする。例えば、場合分けによる証明を行うことができる:
open Classical
variable (p : Prop)
example (h : ¬¬p) : p :=
byCases
(fun h1 : p => h1)
(fun h1 : ¬p => absurd h1 h)
hpq : p → q
、hnpq : ¬p → q
のとき、byCases hpq hnpq
は q
の証明を作る。
あるいは、矛盾により証明を行うこともできる:
open Classical
variable (p : Prop)
example (h : ¬¬p) : p :=
byContradiction
(fun h1 : ¬p =>
show False from h h1)
hnpf : ¬p → False
のとき、byContradiction hnpf
は p
の証明を作る。
もし構成的論理の考え方に慣れていないなら、古典論理的な推論がどこで使われているのか感覚を掴むのに時間がかかるかもしれない。次の例は、構成的論理では、 p
と q
が両立しないと分かってもどちらが偽であるかは必ずしも分からないので、古典論理が必要である:
open Classical
variable (p q : Prop)
example (h : ¬(p ∧ q)) : ¬p ∨ ¬q :=
Or.elim (em p)
(fun hp : p =>
Or.inr
(show ¬q from
fun hq : q =>
h ⟨hp, hq⟩))
(fun hp : ¬p =>
Or.inl hp)
構成的論理には、排中律や二重否定除去のような原則が許される状況が「ある」ことを後に学ぶ。そのような状況では、Leanは排中律に頼ることなく古典論理的な推論の使用をサポートする。
古典論理的な推論を行うためにLeanで採用されている全ての公理の一覧は12章 Axioms and Computation (公理と計算)で論じられている。
Examples of Propositional Validities (命題論理における恒真式の例)
Leanの標準ライブラリは命題論理における恒真式の証明をいくつも含んでいる。その全ては読者自身の証明を書く際に自由に用いてよい。命題論理における恒真式のいくつかを以下に示す。
可換性:
p ∧ q ↔ q ∧ p
p ∨ q ↔ q ∨ p
結合性:
(p ∧ q) ∧ r ↔ p ∧ (q ∧ r)
(p ∨ q) ∨ r ↔ p ∨ (q ∨ r)
分配性:
p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r)
p ∨ (q ∧ r) ↔ (p ∨ q) ∧ (p ∨ r)
他の性質:
(p → (q → r)) ↔ (p ∧ q → r)
((p ∨ q) → r) ↔ (p → r) ∧ (q → r)
¬(p ∨ q) ↔ ¬p ∧ ¬q
¬p ∨ ¬q → ¬(p ∧ q)
¬(p ∧ ¬p)
p ∧ ¬q → ¬(p → q)
¬p → (p → q)
(¬p ∨ q) → (p → q)
p ∨ False ↔ p
p ∧ False ↔ False
¬(p ↔ ¬p)
(p → q) → (¬q → ¬p)
これらは古典論理的な推論を必要とする:
(p → r ∨ s) → ((p → r) ∨ (p → s))
¬(p ∧ q) → ¬p ∨ ¬q
¬(p → q) → p ∧ ¬q
(p → q) → (¬p ∨ q)
(¬q → ¬p) → (p → q)
p ∨ ¬p
(((p → q) → p) → p)
sorry
は魔法のようにあらゆる証明を生成したり任意の型の項を提供したりする。もちろん、sorry
は証明方法としては不健全である。例えば、sorry
を使って False
を証明することができる。Leanは、sorry
に依存する定理を使ったり、インポートしたりすると、深刻な警告を発する。しかし、長い証明を段階的に構築する際は便利である。サブ証明を sorry
で埋めながら、証明をトップダウンで書いてみよう。sorry
だけで構築された項をLeanが受容することを確認してほしい。そうでない場合は、修正する必要があるエラーが存在する。確認と修正が済んだら、実際の証明で sorry
を一つ残らず書き換えよう。
もう一つ、便利な技がある。sorry
を使う代わりに、アンダースコア _
をプレースホルダーとして使うことができる。アンダースコアは引数が暗黙であることをLeanに伝えることを思い出してほしい。そしてアンダースコアはLeanによって自動的に埋められる。もしLeanがアンダースコアを埋めることに失敗したら、エラーメッセージ "don't know how to synthesize placeholder" が返され、続いて項の予想される型とその文脈で使用可能な全ての項と前提が返される。言い換えると、解決できなかったプレースホルダー1つ1つに対して、Leanはその時点で埋める必要のあるサブゴールを報告する。最終的に、プレースホルダーを段階的に埋めていくことで、証明を構築することができる。
参考として、上記のリストから抜粋した恒真式の証明の例を2つ紹介する。
open Classical
-- 分配性
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) :=
Iff.intro
(fun h : p ∧ (q ∨ r) =>
have hp : p := h.left
Or.elim (h.right)
(fun hq : q =>
show (p ∧ q) ∨ (p ∧ r) from Or.inl ⟨hp, hq⟩)
(fun hr : r =>
show (p ∧ q) ∨ (p ∧ r) from Or.inr ⟨hp, hr⟩))
(fun h : (p ∧ q) ∨ (p ∧ r) =>
Or.elim h
(fun hpq : p ∧ q =>
have hp : p := hpq.left
have hq : q := hpq.right
show p ∧ (q ∨ r) from ⟨hp, Or.inl hq⟩)
(fun hpr : p ∧ r =>
have hp : p := hpr.left
have hr : r := hpr.right
show p ∧ (q ∨ r) from ⟨hp, Or.inr hr⟩))
-- 古典論理を必要とする例
example (p q : Prop) : ¬(p ∧ ¬q) → (p → q) :=
fun h : ¬(p ∧ ¬q) =>
fun hp : p =>
show q from
Or.elim (em q)
(fun hq : q => hq)
(fun hnq : ¬q => absurd (And.intro hp hnq) h)
Exercises (練習問題)
"sorry" プレースホルダーを実際の証明で置き換えて、以下の恒真式を証明せよ。
variable (p q r : Prop)
-- ∧ と ∨ の可換性
example : p ∧ q ↔ q ∧ p := sorry
example : p ∨ q ↔ q ∨ p := sorry
-- ∧ と ∨ の結合性
example : (p ∧ q) ∧ r ↔ p ∧ (q ∧ r) := sorry
example : (p ∨ q) ∨ r ↔ p ∨ (q ∨ r) := sorry
-- 分配性
example : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := sorry
example : p ∨ (q ∧ r) ↔ (p ∨ q) ∧ (p ∨ r) := sorry
-- 他の性質
example : (p → (q → r)) ↔ (p ∧ q → r) := sorry
example : ((p ∨ q) → r) ↔ (p → r) ∧ (q → r) := sorry
example : ¬(p ∨ q) ↔ ¬p ∧ ¬q := sorry
example : ¬p ∨ ¬q → ¬(p ∧ q) := sorry
example : ¬(p ∧ ¬p) := sorry
example : p ∧ ¬q → ¬(p → q) := sorry
example : ¬p → (p → q) := sorry
example : (¬p ∨ q) → (p → q) := sorry
example : p ∨ False ↔ p := sorry
example : p ∧ False ↔ False := sorry
example : (p → q) → (¬q → ¬p) := sorry
"sorry" プレースホルダーを実際の証明で置き換えて、以下の恒真式を証明せよ。これらは古典論理を必要とする。
open Classical
variable (p q r : Prop)
example : (p → q ∨ r) → ((p → q) ∨ (p → r)) := sorry
example : ¬(p ∧ q) → ¬p ∨ ¬q := sorry
example : ¬(p → q) → p ∧ ¬q := sorry
example : (p → q) → (¬p ∨ q) := sorry
example : (¬q → ¬p) → (p → q) := sorry
example : p ∨ ¬p := sorry
example : (((p → q) → p) → p) := sorry
最後に、古典論理を使わずに ¬(p ↔ ¬p)
を証明せよ。
Quantifiers and Equality (量化子と等号)
第3章では、命題論理の結合子を含む定理の証明を構築する方法を紹介した。この章では、命題論理の結合子に加え、全称量化子、存在量化子、等号関係を用いた定理とその証明を構築する方法を紹介する。
The Universal Quantifier (全称量化子)
任意の型 α
に対して、α
上の一変数述語 p
は、型 α → Prop
の項として表現できることに注目してほしい。この場合、x : α
が与えられると、p x
は x
について p
が成り立つという主張を表す。同様に、項 r : α → α → Prop
は α
上の二項関係を表す。x y : α
が与えられると、r x y
は x
と y
の間に二項関係 r
が成立するという主張を表す。
全称量化子 ∀
を用いた主張 ∀ x : α, p x
は、「全ての x : α
に対して、p x
が成立する」という主張を表す。命題結合子と同様に、自然演繹の体系においては、全称量化子は導入則と除去則によって統制される。非形式的には、全称量化子の導入則は次のように表される:
x : α
が任意に選べる文脈でp x
の証明が与えられたとき、∀ x : α, p x
の証明を得ることができる。
全称量化子の除去則は次のように表される:
∀ x : α, p x
の証明があるとき、任意の項t : α
に対して、p t
の証明を得ることができる。
含意の場合と同様に、「型としての命題」の考え方が有効である。依存関数型の導入則と除去則を思い出してほしい:
x : α
が任意に選べる文脈で型β x
の項t
が作れるとき、項(fun x : α => t) : (x : α) → β x
が作れる。
依存関数型の除去則は次のように表される:
項
s : (x : α) → β x
が与えられたとき、型α
の任意の項t : α
に対して、項s t : β t
を得ることができる。
p x
が Prop
型を持つとき、型 (x : α) → β x
を型 ∀ x : α, p x
とみなすことで、依存関数型の導入則と除去則を全称量化子の導入則と除去則とみなすことができる。これらの規則に従って、全称量化子を含む証明を構築することができる。
「型としての命題」の考え方に従って、Calculus of Constructionsでは、従属関数型と全称量化子を同一視する。つまり、任意の項 p
に対して、∀ x : α, p
は (x : α) → p
の代替表現に過ぎず、p
が命題のときは、前者の表現の方が後者より自然である、と考えるのである。
通常の関数の場合、α → β
は β
が x : α
に依存しない場合の (x : α) → β
だと解釈できることを思い出してほしい。同様に、命題間の含意 p → q
は命題 q
が x : p
に依存しない場合の ∀ x : p, q
だと解釈することができる。
以下は、全称量化子に関する「型としての命題」対応がどのように実践されるかの例である。
example (α : Type) (p q : α → Prop) : (∀ x : α, p x ∧ q x) → ∀ y : α, p y :=
fun h : ∀ x : α, p x ∧ q x =>
fun y : α =>
show p y from (h y).left
表記上の慣習として、Leanは全称量化子に可能な限り広いスコープを与えるので、上の例では x
に対する量化子のスコープを限定するために括弧が必要である。そして、∀ y : α, p y
を証明する正規の方法は、y
を任意に取り、p y
を証明することである。これが全称量化子の導入則の使用例である。次に、型 ∀ x : α, p x ∧ q x
を持つ項 h
が与えられると、項 h y
は型 p y ∧ q y
を持つ。これが全称量化子の除去則の使用例である。連言命題 h y
の左の命題を取ると、所望の結論 p y
が得られる。
束縛変数の名前を変えることで同じにできる2つの式は、等価であるとみなされる(α-同値)ことを思い出してほしい。例えば、上記の例について、結論の前件と後件の両方で同じ束縛変数名 x
を用いて、証明の中では別の束縛変数名 z
を使うこともできる。
example (α : Type) (p q : α → Prop) : (∀ x : α, p x ∧ q x) → ∀ x : α, p x :=
fun h : ∀ x : α, p x ∧ q x =>
fun z : α =>
show p z from And.left (h z)
もう一つの例として、関係 r
が推移的であることはどのように表現されるかを提示しよう:
variable (α : Type) (r : α → α → Prop)
variable (trans_r : ∀ x y z, r x y → r y z → r x z)
variable (a b c : α)
variable (hab : r a b) (hbc : r b c)
#check trans_r -- ∀ (x y z : α), r x y → r y z → r x z
/- (r : α → α → Prop) と 「r x y → r y z → r x z」により x,y,z の型が推論されている -/
#check trans_r a b c -- r a b → r b c → r a c
#check trans_r a b c hab -- r b c → r a c
#check trans_r a b c hab hbc -- r a c
この例で何が起こっているのかを考えてみよう。trans_r
を値 a b c
に適用すると、これは r a b → r b c → r a c
の証明になる。これを「前提」hab : r a b
に適用すると、含意命題 r b c → r a c
の証明が得られる。最後に、これを前提 hbc
に適用すると、結論 r a c
の証明が得られる。
hab
と hbc
があれば最初の3つの引数が a b c
であることは容易に推論できる。このような状況において、引数 a b c
を毎回与えるのは面倒かもしれない。そのため、これらを暗黙の引数にするのが一般的である:
variable (α : Type) (r : α → α → Prop)
variable (trans_r : ∀ {x y z}, r x y → r y z → r x z)
variable (a b c : α)
variable (hab : r a b) (hbc : r b c)
#check trans_r -- r ?m.1 ?m.2 → r ?m.2 ?m.3 → r ?m.1 ?m.3
#check trans_r hab -- r b ?m.42 → r a ?m.42
#check trans_r hab hbc -- r a c
x y z
を暗黙の引数にする利点は、r a c
の証明を trans_r hab hbc
と簡単に書けることである。欠点は、項 trans_r
と項 trans_r hab
の型を推論するのに必要な情報をLeanに与えることができないことである。最初の #check
コマンドの出力は r ?m.1 ?m.2 → r ?m.2 ?m.3 → r ?m.1 ?m.3
であり、暗黙の引数が特定できなかったことを示している。
次は r
が同値関係であるという前提を使って初歩的な推論を行う例である:
variable (α : Type) (r : α → α → Prop)
variable (refl_r : ∀ x, r x x)
variable (symm_r : ∀ {x y}, r x y → r y x)
variable (trans_r : ∀ {x y z}, r x y → r y z → r x z)
example (a b c d : α) (hab : r a b) (hcb : r c b) (hcd : r c d) : r a d :=
trans_r (trans_r hab (symm_r hcb)) hcd
全称量化子の使い方に慣れるために、この章の最後にある練習問題をいくつかやってみるとよい。
依存関数型には型付け規則があるが、全称量化子には特殊な型付け規則がある。これが Prop
と他の型の違いである。α : Sort i
と β : Sort j
があり、項 β
は x : α
に依存するかもしれないとする。このとき、(x : α) → β
は型 Sort (imax i j)
の項である。ここで、imax i j
は j
が0でないなら i
と j
の最大値で、j
が0なら0である。
imax i j
の定義は次のように解釈すればよい。もし j
が 0
でないなら、(x : α) → β
は型 Sort (max i j)
の項である。言い換えれば、α
から β
への依存関数型は、インデックスが i
と j
の最大値である宇宙に「住んで」いる。他方で、β
が Sort 0
、つまり Prop
の項であるとしよう。この場合、α
がどの階層的型宇宙に住んでいるかに関わらず、(x : α) → β
も Sort 0
(Prop
) の項となる。言い換えれば、β
が α
に依存する命題であれば、 ∀ x : α, β
も命題であるということである。これは、Prop
は単なるデータの型ではなく命題の型であるという解釈を反映している。そして以上のことは Prop
をimpredicative(非可述的)にしている。
predicative(可述的)という用語は、20世紀初頭の数学基礎論の発展に由来する。当時、ポアンカレやラッセルといった論理学者が、集合論におけるパラドックスを、性質Aを持つ集合を量化することで性質Aを定義するときに生じる「悪循環」のせいにしたのである。任意の型 α
に対して、α
上の全ての(一変数)述語からなる型 α → Prop
(α
の「べき型」)を作れることに注目してほしい。Prop
のimpredicativity(非可述性)とは、α → Prop
を量化した命題を作れることを意味する。特に、α
上の述語を全称量化することで、α
上の述語を定義することができ(∀ X : α → Prop, β
と書くことで「全ての α
上の述語に対して β
が成立する」という α
上の述語を定義することができ)、これはまさにかつて問題視された類の循環である。
Equality (等号)
ここで、Leanのライブラリで定義されている最も基本的な関係の一つである「等号関係」に注目しよう。7章 Inductive Types (帰納型)では、Leanのlogical framework(論理フレームワーク)の根本から「どのように」等号が定義されるかを説明する。その前に、ここでは等号の使い方を説明する。
もちろん、等号の基本的な性質の一つは、「等号は同値関係である」という性質である:
#check Eq.refl -- Eq.refl.{u_1} (a : α) : a = a
#check Eq.symm -- Eq.symm.{u} {α : Sort u} {a b : α} (h : a = b) : b = a
#check Eq.trans -- Eq.trans.{u} {α : Sort u} {a b c : α} (h₁ : a = b) (h₂ : b = c) : a = c
Leanに暗黙の引数(ここではメタ変数として表示されている)を挿入しないように指示することで、出力を読みやすくすることができる。
universe u
#check @Eq.refl.{u} -- @Eq.refl : ∀ {α : Sort u} (a : α), a = a
#check @Eq.symm.{u} -- @Eq.symm : ∀ {α : Sort u} {a b : α}, a = b → b = a
#check @Eq.trans.{u} -- @Eq.trans : ∀ {α : Sort u} {a b c : α}, a = b → b = c → a = c
.{u}
という記法は、宇宙パラメータとして u
を使うことをLeanに指示する。
したがって、例えば、前節の例を等号関係に特化させることができる:
variable (α : Type) (a b c d : α)
variable (hab : a = b) (hcb : c = b) (hcd : c = d)
example : a = d :=
Eq.trans (Eq.trans hab (Eq.symm hcb)) hcd
射影表記(Foo.bar e
の e.bar
という略記)も使うことができる:
variable (α : Type) (a b c d : α)
variable (hab : a = b) (hcb : c = b) (hcd : c = d)
example : a = d := (hab.trans hcb.symm).trans hcd
反射律 Eq.refl
は見た目よりも強力である。Calculus of Constructionsにおいて、任意の型は計算可能な解釈を持ち、論理フレームワークは同一の簡約結果を持つ項たちを同じものとして扱うことを思い出してほしい。その結果、いくつかの非自明な恒等式を反射律によって証明することができる:
variable (α β : Type)
example (f : α → β) (a : α) : (fun x => f x) a = f a := Eq.refl _
example (a : α) (b : β) : (a, b).1 = a := Eq.refl _
example : 2 + 3 = 5 := Eq.refl _
論理フレームワークのこの機能は非常に重要であるため、Leanのライブラリでは Eq.refl _
により rfl
という記法を定義している:
variable (α β : Type)
example (f : α → β) (a : α) : (fun x => f x) a = f a := rfl
example (a : α) (b : β) : (a, b).1 = a := rfl
example : 2 + 3 = 5 := rfl
しかし、等号は同値関係以上のものである。等号は、左辺の式を右辺の式に置き換えても、あるいは右辺の式を左辺の式に置き換えても真理値が変わらないという意味で、全ての命題が等号によって主張される同値性を尊重するという重要な性質を持っている。つまり、h1 : a = b
と h2 : p a
があれば、代入 Eq.subst h1 h2
を使って p b
の証明を作ることができる。
example (α : Type) (a b : α) (p : α → Prop)
(h1 : a = b) (h2 : p a) : p b :=
Eq.subst h1 h2
example (α : Type) (a b : α) (p : α → Prop) -- h2の型の中に登場するh1の左辺をh1の右辺で書き換える
(h1 : a = b) (h2 : p a) : p b :=
h1 ▸ h2
example (α : Type) (a b : α) (p : α → Prop) -- h2の型の中に登場するh1の右辺をh1の左辺で書き換える
(h1 : a = b) (h2 : p b) : p a :=
h1 ▸ h2
2番目、3番目の例の中の三角形は、Eq.subst
と Eq.symm
の上に構築されたマクロで、\t
と打つことで入力できる。h1 ▸ h2
は「h1
を使って h2
を書き換える」と解釈できる。
Eq.subst
規則は、より明示的な置換を行う以下の補助規則を定義するために使われる。これらは関数適用項、つまり s t
の形の項を扱うためのものである。具体的には、congrArg
は s
を固定して t
を置換するのに使われ、congrFun
は t
を固定して s
を置換するのに使われ、congr
は s
と t
の両方を一度に置換するのに使われる。
variable (α : Type)
variable (a b : α)
variable (f g : α → Nat)
variable (h₁ : a = b)
variable (h₂ : f = g)
example : f a = f b := congrArg f h₁
example : f a = g a := congrFun h₂ a
example : f a = g b := congr h₂ h₁
Leanのライブラリには次のような一般的な恒等式が多数収載されている:
variable (a b c : Nat)
example : a + 0 = a := Nat.add_zero a
example : 0 + a = a := Nat.zero_add a
example : a * 1 = a := Nat.mul_one a
example : 1 * a = a := Nat.one_mul a
example : a + b = b + a := Nat.add_comm a b
example : a + b + c = a + (b + c) := Nat.add_assoc a b c
example : a * b = b * a := Nat.mul_comm a b
example : a * b * c = a * (b * c) := Nat.mul_assoc a b c
example : a * (b + c) = a * b + a * c := Nat.mul_add a b c
example : a * (b + c) = a * b + a * c := Nat.left_distrib a b c
example : (a + b) * c = a * c + b * c := Nat.add_mul a b c
example : (a + b) * c = a * c + b * c := Nat.right_distrib a b c
Nat.mul_add
と Nat.add_mul
はそれぞれ Nat.left_distrib
と Nat.right_distrib
の別名である。上記の性質は、自然数(Nat
型)に関するものである。
example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
have h1 : (x + y) * (x + y) = (x + y) * x + (x + y) * y :=
Nat.mul_add (x + y) x y
have h2 : (x + y) * (x + y) = x * x + y * x + (x * y + y * y) :=
(Nat.add_mul x y x) ▸ (Nat.add_mul x y y) ▸ h1
h2.trans (Nat.add_assoc (x * x + y * x) (x * y) (y * y)).symm
Eq.subst
の2番目の暗黙の引数は、置換が行われる文脈を提供するもので、α → Prop
型を持っていることに注意してほしい。
#check Eq.subst -- {α : Sort u} {motive : α → Prop} {a b : α} (h₁ : a = b) (h₂ : motive a) : motive b
したがって、この述語を推論するには、higher-order unification(高階ユニフィケーション)の解(高階単一子)が必要である。一般論として、高階単一子が存在するかを決定する問題は決定不能であり、Leanはせいぜいこの問題に対して不完全で近似的な解を提供することしかできない。そのため、Eq.subst
は必ずしも思い通りに動くとは限らない。マクロ h ▸ e
はこの暗黙の引数を計算する際により効果的なヒューリスティクスを使う。そのため、Eq.subst
の適用が失敗するような状況でも、h ▸ e
が成功することがしばしばある。
等式の推論は非常に一般的で重要であるため、Leanはそれをより効率的に実行するためのメカニズムを数多く提供している。次の節では、より自然で簡潔な方法で計算的証明を書くための構文を提供する。しかし、より重要なのは、等式推論がrewriter(項書き換え器)、simplifier(単純化器)、その他の自動化によって成り立っていることである。項書き換え器と単純化器については次の節で簡単に説明し、次の章でさらに詳しく説明する。
Calculational Proofs (計算的証明)
計算的証明は、等号の推移律などの基本原則によって構成される中間結果の連鎖にすぎない。Leanにおいて、計算的証明はキーワード calc
から始まる以下の構文を持つ:
calc
<expr>_0 'op_1' <expr>_1 ':=' <proof>_1
'_' 'op_2' <expr>_2 ':=' <proof>_2
...
'_' 'op_n' <expr>_n ':=' <proof>_n
calc
以降の一連の行は全て同じインデントを持つ必要があることに注意。そうでなければコンパイルエラーになる。各 <proof>_i
は <expr>_{i-1} op_i <expr>_i
の証明である必要がある。
最初の行に calc <expr>_0
と書いた後、その次の行から _
を使う事もできる。これは関係命題と証明の組からなる行を揃えるのに便利である。
calc <expr>_0
'_' 'op_1' <expr>_1 ':=' <proof>_1
'_' 'op_2' <expr>_2 ':=' <proof>_2
...
'_' 'op_n' <expr>_n ':=' <proof>_n
次は計算的証明の一例である:
variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
calc
a = b := h1
_ = c + 1 := h2
_ = d + 1 := congrArg Nat.succ h3
_ = 1 + d := Nat.add_comm d 1
_ = e := Eq.symm h4
この証明の書き方は、次章で詳しく説明する simp
タクティクや rewrite
タクティクと併用すると最も効果的である。例えば、rewrite
の略語 rw
を使うと、上記の証明は次のように書ける:
variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
calc
a = b := by rw [h1]
_ = c + 1 := by rw [h2]
_ = d + 1 := by rw [h3]
_ = 1 + d := by rw [Nat.add_comm]
_ = e := by rw [h4]
基本的に、rw
タクティクは []
でくくられた等式(前提、定理名、複合的な項のいずれでもよい)を用いてゴールを「書き換える」。その結果、ゴールが恒等式 t = t
になったら、rw
タクティクは自動で反射律を使ってゴールを証明する。
段階的な書き換えを一度に実行することもできる。上の証明は次のように短縮できる:
variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
calc
a = d + 1 := by rw [h1, h2, h3]
_ = 1 + d := by rw [Nat.add_comm]
_ = e := by rw [h4]
ここまで短くしてもよい:
variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
by rw [h1, h2, h3, Nat.add_comm, h4]
simp
タクティクは、ゴールの項の中に与えられた恒等式が適用できる場所がある限り、与えられた恒等式を任意の順番で繰り返し適用し、ゴールを書き換える。また、システム内で宣言された既存のルールも活用し、書き換えのループを避けるため可換性を賢く適用する。上記の証明は simp
を使って次のように証明することもできる:
variable (a b c d e : Nat)
variable (h1 : a = b)
variable (h2 : b = c + 1)
variable (h3 : c = d)
variable (h4 : e = 1 + d)
theorem T : a = e :=
by simp [h1, h2, h3, Nat.add_comm, h4]
次の章では rw
と simp
の派生について説明する。
calc
コマンドは、何らかの形で推移律を持つあらゆる関係に対して使うことができる。計算的証明の中で異なる関係を組み合わせることもできる。
example (a b c d : Nat) (h1 : a = b) (h2 : b ≤ c) (h3 : c + 1 < d) : a < d :=
calc
a = b := h1
_ < b + 1 := Nat.lt_succ_self b
_ ≤ c + 1 := Nat.succ_le_succ h2
_ < d := h3
Trans
型クラスの新しいインスタンスを追加することで、calc
に新しい推移律の定理を「教える」ことができる。型クラスについては後で紹介するが、ここではとりあえず新しい Trans
インスタンスを使って calc
記法を拡張する方法を示す小さな例を以下に挙げる。
def divides (x y : Nat) : Prop :=
∃ k, k*x = y
def divides_trans (h₁ : divides x y) (h₂ : divides y z) : divides x z :=
let ⟨k₁, d₁⟩ := h₁
let ⟨k₂, d₂⟩ := h₂
⟨k₁ * k₂, by rw [Nat.mul_comm k₁ k₂, Nat.mul_assoc, d₁, d₂]⟩
def divides_mul (x : Nat) (k : Nat) : divides x (k*x) :=
⟨k, rfl⟩
instance : Trans divides divides divides where
trans := divides_trans
example (h₁ : divides x y) (h₂ : y = z) : divides x (2*z) :=
calc
divides x y := h₁
_ = z := h₂
divides _ (2*z) := divides_mul ..
infix:50 " ∣ " => divides
example (h₁ : divides x y) (h₂ : y = z) : divides x (2*z) :=
calc
x ∣ y := h₁
_ = z := h₂
_ ∣ 2*z := divides_mul ..
上記の例から、ユーザーが定義した関係がinfix表記を持たなくても、その関係について calc
が使えることがわかる。最後に、上記の例の縦棒 ∣
はunicodeのものである。match ... with
式で使われるASCIIの |
のオーバーロードを避けるためにunicodeの記号を用いた。
calc
を用いると、前節の証明をより自然にわかりやすく書くことができる。
example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
calc
(x + y) * (x + y) = (x + y) * x + (x + y) * y := by rw [Nat.mul_add]
_ = x * x + y * x + (x + y) * y := by rw [Nat.add_mul]
_ = x * x + y * x + (x * y + y * y) := by rw [Nat.add_mul]
_ = x * x + y * x + x * y + y * y := by rw [←Nat.add_assoc]
ここでは、calc
の他の記法を検討する価値がある。最初の式がこれだけ広いスペースをとる場合、最初の関係式に _
を使うと、全ての関係式が自然に整列される:
example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
calc (x + y) * (x + y)
_ = (x + y) * x + (x + y) * y := by rw [Nat.mul_add]
_ = x * x + y * x + (x + y) * y := by rw [Nat.add_mul]
_ = x * x + y * x + (x * y + y * y) := by rw [Nat.add_mul]
_ = x * x + y * x + x * y + y * y := by rw [←Nat.add_assoc]
ここで、Nat.add_assoc
の前の左矢印は、書き換えの際に与えられた恒等式を逆向きに使うように rw
に指示する(左矢印は \l
と打つと入力できる。これと等価なascii文字列 <-
を使ってもいい)。簡潔さを求めるなら、次のように単独の rw
や simp
を使うだけで証明を完結させることもできる。
example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
by rw [Nat.mul_add, Nat.add_mul, Nat.add_mul, ←Nat.add_assoc]
example (x y : Nat) : (x + y) * (x + y) = x * x + y * x + x * y + y * y :=
by simp [Nat.mul_add, Nat.add_mul, Nat.add_assoc]
The Existential Quantifier (存在量化子)
最後に、存在量化子について考えよう。存在量化子は exists x : α, p x
または ∃ x : α, p x
と書くことができる。どちらの記法も、Leanのライブラリで定義されている Exists (fun x : α => p x)
という長ったらしい表現の便利な省略形である。
もうお分かりのように、Leanのライブラリは存在量化子の導入則と除去則を含んでいる。導入則は簡単である: ∃ x : α, p x
を証明するには、適切な項 t : α
と p t
の証明を与えればよい。次はその例である:
example : ∃ x : Nat, x > 0 :=
have h : 1 > 0 := Nat.zero_lt_succ 0
Exists.intro 1 h
example (x : Nat) (h : x > 0) : ∃ y, y < x :=
Exists.intro 0 h
example (x y z : Nat) (hxy : x < y) (hyz : y < z) : ∃ w, x < w ∧ w < z :=
Exists.intro y (And.intro hxy hyz)
#check @Exists.intro -- @Exists.intro : ∀ {α : Sort u_1} {p : α → Prop} (w : α), p w → Exists p
型が文脈から明らかな場合、Exists.intro t h
の代わりに、無名コンストラクタ表記 ⟨t, h⟩
を使うことができる。
example : ∃ x : Nat, x > 0 :=
have h : 1 > 0 := Nat.zero_lt_succ 0
⟨1, h⟩
example (x : Nat) (h : x > 0) : ∃ y, y < x :=
⟨0, h⟩
example (x y z : Nat) (hxy : x < y) (hyz : y < z) : ∃ w, x < w ∧ w < z :=
⟨y, hxy, hyz⟩ -- ⟨y, hxy, hyz⟩ は ⟨y, ⟨hxy, hyz⟩ ⟩ と同じ
Exists.intro
には暗黙の引数があることに注意してほしい: Leanは結論 ∃ x, p x
から述語 p : α → Prop
が何であるかを推論しなければならない。これは簡単なことではない。例えば、hg : g 0 0 = 0
とし、Exists.intro 0 hg
と書くとする。このとき、述語 p
は 定理 ∃ x, g x x = x
、∃ x, g x x = 0
、∃ x, g x 0 = x
などに対応する様々な値を取りうる。Leanは文脈からどれが適切かを推論する。次の例では、pp.explicit
オプションを true
に設定し、#print
コマンドに暗黙の引数を表示するように問い合わせている。
variable (g : Nat → Nat → Nat)
variable (hg : g 0 0 = 0)
theorem gex1 : ∃ x, g x x = x := ⟨0, hg⟩
theorem gex2 : ∃ x, g x 0 = x := ⟨0, hg⟩
theorem gex3 : ∃ x, g 0 0 = x := ⟨0, hg⟩
theorem gex4 : ∃ x, g x x = 0 := ⟨0, hg⟩
set_option pp.explicit true -- 暗黙の引数を表示する
#print gex1
#print gex2
#print gex3
#print gex4
Exists.intro
は主張本体の証人(存在量化を受けた主張を満たす項)を隠すため、情報を隠す操作であると解釈することができる。存在量化子の除去則 Exists.elim
はその逆の操作を行う。Exists.elim
は任意の値 w : α
に対して p w
ならば q
が成立することを示すことで、∃ x : α, p x
から命題 q
を証明することを可能にする。大雑把に言えば、∃ x : α, p x
が成立するなら p x
を満たす x
が存在することがわかるので、その x
に名前、例えば w
を与えることができる。もし q
が w
に言及していなければ、q
が p w
から導かれることを示すことは、q
が p w
を満たす x
の存在から導かれることを示すことに等しい。次はその例である:
variable (α : Type) (p q : α → Prop)
example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
Exists.elim h
(fun w =>
fun hw : p w ∧ q w =>
show ∃ x, q x ∧ p x from ⟨w, hw.right, hw.left⟩)
#check @Exists.elim -- ∀ {α : Sort u_1} {p : α → Prop} {b : Prop}, (∃ x, p x) → (∀ (a : α), p a → b) → b
ここで、無名コンストラクタ表記 ⟨w, hw.right, hw.left⟩
が入れ子になったコンストラクタの適用を省略していることに注意。⟨w, hw.right, hw.left⟩
は ⟨w, ⟨hw.right, hw.left⟩⟩
と書いたのと同じである。
存在量化子除去則と選言除去則を比較することは有用であろう: 主張 ∃ x : α, p x
は、 a
が型 α
の全ての項をわたるときの、命題 p a
全てを選言で繋げたものと考えることができる。
存在命題は、2章の従属型の節で説明したシグマ型(依存直積型)に非常に似ていることに注目しよう。a : α
と h : p a
が与えられたとき、項 Exists.intro a h
は型 (∃ x : α, p x) : Prop
を持つ一方で、Sigma.mk a h
は型 (Σ x : α, p x) : Type
を持つ。この ∃
と Σ
の類似性はカリー=ハワード同型のもう一つの例である。
section exist_prop
variable (a : α) (p : α → Prop) (h : p a)
#check Exists.intro a h -- Exists p
end exist_prop
section sigma_type
variable (a : α) (p : α → Type) (h : p a)
#check Sigma.mk a h -- Sigma p
end sigma_type
Leanは、match
式を用いた、存在量化子を除去するためのより便利な方法を提供する:
variable (α : Type) (p q : α → Prop)
example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
match h with
| ⟨w, hw⟩ => ⟨w, hw.right, hw.left⟩
match
式はLeanの関数定義システムの一部であり、複雑な関数を定義する便利で表現力豊かな方法を提供する。再びカリー=ハワード同型により、この関数定義方法 match
を証明の記述にも応用させることができる。match
式は存在量化された主張を w
と hw
に「分解」する。これらは命題の証明記述内で使うことができる。より明確にするために、マッチで分解されてできた要素に型の注釈を付けることができる:
variable (α : Type) (p q : α → Prop)
example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
match h with
| ⟨(w : α), (hw : p w ∧ q w)⟩ => ⟨w, hw.right, hw.left⟩
match 式を使って、存在量化子と連言を同時に分解することもできる:
variable (α : Type) (p q : α → Prop)
example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
match h with
| ⟨(w : α), (hpw : p w), (hqw : q w)⟩ => ⟨w, hqw, hpw⟩
Leanは let
キーワードにもパターンマッチングを提供する:
variable (α : Type) (p q : α → Prop)
example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x :=
let ⟨w, hpw, hqw⟩ := h
⟨w, hqw, hpw⟩
これは、基本的に上記の match
式の代替表記に過ぎない。Leanでは、fun
キーワードの中で暗黙の match
を使うこともできる:
variable (α : Type) (p q : α → Prop)
example : (∃ x, p x ∧ q x) → ∃ x, q x ∧ p x :=
fun ⟨w, hpw, hqw⟩ => ⟨w, hqw, hpw⟩
8章 Induction and Recursion (帰納と再帰) では、これらの派生構文は全てより一般的なパターンマッチング構文の特殊例であることを説明する。
次の例では、is_even a
を ∃ b, a = 2 * b
と定義し、2つの偶数の和は偶数であることを示す。
def is_even (a : Nat) : Prop := ∃ b : Nat, a = 2 * b
theorem even_plus_even {a b : Nat} (h1 : is_even a) (h2 : is_even b) : is_even (a + b) :=
Exists.elim h1 (fun w1 (hw1 : a = 2 * w1) =>
Exists.elim h2 (fun w2 (hw2 : b = 2 * w2) =>
Exists.intro (w1 + w2)
(calc a + b
_ = 2 * w1 + 2 * w2 := by rw [hw1, hw2]
_ = 2 * (w1 + w2) := by rw [Nat.mul_add])))
theorem even_plus_even2 : ∀ a b : Nat, is_even a → is_even b → is_even (a + b) :=
fun a : Nat =>
fun b : Nat =>
fun ⟨(w1 : Nat), (hw1 : a = 2 * w1)⟩ =>
fun ⟨(w2 : Nat), (hw2 : b = 2 * w2)⟩ =>
have hw3 : a + b = 2 * (w1 + w2) :=
calc a + b
_ = 2 * w1 + 2 * w2 := by rw [hw1, hw2]
_ = 2 * (w1 + w2) := by rw [Nat.mul_add]
⟨(w1 + w2 : Nat), (hw3 : a + b = 2 * (w1 + w2))⟩
マッチ式、無名コンストラクタ、rewrite
タクティク……、この章で説明した様々な小道具を使ってこの証明を簡潔に書くと次のようになる:
def is_even (a : Nat) := ∃ b, a = 2 * b
theorem even_plus_even (h1 : is_even a) (h2 : is_even b) : is_even (a + b) :=
match h1, h2 with
| ⟨w1, hw1⟩, ⟨w2, hw2⟩ => ⟨w1 + w2, by rw [hw1, hw2, Nat.mul_add]⟩
構成的(構成的論理の)「または」が古典的「または」よりも強いように、構成的「存在する」も古典的「存在する」より強い。次の例に挙げるような含意命題を証明するためには、古典論理的な推論を必要とする。なぜなら、構成的論理では、「全ての x
が ¬ p
を満たす」の否定が真であることと、「p
を満たす x
が存在する」が真であることは同じではないからである。
open Classical
universe u
variable (α : Sort u) (p : α → Prop)
example (h : ¬ ∀ x, ¬ p x) : ∃ x, p x :=
byContradiction
(fun h1 : ¬ ∃ x, p x =>
have h2 : ∀ x, ¬ p x :=
fun x =>
fun h3 : p x =>
have h4 : ∃ x, p x := ⟨x, h3⟩
show False from h1 h4
show False from h h2)
以下に、練習問題として存在量化子を含む一般的な恒等式を挙げる。ここでは、できる限り多くの命題を証明することを勧める。また、どの命題が非構成的で、古典論理的な推論を必要とするかは、読者の判断に委ねる。
open Classical
variable (α : Type) (p q : α → Prop)
variable (r : Prop)
example : (∃ x : α, r) → r := sorry
example (a : α) : r → (∃ x : α, r) := sorry
example : (∃ x, p x ∧ r) ↔ (∃ x, p x) ∧ r := sorry
example : (∃ x, p x ∨ q x) ↔ (∃ x, p x) ∨ (∃ x, q x) := sorry
example : (∀ x, p x) ↔ ¬ (∃ x, ¬ p x) := sorry
example : (∃ x, p x) ↔ ¬ (∀ x, ¬ p x) := sorry
example : (¬ ∃ x, p x) ↔ (∀ x, ¬ p x) := sorry
example : (¬ ∀ x, p x) ↔ (∃ x, ¬ p x) := sorry
example : (∀ x, p x → r) ↔ (∃ x, p x) → r := sorry
example (a : α) : (∃ x, p x → r) ↔ (∀ x, p x) → r := sorry
example (a : α) : (∃ x, r → p x) ↔ (r → ∃ x, p x) := sorry
2番目の例と最後の2つの例は、型 α
には少なくとも1つの要素 a
が存在するという前提を必要とすることに注意してほしい。
以下は2つの難しい問題への解答である:
open Classical
variable (α : Type) (p q : α → Prop)
variable (a : α)
variable (r : Prop)
example : (∃ x, p x ∨ q x) ↔ (∃ x, p x) ∨ (∃ x, q x) :=
Iff.intro
(fun ⟨a, (h1 : p a ∨ q a)⟩ =>
Or.elim h1
(fun hpa : p a => Or.inl ⟨a, hpa⟩)
(fun hqa : q a => Or.inr ⟨a, hqa⟩))
(fun h : (∃ x, p x) ∨ (∃ x, q x) =>
Or.elim h
(fun ⟨a, hpa⟩ => ⟨a, (Or.inl hpa)⟩)
(fun ⟨a, hqa⟩ => ⟨a, (Or.inr hqa)⟩))
example : (∃ x, p x → r) ↔ (∀ x, p x) → r :=
Iff.intro
(fun ⟨b, (hb : p b → r)⟩ =>
fun h2 : ∀ x, p x =>
show r from hb (h2 b))
(fun h1 : (∀ x, p x) → r =>
show ∃ x, p x → r from
byCases
(fun hap : ∀ x, p x => ⟨a, fun h' : p a => h1 hap⟩)
(fun hnap : ¬ ∀ x, p x =>
byContradiction
(fun hnex : ¬ ∃ x, p x → r =>
have hap : ∀ x, p x :=
fun x =>
byContradiction
(fun hnp : ¬ p x =>
have hex : ∃ x, p x → r := ⟨x, (fun hp : p x => absurd hp hnp)⟩
show False from hnex hex)
show False from hnap hap)))
More on the Proof Language (証明言語の詳細)
fun
、have
、show
などのキーワードにより、非形式的な数学的証明の構造を反映した形式的証明項を書くことができることを見てきた。この節では、証明言語の他の便利な機能について説明する。
まず、ラベルを付けることなく補助ゴールを導入するために、無名の「have」式を使うことができる。this
キーワードを用いると、無名の「have」式を使って導入された最後の項を参照することができる:
variable (f : Nat → Nat)
variable (h : ∀ x : Nat, f x ≤ f (x + 1))
example : f 0 ≤ f 3 :=
have : f 0 ≤ f 1 := h 0
have : f 0 ≤ f 2 := Nat.le_trans this (h 1)
show f 0 ≤ f 3 from Nat.le_trans this (h 2)
証明の中ではいくつもの事実を使い捨てることが多いので、ラベルの付いた項が増えすぎてごちゃごちゃするのを防ぐには無名の「have」式が有効である。
ゴール(今ここで使いたい項の型)が推論できる場合は、by assumption
と書くことでLeanに証明を埋めるよう頼むこともできる:
variable (f : Nat → Nat)
variable (h : ∀ x : Nat, f x ≤ f (x + 1))
example : f 0 ≤ f 3 :=
have : f 0 ≤ f 1 := h 0
have : f 0 ≤ f 2 := Nat.le_trans (by assumption) (h 1)
show f 0 ≤ f 3 from Nat.le_trans (by assumption) (h 2)
by assumption
はLeanに assumption
タクティクを使うように指示し、assumption
タクティクはローカルなコンテキストで適切な前提命題(の証明項)を見つけることでゴールを証明する。assumption
タクティクについては次の章で詳しく説明する。
‹p›
と書くことで、Leanに証明を埋めるよう頼むこともできる。ここで、‹p›
はLeanにコンテキストから証明を見つけてもらいたい命題である。この角ばった括弧はそれぞれ \f<
と \f>
と打つと入力できる。"f" は "フランス語" を意味する。なぜならこのunicode記号はフランス語における引用符としても使われるからである。この表記はLeanにおいて次のように定義されている:
notation "‹" p "›" => show p by assumption
このアプローチは、推論してほしい前提の型が明示的に与えられるため、by assumption
を用いるよりもロバストである。また、証明も読みやすくなる。以下は、より詳細な例である:
variable (f : Nat → Nat)
variable (h : ∀ x : Nat, f x ≤ f (x + 1))
example : f 0 ≥ f 1 → f 1 ≥ f 2 → f 0 = f 2 :=
fun _ : f 0 ≥ f 1 =>
fun _ : f 1 ≥ f 2 =>
have : f 0 ≥ f 2 := Nat.le_trans ‹f 1 ≥ f 2› ‹f 0 ≥ f 1›
have : f 0 ≤ f 2 := Nat.le_trans (h 0) (h 1)
show f 0 = f 2 from Nat.le_antisymm this ‹f 0 ≥ f 2›
フランス語の引用符は、名無しで導入されたものだけでなくコンテキスト中の「あらゆるもの」を参照できることを覚えておこう。フランス語の引用符の適用範囲は命題だけにとどまらないが、これをデータに対して使うのはやや奇妙である:
example (n : Nat) : Nat := ‹Nat›
以降の章で、Leanのマクロシステムを使って証明言語を拡張する方法を紹介する。
Exercises (練習問題)
- 以下の命題を証明せよ:
variable (α : Type) (p q : α → Prop)
example : (∀ x, p x ∧ q x) ↔ (∀ x, p x) ∧ (∀ x, q x) := sorry
example : (∀ x, p x → q x) → (∀ x, p x) → (∀ x, q x) := sorry
example : (∀ x, p x) ∨ (∀ x, q x) → ∀ x, p x ∨ q x := sorry
最後の例について、逆の命題が導出できないのはなぜかを理解してみよう。
- 式の一部が全称量化された変数に依存しない場合、それを全称量化子の外側に持ってくることはしばしば可能である。以下の命題を証明してみよう(このうち2つ目の命題の1方向は古典論理を必要とする):
variable (α : Type) (p q : α → Prop)
variable (r : Prop)
example : α → ((∀ x : α, r) ↔ r) := sorry
example : (∀ x, p x ∨ r) ↔ (∀ x, p x) ∨ r := sorry
example : (∀ x, r → p x) ↔ (r → ∀ x, p x) := sorry
- 「理髪師のパラドックス」について考えてみよう。これは、ある町において、「自分で髭を剃らない男性全員の髭を剃り、自分で髭を剃る男性の髭は一切剃らない男性の理髪師がいる」という主張である。この主張が矛盾することを示せ:
variable (men : Type) (barber : men)
variable (shaves : men → men → Prop)
example (h : ∀ x : men, shaves barber x ↔ ¬ shaves x x) : False := sorry
- パラメータを持たない
Prop
型の項は(それが真か偽かを問わない)単なる主張である。まず以下のprime
とFermat_prime
の定義を埋め、それらを使って他の定義を構築せよ。例えば、任意の自然数n
に対して、n
より大きな素数が存在すると主張することで、素数は無限に存在すると言うことができる。弱いゴールドバッハ予想は、5より大きい任意の奇数は3つの素数の和で表されることを主張している。必要であれば、フェルマー素数や他の記述の定義を調べてみよう。
def even (n : Nat) : Prop := sorry
def prime (n : Nat) : Prop := sorry
def infinitely_many_primes : Prop := sorry
def Fermat_prime (n : Nat) : Prop := sorry
def infinitely_many_Fermat_primes : Prop := sorry
def goldbach_conjecture : Prop := sorry
def Goldbach's_weak_conjecture : Prop := sorry
def Fermat's_last_theorem : Prop := sorry
- 存在量化子の節で列挙した恒真式をできるだけ多く証明せよ。
Tactics (タクティク)
この章では、tactics(タクティク)を使って証明を構築する方法について説明する。証明項とは数学的証明の表現であり、タクティクは数学的証明を構築する手順を記述するコマンド(指示)である。定理 A ↔ B を証明する際に、非形式的に、「まず A → B を証明する。最初に定義を展開し、次に既存の補題を適用し、それから式を単純化する」という導入から数学の証明を始めることがあるかもしれない。これらの言明が証明を見つける方法を読者に伝える指示であるのと同様に、タクティクは証明項を構築する方法をLeanに伝える指示である。タクティクは、証明を分解し、一歩ずつゴールに向かうという段階的な証明の書き方を自然にサポートする。
タクティクの連続からなる証明を「タクティクスタイル」の証明と呼び、これまで見てきた証明項の構築の仕方を「項スタイル」の証明と呼ぶ。それぞれのスタイルには長所と短所がある。例えば、タクティクスタイルの証明は、各タクティクの結果を予測・推測することを読者に要求するため、項スタイルの証明より読みにくいという短所がある。しかし、短くて書きやすいという長所もある。さらに、タクティクはLeanの自動証明を利用するための入り口になる。なぜなら、Leanに自動証明を指示するコマンド自体がタクティクだからである。
用語に関する注意
この節は翻訳に際して追加した節である。
この章では、含意命題 p → q
あるいはより一般的に依存関数型 (x : α) → β
の中に登場する型 p
や型 (x : α)
を「前件(antecedent)」、型 q
や型 β
を「後件(consequent)」と呼び、一方で「ゴール(Goal)」 A ⊢ B
の中に登場する項の集まり A を「コンテキスト(Context)」または「前提(Premise)」、ゴールにおいて項構築の目標となる型 B を「ターゲット(Target)」または「結論(Conclusion)」と呼び明確に区別する。特に、ゴールが閉じられていない(open)/未達成(not accomplished)/未証明(not proved)/未解決(not solved)のときは「コンテキスト(Context)」「ターゲット(Target)」という用語を使い、ゴールが閉じられている(closed)/達成済み(accomplished)/証明済み(proved)/解決済(solved)のときは「前提(Premise)」「結論(Conclusion)」という用語を使う。
コンテキスト(あるいは前提)に含まれる各項を「仮説(Hypothesis)」と呼ぶ。
ここでいう「ターゲット」を「ゴール」と呼ぶこともあるが、混乱を避けるため本訳ではこのような用語の使い方はしない。
intro
タクティクや revert
タクティクの働きを理解する際に、この用語の区別が重要となる。
参考記事
Entering Tactic Mode (タクティクモードへの入り方)
定理を述べたり、have文を使うと、ゴール、すなわち期待された型を持つ項を構築するという目標が生成される。例えば、仮説 p q : Prop
、hp : p
、hq : q
を持つコンテキストでは、次のような記述は p ∧ q ∧ p
という型の項を構築するというゴールを作成する:
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p :=
sorry
このゴールは次のように記述できる:
p : Prop, q : Prop, hp : p, hq : q ⊢ p ∧ q ∧ p
実際、上記の例で "sorry" をアンダースコアに置き換えると、Leanはまさにこのゴールが未解決であることを報告する。
通常は、明示的に証明項を記述することでこのようなゴールを達成する。しかし、Leanでは、項が記述されることが期待される任意の場所に、項の記述の代わりに by <tactics>
ブロックを挿入することができる。ここで、<tactics>
はセミコロンまたは改行で区切られたコマンドの列である。by <tactics>
ブロックを使って上記の定理を証明することができる:
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p :=
by apply And.intro
exact hp
apply And.intro
exact hq
exact hp
つまり、by
キーワードを書くことでタクティクモードに入れるのである。しばしば by
キーワードは前の行に書き、上記の例はこのように書かれる:
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
apply And.intro
exact hp
apply And.intro
exact hq
exact hp
apply
タクティクは、0個以上の引数をとる関数を表現する項 t
を現在のゴールに適用する。apply
タクティクは現在のゴールのターゲット(⊢
の後に書かれる型)と関数 t
の出力の型を同一視し、引数(関数 t
の入力の型を持つ項)を構築するという新しいゴールを作る。ただし、後続の引数の型が先行の引数に依存しない場合に限る。上記の例では、コマンド apply And.intro
は2つのサブゴールを生成する:
case left
p q : Prop
hp : p
hq : q
⊢ p
case right
p q : Prop
hp : p
hq : q
⊢ q ∧ p
最初のゴールはコマンド exact hp
で達成される。exact
コマンドは apply
コマンドの一種で、「与えられた項がターゲットと同じ型を持つことを確認し、確認できたらゴールを閉じよ」とLeanに指示する。exact
コマンドをタクティク証明で使うのは良いことである。なぜなら、exact
コマンドの失敗は何かが間違っていることを示すからである。また exact
は apply
よりもロバストである。なぜなら、elaboratorは、与えられた項を処理する際に、今期待されている型(ゴールのターゲット)が何であるかを考慮に入れるからである。しかしながら、上記の例では apply
も同様に機能する。
#print
コマンドを使って最終的に得られた証明項を確認することができる:
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
apply And.intro
exact hp
apply And.intro
exact hq
exact hp
#print test
/-
theorem test : ∀ (p q : Prop), p → q → p ∧ q ∧ p :=
fun p q hp hq => { left := hp, right := { left := hq, right := hp } }
-/
タクティク証明は段階的に書くことができる。VS Codeでは、Ctrl-Shift-Enter
を押すことでメッセージウィンドウを開くことができる。カーソルがタクティクブロック内にあるときはいつでも、このウィンドウは現在のゴールを表示する。Emacsでは、タクティクブロック内の任意の行末で C-c C-g
を押すことで現在のゴールを見ることができる。また、カーソルを最後のタクティクの1文字目に置くことで、不完全な証明内の残りのゴールを見ることができる。証明が不完全な場合、キーワード by
に赤い波線が引かれ、エラーメッセージには残りのゴールが表示される。
タクティクコマンドは単一の項名だけでなく、複合式を受け取ることもできる。以下は、前述の証明の短縮版である:
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
apply And.intro hp
exact And.intro hq hp
当然のことながら、この証明記述は全く同じ証明項を生成する。
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
apply And.intro hp
exact And.intro hq hp
#print test
複数のタクティク適用をセミコロンで連結して1行で書くことができる。
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
apply And.intro hp; exact And.intro hq hp
複数のサブゴールを生成する可能性のあるタクティクは、自動的に各サブゴールにタグを付けることが多い。例えば、タクティク apply And.intro
は最初のサブゴールにタグ left
を、2つ目のサブゴールにタグ right
を付ける。この場合において、タグ名は And.intro
の宣言の中で使われた引数の名前から推測される。case <tag> => <tactics>
という表記を使うことで、タクティクを構造化することができる。つまり、<tactics>
をどのタグ付けされたサブゴールに適用するかを明示することができる。以下は、この章の最初のタクティク証明の構造化されたバージョンである:
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
apply And.intro
case left => exact hp
case right =>
apply And.intro
case left => exact hq
case right => exact hp
case
記法を使うと、サブゴール right
を left
よりも先に解くことができる:
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
apply And.intro
case right =>
apply And.intro
case left => exact hq
case right => exact hp
case left => exact hp
case
ブロック内で、Leanが他のゴールを隠していることに注意してほしい。言わば、Leanは選択されたゴールに「集中」しているのである。さらに、case
ブロックの終了時に選択されたゴールが完全には解かれていない場合、Leanはエラーフラグを建てる。
サブゴールが単純である場合、タグを使ってサブゴールを選択する価値はないかもしれないが、その場合でも証明を構造化したい場合は case
が有用である。また、Leanは証明を構造化するための「箇条書き」記法 . <tactics>
(あるいは · <tactics>
) を提供する。. <tactics>
記法を使うと、Leanは一番上のゴールに「集中」する。
theorem test (p q : Prop) (hp : p) (hq : q) : p ∧ q ∧ p := by
apply And.intro
. exact hp
. apply And.intro
. exact hq
. exact hp
Basic Tactics (基本的なタクティク)
apply
と exact
に加えて、もう一つの便利なタクティクが intro
である。intro
タクティクはゴールのターゲットの前件(ゴールのターゲットの →
の前にある命題)をゴールのコンテキスト(ゴールの ⊢
の前)に移動させる。以降、この intro
タクティクの機能を「ターゲットの前件をコンテキストに導入する」または単に「導入する」と表現する。以下は、3章で証明した命題論理の恒真式を今一度タクティクを使って証明した例である。
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
apply Iff.intro
. intro h
apply Or.elim (And.right h)
. intro hq
apply Or.inl
apply And.intro
. exact And.left h
. exact hq
. intro hr
apply Or.inr
apply And.intro
. exact And.left h
. exact hr
. intro h
apply Or.elim h
. intro hpq
apply And.intro
. exact And.left hpq
. apply Or.inl
exact And.right hpq
. intro hpr
apply And.intro
. exact And.left hpr
. apply Or.inr
exact And.right hpr
intro
タクティクはより一般的に任意の型の項をコンテキストに導入できる:
example (α : Type) : α → α := by
intro a
exact a
example (α : Type) : ∀ x : α, x = x := by
intro x
exact Eq.refl x
intro
タクティクは複数の項を一度に導入できる:
example : ∀ a b c : Nat, a = b → a = c → c = b := by
intro a b c h₁ h₂
exact Eq.trans (Eq.symm h₂) h₁
apply
タクティクが対話的に関数適用を構築するためのコマンドであるように、intro
タクティクは対話的に関数抽象(つまり fun x => e
の形の項)を構築するためのコマンドである。ラムダ抽象記法と同様に、intro
タクティクでは暗黙の match
を使うことができる。
example (α : Type) (p q : α → Prop) : (∃ x, p x ∧ q x) → ∃ x, q x ∧ p x := by
intro ⟨w, hpw, hqw⟩
exact ⟨w, hqw, hpw⟩
また、intro
タクティクは match
式のようにコンテキストに導入した項を場合分けすることもできる(詳しくは8章 Induction and Recursion (帰納と再帰)を参照のこと)。
example (α : Type) (p q : α → Prop) : (∃ x, p x ∨ q x) → ∃ x, q x ∨ p x := by
intro
| ⟨w, Or.inl h⟩ => exact ⟨w, Or.inr h⟩
| ⟨w, Or.inr h⟩ => exact ⟨w, Or.inl h⟩
example (α : Type) (p q : α → Prop) : (∃ x, p x ∨ q x) → ∃ x, q x ∨ p x := by
intro h
let ⟨w, hpq⟩ := h
exact Or.elim hpq (fun hp : p w => ⟨w, Or.inr hp⟩) (fun hq : q w => ⟨w, Or.inl hq⟩)
intros
タクティクは引数を与えずに使うことができる。その場合、intros
タクティクはできる限り多くの項を一度に導入し、導入した各項に自動で名前を付ける。その例はすぐ後に紹介する。
assumption
タクティクは現在のゴールの仮説たちに目を通し、それらの中にゴールのターゲットと合致するものがあればそれを適用する。
example (x y z w : Nat) (h₁ : x = y) (h₂ : y = z) (h₃ : z = w) : x = w := by
apply Eq.trans h₁
apply Eq.trans h₂
assumption -- applied h₃
assumption
タクティクは、必要に応じてゴールのターゲット内のメタ変数( ?b
など、どんな項が代入されるかが未決定の変数)を解決する(メタ変数に具体的な項を代入する):
example (x y z w : Nat) (h₁ : x = y) (h₂ : y = z) (h₃ : z = w) : x = w := by
apply Eq.trans
assumption -- solves x = ?b with h₁
apply Eq.trans
assumption -- solves y = ?h₂.b with h₂
assumption -- solves z = w with h₃
次の例では、intros
タクティクを用いて3つの変数と2つの命題の証明を自動的にコンテキストに導入している:
example : ∀ a b c : Nat, a = b → a = c → c = b := by
intros
apply Eq.trans
apply Eq.symm
assumption
assumption
デフォルトでは、Leanが自動生成した名前( a✝
など)にはアクセスできないことに注意してほしい。この仕様はタクティク証明の成否が自動生成された名前に依存しないようにするためにあり、この仕様があるおかげで証明はよりロバストになる。ただし、キーワード unhygienic
を by
の後に書くことでこの制限を無効にすることができる(証明のロバスト性は低下する)。
example : ∀ a b c : Nat, a = b → a = c → c = b := by unhygienic
intros
apply Eq.trans
apply Eq.symm
exact a_2
exact a_1
また、rename_i
タクティクを用いて、現在のゴール内の最も直近のアクセス不能な名前を変更することができる。次の例では、タクティク rename_i h1 _ h2
がゴール内の最後の3つの仮説のうち2つの名前を変更している。
example : ∀ a b c d : Nat, a = b → a = d → a = c → c = b := by
intros
rename_i h1 _ h2
apply Eq.trans
apply Eq.symm
exact h2
exact h1
rfl
タクティクは exact rfl
の、つまり exact Eq.refl _
の糖衣構文である。
example (y : Nat) : (fun x : Nat => 0) y = 0 := by
rfl
example (y : Nat) : (fun x : Nat => 0) y = 0 := by
exact Eq.refl _
タクティクの前に repeat
キーワードを書くと、そのタクティクは何度か繰り返し適用される。
example : ∀ a b c : Nat, a = b → a = c → c = b := by
intros
apply Eq.trans
apply Eq.symm
repeat assumption
revert
タクティクは時々有用である。これは intro
タクティクの逆の機能を持つ。つまり、指定した項をゴールのコンテキストからゴールのターゲットの前件に移動させる。以降、revert
タクティクの機能を「コンテキストの一部をターゲットに戻す」または単に「戻す」と表現する。
example (x : Nat) : x = x := by
revert x
-- goal is ⊢ ∀ (x : Nat), x = x
intro y
-- goal is y : Nat ⊢ y = y
rfl
(この章の最初に書いたように用語の区別をしていれば明らかなことだが、)コンテキスト(の一部)をターゲットに移すと含意命題が得られる:
example (x y : Nat) (h : x = y) : y = x := by
revert h
-- goal is x y : Nat ⊢ x = y → y = x
intro h₁
-- goal is x y : Nat, h₁ : x = y ⊢ y = x
apply Eq.symm
assumption
しかし、revert
はさらに賢く、指定した項だけでなく、指定した項に依存する型を持つ要素も全てゴールのターゲットに移動させる。例えば、上の例で x
を戻すと、h
も一緒に戻される:
example (x y : Nat) (h : x = y) : y = x := by
revert x
-- goal is y : Nat ⊢ ∀ (x : Nat), x = y → y = x
intros
apply Eq.symm
assumption
また、コンテキスト内の複数の仮説を一度に戻すこともできる:
example (x y : Nat) (h : x = y) : y = x := by
revert x y
-- goal is ⊢ ∀ (x y : Nat), x = y → y = x
intros
apply Eq.symm
assumption
revert
で戻せるのは現在のゴールのコンテキスト内の項(仮説)だけである。しかし、タクティク generalize e = x
を使えば、ゴールのターゲットに登場する任意の式 e
を新しい変数 x
に置き換えることができる。また、タクティク generalize e = x at h₁
を使えば、ゴールの仮説 h₁
に登場する任意の式 e
を新しい変数 x
に置き換えることができる。
example : 3 = 3 := by
generalize 3 = x
-- goal is x : Nat ⊢ x = x
revert x
-- goal is ⊢ ∀ (x : Nat), x = x
intro y
-- goal is y : Nat ⊢ y = y
rfl
上記の例において、generalize 3 = x
は 3
に任意の変数 x
を割り当てることでゴールのターゲットを一般化している。全ての一般化がゴールの証明可能性を保存するわけではないことに注意してほしい。次の例では、generalize
が rfl
を使うだけで証明できるゴールを決して証明できないゴールに置き換えている:
example : 2 + 3 = 5 := by
generalize 3 = x
-- goal is x : Nat ⊢ 2 + x = 5
admit
admit
タクティクは exact sorry
の糖衣構文である。これは現在のゴールを閉じ、sorry
が使われたという警告を出す。一般化以前のゴールの証明可能性を保存するために、generalize
タクティクを使う際に 3
が x
に置き換えられたという事実を記録することができる。そのためには、置き換えの事実を保存するためのラベルを generalize
に与えるだけでよい:
example : 2 + 3 = 5 := by
generalize h : 3 = x
-- goal is x : Nat, h : 3 = x ⊢ 2 + x = 5
rw [← h]
ここでは、rewrite
タクティク(略称は rw
)が h
を用いて x
を 3
で再び置き換えている。rewrite
タクティクについては後述する。
More Tactics (他のタクティク)
命題やデータを構築したり分解したりするには、他のいくつかのタクティクが有用である。例えば、p ∨ q
の形のターゲットに対して apply
タクティクを使う場合は、タクティク apply Or.inl
や apply Or.inr
を使うだろう。逆に、cases
タクティクは選言命題型(など)の仮説を分解する。
example (p q : Prop) : p ∨ q → q ∨ p := by
intro h
cases h with
| inl hp => apply Or.inr; exact hp
| inr hq => apply Or.inl; exact hq
cases
タクティクの構文は match
式の構文と似ていることに注意。新しいサブゴールは好きな順番で解くことができる。
example (p q : Prop) : p ∨ q → q ∨ p := by
intro h
cases h with
| inr hq => apply Or.inl; exact hq
| inl hp => apply Or.inr; exact hp
with
と後続のタクティクを書かずに(構造化されていない) cases
を使うこともできる。この場合、複数のサブゴールが生成される。
example (p q : Prop) : p ∨ q → q ∨ p := by
intro h
cases h
apply Or.inr
assumption
apply Or.inl
assumption
(構造化されていない) cases
は、同じタクティクを使って複数のサブゴールを閉じられる場合に特に便利である。
example (p : Prop) : p ∨ p → p := by
intro h
cases h
repeat assumption
tac1 <;> tac2
という結合子を使えば、タクティク tac1
により生成された各サブゴールに tac2
を適用することもできる。
example (p : Prop) : p ∨ p → p := by
intro h
cases h <;> assumption
構造化されていない cases
タクティクと case
記法や .
記法を組み合わせることができる。
example (p q : Prop) : p ∨ q → q ∨ p := by
intro h
cases h
. apply Or.inr
assumption
. apply Or.inl
assumption
example (p q : Prop) : p ∨ q → q ∨ p := by
intro h
cases h
case inr h =>
apply Or.inl
assumption
case inl h =>
apply Or.inr
assumption
example (p q : Prop) : p ∨ q → q ∨ p := by
intro h
cases h
case inr h =>
apply Or.inl
assumption
. apply Or.inr
assumption
cases
タクティクは連言命題を分解することもできる。
example (p q : Prop) : p ∧ q → q ∧ p := by
intro h
cases h with
| intro hp hq => constructor; exact hq; exact hp
example (p q : Prop) : p ∧ q → q ∧ p := by
intro h
cases h with
| intro hp hq => apply And.intro; exact hq; exact hp
この例では、cases
タクティクにより h : p ∧ q
が一対の項 hp : p
と hq : q
に置き換えられた。constructor
タクティクは、連言のための唯一のコンストラクタ And.intro
をターゲットに適用する。これらのタクティクにより、前節の例は以下のように書き換えられる:
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
apply Iff.intro
. intro h
cases h with
| intro hp hqr =>
cases hqr
. apply Or.inl; constructor <;> assumption
. apply Or.inr; constructor <;> assumption
. intro h
cases h with
| inl hpq =>
cases hpq with
| intro hp hq => constructor; exact hp; apply Or.inl; exact hq
| inr hpr =>
cases hpr with
| intro hp hr => constructor; exact hp; apply Or.inr; exact hr
これらのタクティクを非常に一般的に用いることができることは、7章 Inductive Types (帰納型)で説明する。端的に説明すると、cases
タクティクは帰納的に定義された型の任意の項を分解することができる。constructor
タクティクは常に、帰納的に定義された型の適用可能な最初のコンストラクタを適用する。例えば、cases
と constructor
は存在量化子に対して使うことができる:
example (p q : Nat → Prop) : (∃ x, p x) → ∃ x, p x ∨ q x := by
intro h
cases h with
| intro x px => constructor; apply Or.inl; exact px
example (p q : Nat → Prop) : (∃ x, p x) → ∃ x, p x ∨ q x := by
intro h
cases h with
| intro x px => apply Exists.intro; apply Or.inl; exact px
ここでは、constructor
タクティクは Exists.intro
の最初の引数である x
の値を暗黙のままにしている。これは一旦メタ変数で表され、そのメタ変数の値は後で決定される。前の例では、メタ変数の適切な値はタクティク exact px
が使われた時点で決定される。なぜなら、px
は型 p x
を持つからである。存在量化子に対する証人を明示的に指定したい場合は、代わりに exists
タクティクを使うことができる:
example (p q : Nat → Prop) : (∃ x, p x) → ∃ x, p x ∨ q x := by
intro h
cases h with
| intro x px => exists x; apply Or.inl; exact px
これは他の例である:
example (p q : Nat → Prop) : (∃ x, p x ∧ q x) → ∃ x, q x ∧ p x := by
intro h
cases h with
| intro x hpq =>
cases hpq with
| intro hp hq =>
exists x
cases
タクティクやconstructor
タクティクは命題だけでなくデータにも使える。次の例では、直積型の項の成分を入れ替える関数と直和型の項の成分を入れ替える関数を定義するためにこれらのタクティクが使われている:
def swap_pair : α × β → β × α := by
intro p
cases p
constructor <;> assumption
def swap_sum : Sum α β → Sum β α := by
intro p
cases p
. apply Sum.inr; assumption
. apply Sum.inl; assumption
theorem swap_and : a ∧ b → b ∧ a := by
intro p
cases p
constructor <;> assumption
theorem swap_or : a ∨ b → b ∨ a := by
intro p
cases p
. apply Or.inr; assumption
. apply Or.inl; assumption
上2つの関数定義の記述と、下2つの定理の証明が、わずかな差を除いて同じであることに注意してほしい。
cases
タクティクは自然数を「場合分け」することもできる:
open Nat
example (P : Nat → Prop) (h₀ : P 0) (h₁ : ∀ n, P (succ n)) (m : Nat) : P m := by
cases m with
| zero => exact h₀
| succ m' => exact h₁ m'
cases
タクティクとその仲間である induction
タクティクについては、Tactics for Inductive Types (帰納型のためのタクティク)節で詳しく説明する。
contradiction
タクティクは現在のゴールのコンテキストの中から矛盾を探す:
example (p q : Prop) : p ∧ ¬ p → q := by
intro h
cases h
contradiction
match
はタクティクブロック内でも使うことができる。
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
apply Iff.intro
. intro h
match h with
| ⟨_, Or.inl _⟩ => apply Or.inl; constructor <;> assumption
| ⟨_, Or.inr _⟩ => apply Or.inr; constructor <;> assumption
. intro h
match h with
| Or.inl ⟨hp, hq⟩ => constructor; exact hp; apply Or.inl; exact hq
| Or.inr ⟨hp, hr⟩ => constructor; exact hp; apply Or.inr; exact hr
intro h
を match h ...
と「組み合わせる」と、上記の例は次のように書ける:
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
apply Iff.intro
. intro
| ⟨hp, Or.inl hq⟩ => apply Or.inl; constructor <;> assumption
| ⟨hp, Or.inr hr⟩ => apply Or.inr; constructor <;> assumption
. intro
| Or.inl ⟨hp, hq⟩ => constructor; assumption; apply Or.inl; assumption
| Or.inr ⟨hp, hr⟩ => constructor; assumption; apply Or.inr; assumption
Structuring Tactic Proofs (タクティク証明の構造化)
タクティクはしばしば証明を構築する効率的な方法を提供するが、長いタクティクの列は証明の構造を不明瞭にすることがある。このセクションでは、タクティクスタイルの証明をより読みやすくよりロバストにするために、タクティクスタイルの証明を構造化する方法を説明する。
Leanの証明記述構文の優れている点のひとつは、項スタイルの証明とタクティクスタイルの証明をミックスさせて、その間を自由に行き来できることだ。例えば、apply
タクティクや exact
タクティクは have
や show
などを使って書かれた任意の型の項を受け取ることができる。逆に、Leanで任意の項を書くときは、by
キーワードを挿入することで、いつでもタクティクモードを呼び出すことができる。以下はその例である:
example (p q r : Prop) : p ∧ (q ∨ r) → (p ∧ q) ∨ (p ∧ r) := by
intro h
exact
have hp : p := h.left
have hqr : q ∨ r := h.right
show (p ∧ q) ∨ (p ∧ r) by
cases hqr with
| inl hq => exact Or.inl ⟨hp, hq⟩
| inr hr => exact Or.inr ⟨hp, hr⟩
次はより自然な例である:
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
apply Iff.intro
. intro h
cases h.right with
| inl hq => exact Or.inl ⟨h.left, hq⟩
| inr hr => exact Or.inr ⟨h.left, hr⟩
. intro h
cases h with
| inl hpq => exact ⟨hpq.left, Or.inl hpq.right⟩
| inr hpr => exact ⟨hpr.left, Or.inr hpr.right⟩
実際、show
タクティクというものがあり、これは項スタイルの証明の show
式に似ている。これは、タクティクモードの中で、解こうとしているゴールのターゲットの型を宣言するだけのタクティクである。
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
apply Iff.intro
. intro h
cases h.right with
| inl hq =>
show (p ∧ q) ∨ (p ∧ r)
exact Or.inl ⟨h.left, hq⟩
| inr hr =>
show (p ∧ q) ∨ (p ∧ r)
exact Or.inr ⟨h.left, hr⟩
. intro h
cases h with
| inl hpq =>
show p ∧ (q ∨ r)
exact ⟨hpq.left, Or.inl hpq.right⟩
| inr hpr =>
show p ∧ (q ∨ r)
exact ⟨hpr.left, Or.inr hpr.right⟩
実は、show
タクティクは、ゴールのターゲットをdefinitionally equalな他の表現に書き換えるために使うことができる:
example (n : Nat) : n + 1 = Nat.succ n := by
-- goal is n: Nat ⊢ n + 1 = Nat.succ n
show Nat.succ n = Nat.succ n
-- goal is n: Nat ⊢ Nat.succ n = Nat.succ n
rfl
example (n : Nat) : n + 1 = Nat.succ n := by
-- goal is n: Nat ⊢ n + 1 = Nat.succ n
rfl
項スタイルの証明のときと同様に、have
タクティクは、宣言した型の項を作るというサブゴールを導入する:
example (p q r : Prop) : p ∧ (q ∨ r) → (p ∧ q) ∨ (p ∧ r) := by
intro ⟨hp, hqr⟩
show (p ∧ q) ∨ (p ∧ r)
cases hqr with
| inl hq =>
have hpq : p ∧ q := And.intro hp hq
apply Or.inl
exact hpq
| inr hr =>
have hpr : p ∧ r := And.intro hp hr
apply Or.inr
exact hpr
項スタイルの証明のときと同様に、have
タクティクでは項に付けるラベルを省略することもできる。その場合、デフォルトのラベル this
が使われる:
example (p q r : Prop) : p ∧ (q ∨ r) → (p ∧ q) ∨ (p ∧ r) := by
intro ⟨hp, hqr⟩
show (p ∧ q) ∨ (p ∧ r)
cases hqr with
| inl hq =>
have : p ∧ q := And.intro hp hq
apply Or.inl
exact this
| inr hr =>
have : p ∧ r := And.intro hp hr
apply Or.inr
exact this
have
タクティクでは型も省略することができる。したがって、have hp := h.left
や have hqr := h.right
と書くことができる。これらの省略記法を用いると、have
タクティクにおいて型とラベルの両方を省略することさえできる。その場合、新しい項にはデフォルトのラベル this
が使われる。
example (p q r : Prop) : p ∧ (q ∨ r) → (p ∧ q) ∨ (p ∧ r) := by
intro ⟨hp, hqr⟩
cases hqr with
| inl hq =>
have := And.intro hp hq
apply Or.inl; exact this
| inr hr =>
have := And.intro hp hr
apply Or.inr; exact this
Leanには let
タクティクもある。これは have
タクティクに似ているが、have
タクティクが補助的な事実を導入するのに対して、let
タクティクは局所的な定義を導入する。これは項スタイルの証明における let
に類似したタクティクである。
example : ∃ x, x + 2 = 8 := by
let a : Nat := 3 * 2
exists a
have
と同様に、let a := 3 * 2
と書くことで、型を暗黙のままにすることができる。let
と have
の違いは、let
はコンテキストの中でローカルな定義を導入することである。ローカルに宣言された定義はその証明の中で展開することができる。
先ほど、.
を使って入れ子のタクティクブロックを作成した。入れ子のブロックの中では、Leanは最初のゴールに注目し、そのブロックの最後でそのゴールが完全に解決されていなければエラーを生成する。これは、タクティクによって導入された複数のサブゴールを一つ一つ証明するのに便利である。.
記法はインデントに敏感である。なぜなら、インデントを見て各タクティクブロックが終了したかどうかを検知するからである。あるいは、波括弧とセミコロンを使ってタクティクブロックを表すこともできる。
example (p q r : Prop) : p ∧ (q ∨ r) ↔ (p ∧ q) ∨ (p ∧ r) := by
apply Iff.intro
{ intro h;
cases h.right;
{ show (p ∧ q) ∨ (p ∧ r);
exact Or.inl ⟨h.left, ‹q›⟩ }
{ show (p ∧ q) ∨ (p ∧ r);
exact Or.inr ⟨h.left, ‹r›⟩ } }
{ intro h;
cases h;
{ show p ∧ (q ∨ r);
rename_i hpq;
exact ⟨hpq.left, Or.inl hpq.right⟩ }
{ show p ∧ (q ∨ r);
rename_i hpr;
exact ⟨hpr.left, Or.inr hpr.right⟩ } }
証明を構造化するためにインデントを使うと便利である: タクティクが2つ以上のサブゴールを残すたびに、残りのサブゴールをブロックで囲んでインデントして分離するとよい。もし定理 foo
の適用が1つのゴールから4つのサブゴールを生成するなら、証明の見た目は次のようになるだろう:
apply foo
. <proof of first goal>
. <proof of second goal>
. <proof of third goal>
. <proof of final goal>
あるいは
apply foo
case <tag of first goal> => <proof of first goal>
case <tag of second goal> => <proof of second goal>
case <tag of third goal> => <proof of third goal>
case <tag of final goal> => <proof of final goal>
あるいは
apply foo
{ <proof of first goal> }
{ <proof of second goal> }
{ <proof of third goal> }
{ <proof of final goal> }
Tactic Combinators (タクティク結合子)
Tactic combinators(タクティク結合子) は既存のタクティクから新しいタクティクを作る。逐次結合子 ;
は by
ブロックの中で既に暗黙のうちに使われている:
example (p q : Prop) (hp : p) : p ∨ q :=
by apply Or.inl; assumption
ここで、apply Or.inl; assumption
はまず単一のタクティク apply Or.inl
を使ってから assumption
を使うのと機能的に同等である。
t₁ <;> t₂
において、結合子 <;>
は逐次結合子の「パラレル」版である: まず t₁
が現在のゴールに適用される。それから t₂
がサブゴール「全て」に適用される。
example (p q : Prop) (hp : p) (hq : q) : p ∧ q :=
by constructor <;> assumption
この方法は、t₁
の適用の結果得られるサブゴールが一様の形式を持つ場合、あるいは少なくとも、全てのゴールを一様な方法で進めることができる場合に特に有効である。
first | t₁ | t₂ | ... | tₙ
はどれか一つが成功するまで各 tᵢ
を適用する。その全てが成功しなかったら失敗する:
example (p q : Prop) (hp : p) : p ∨ q := by
first | apply Or.inl; assumption | apply Or.inr; assumption
example (p q : Prop) (hq : q) : p ∨ q := by
first | apply Or.inl; assumption | apply Or.inr; assumption
最初の例では左のタクティクが成功し、2番目の例では右のタクティクが成功している。次の3つの例では、いずれも同じ複合タクティクにより証明が成功している。
example (p q r : Prop) (hp : p) : p ∨ q ∨ r :=
by repeat (first | apply Or.inl; assumption | apply Or.inr | assumption)
example (p q r : Prop) (hq : q) : p ∨ q ∨ r :=
by repeat (first | apply Or.inl; assumption | apply Or.inr | assumption)
example (p q r : Prop) (hr : r) : p ∨ q ∨ r :=
by repeat (first | apply Or.inl; assumption | apply Or.inr | assumption)
/- repeat と first を使わなかった場合 -/
example (p q r : Prop) (hp : p) : p ∨ q ∨ r :=
by apply Or.inl; assumption
example (p q r : Prop) (hq : q) : p ∨ q ∨ r :=
by apply Or.inr
apply Or.inl; assumption
example (p q r : Prop) (hr : r) : p ∨ q ∨ r :=
by apply Or.inr
apply Or.inr
assumption
このタクティクはまず選言命題の左側をターゲットにし、それを assumption
で解こうとする。それが失敗したら、選言命題の右側に注目する。もしそれも失敗したら、assumption
タクティクを呼び出す。
もう気付いているだろうが、タクティクは失敗することがある。実際、first
結合子がバックトラックして次のタクティクを試すのは、一番最初のタクティクが「失敗」したときである。try
結合子は、つまらないかもしれない方法によって、常に成功するタクティクを構築する: try t
は t
を実行し、たとえ t
が失敗しても成功したと報告する。try t
は first | t | skip
と等価である。ここで、skip
は何もせず、そして何もしないことで成功するタクティクである。次の例では、2番目の constructor
は連言の右側 q ∧ r
については成功するが、連言の左側 p
については失敗する (連言と選言は右結合的であることを覚えておこう)。try
タクティクは逐次結合されたタクティクが成功することを保証する。
example (p q r : Prop) (hp : p) (hq : q) (hr : r) : p ∧ q ∧ r := by
constructor <;> (try constructor) <;> assumption
注意: try t
は決して失敗しないため、repeat (try t)
は無限にループする。
証明では、複数のゴールが未解決であることがよくある。並列逐次結合子 <;>
は1つのタクティクを複数のゴールに適用する1つの方法だが、他にも方法はある。例えば、all_goals t
は全ての未解決のゴールに t
を適用する:
example (p q r : Prop) (hp : p) (hq : q) (hr : r) : p ∧ q ∧ r := by
constructor
all_goals (try constructor)
all_goals assumption
この例では、any_goals
タクティクはよりロバストな解を提供する。タクティク any_goals t
は all_goals t
に似ているが、any_goals t
は t
が少なくとも1つのゴールで成功すれば成功する。
example (p q r : Prop) (hp : p) (hq : q) (hr : r) : p ∧ q ∧ r := by
constructor
any_goals constructor
any_goals assumption
次の例において、最初のタクティクは連言命題を繰り返し分解する:
example (p q r : Prop) (hp : p) (hq : q) (hr : r) :
p ∧ ((p ∧ q) ∧ r) ∧ (q ∧ r ∧ p) := by
repeat (any_goals constructor)
all_goals assumption
実際、上記の例の全てのタクティクを一行に詰め込むことができる:
example (p q r : Prop) (hp : p) (hq : q) (hr : r) :
p ∧ ((p ∧ q) ∧ r) ∧ (q ∧ r ∧ p) := by
repeat (any_goals (first | constructor | assumption))
タクティク focus t
は、他のゴールを一時的にスコープから隠し、t
を現在のゴール(一番上のゴール)だけに作用させる。したがって、通常は t
が現在のゴールだけに作用する場合、focus (all_goals t)
は all_goals
の機能を打ち消して t
と同じ作用を持つ。
Rewriting (書き換え)
Calculational Proofs (計算的証明)の節で、rewrite
タクティク(省略版: rw
)と simp
タクティクを簡単に紹介した。本節と次節では、これらについてさらに詳しく説明する。
rewrite
タクティクはターゲットとコンテキストに置換を適用するための基本的なメカニズムであり、等式を扱う便利で効率的な方法を提供する。このタクティクの最も基本的な構文は rewrite [t]
である。ここで、t
はある等式が成立することを主張する型である。例えば、仮説 h : x = y
を t
として採用することができる。t
は add_comm : ∀ x y, x + y = y + x
のような全称命題でもよい。その場合、rewrite
タクティクは x
と y
に対して適切な項を見つけようとする。あるいは、それが具体的な等式あるいは等式に関する全称命題であれば、t
は関数適用などを含む複合的な項であってもよい。次の例では、仮説を用いてターゲットを書き換えるために基本的な構文 rewrite [t]
を使う。
example (f : Nat → Nat) (k : Nat) (h₁ : f 0 = 0) (h₂ : k = 0) : f k = 0 := by
rw [h₂] -- replace k with 0
rw [h₁] -- replace f 0 with 0
上記の例では、最初の rw
はターゲット f k = 0
内の k
を 0
に置き換え、2番目の rw
はターゲット f 0 = 0
内の f 0
を 0
に置き換えている。このタクティクは t = t
という形の任意のゴールを(rfl
を使うまでもなく)自動的に閉じる。次は複合的な項を使った書き換えの例である:
example (x y : Nat) (p : Nat → Prop) (q : Prop) (h : q → x = y)
(h' : p y) (hq : q) : p x := by
rw [h hq]; assumption
ここで h hq
は x = y
の証明を構築している。
rw [t_1, ..., t_n]
という記法を使って、複数回の書き換えを1つにまとめることができる。これは rw [t_1]; ...; rw [t_n]
の略記である。この記法を用いると、先ほどの例は次のように書ける:
example (f : Nat → Nat) (k : Nat) (h₁ : f 0 = 0) (h₂ : k = 0) : f k = 0 := by
rw [h₂, h₁]
デフォルトでは、rw
は等式を順方向に用いる。つまり、書き換え対象の中で t
の左辺とマッチした(全ての)部分項を t
の右辺で置き換える。記法 ←t
を使うと、等式 t
を逆向きに使うように指示することができる。つまり、書き換え対象の中で t
の右辺とマッチした部分項を t
の左辺で置き換えることができる。
example (f : Nat → Nat) (a b : Nat) (h₁ : a = b) (h₂ : f a = 0) : f b = 0 := by
rw [←h₁, h₂]
この例では、項 ←h₁
は b
を a
に置き換えるよう書き換え器に指示する。エディターでは、\l
と打つと ←
を入力することができる。また、これと等価なアスキー文字列 <-
を使うこともできる。
用いる等式が全称命題の場合、等式の左辺が書き換え対象内の複数の部分項とマッチすることがある。例えば、書き換え対象が a + b + c = a + c + b
であるとき、rw [Nat.add_comm]
は a + b
とも a + c
とも a + b + c
とも a + c + b
ともマッチしうる。その場合、rw
タクティクは書き換え対象を走査したときに最初にマッチした部分項を書き換える。それが希望するものではない場合は、追加の引数を与えることでマッチさせたい部分項を指定することができる。
example (a b c : Nat) : a + b + c = a + c + b := by
rw [Nat.add_assoc, Nat.add_comm b, ← Nat.add_assoc]
example (a b c : Nat) : a + b + c = a + c + b := by
rw [Nat.add_assoc, Nat.add_assoc, Nat.add_comm b]
example (a b c : Nat) : a + b + c = a + c + b := by
rw [Nat.add_assoc, Nat.add_assoc, Nat.add_comm _ b]
上記の最初の例では、最初のステップで a + b + c
を a + (b + c)
に書き換えている。次のステップでは、項 b + c
に可換性を適用している。ここで、引数 b
を指定しなければ、a + (b + c)
が (b + c) + a
に書き換えられていただろう。最後に、結合性を逆向きに使うことで a + (c + b)
を a + c + b
に書き換えている。次の2つの例では、まず結合性を2回使って両辺の括弧を右に寄せ、それから b
と c
を入れ替えている。最後の例では、Nat.add_comm
の第2引数を指定することで、左辺ではなく右辺の書き換えを指示していることに注意してほしい。
デフォルトでは、rw
タクティクはゴールのターゲットだけに影響する。rw [t] at h
という記法は、仮説 h
に t
による書き換えを適用する。
example (f : Nat → Nat) (a : Nat) (h : a + 0 = 0) : f a = f 0 := by
rw [Nat.add_zero] at h
rw [h]
最初のステップでは、rw [Nat.add_zero] at h
が仮説 h
の型を a + 0 = 0
から a = 0
に書き換えている。それからターゲットを f 0 = f 0
に書き換えるために h : a = 0
が用いられている。
rewrite
タクティクは命題だけにとどまらず変数の型を書き換えることもできる。次の例では、rw [h] at t
が仮説 t : Tuple α n
の型を t : Tuple α 0
に書き換えている。
def Tuple (α : Type) (n : Nat) :=
{ as : List α // as.length = n }
example (n : Nat) (h : n = 0) (t : Tuple α n) : Tuple α 0 := by
rw [h] at t
exact t
Using the Simplifier (単純化器の使用)
rewrite
タクティクがゴールを操作するために特化したツールとして設計されているのに対し、simplifier(単純化器) はより強力な自動化を提供する。Leanのライブラリには、[simp]
属性が付けられた恒等式が多数収載されており、simp
タクティクはそれらを使って単純化対象内の部分項を繰り返し書き換える。
example (x y z : Nat) : (x + 0) * (0 + y * 1 + z * 0) = x * y := by
simp
example (x y z : Nat) (p : Nat → Prop) (h : p (x * y))
: p ((x + 0) * (0 + y * 1 + z * 0)) := by
simp; assumption
最初の例では、ターゲットの等式の左辺は、0と1を含む普通の恒等式を使って単純化され、ターゲットは x * y = x * y
となる。この時点で、simp
は反射律を用いてゴールを閉じる。2番目の例では、simp
がゴールを p (x * y)
に簡約し、その時点で h
がゴールを閉じる。次はリストに関する例である:
open List
example (xs : List Nat)
: reverse (xs ++ [1, 2, 3]) = [3, 2, 1] ++ reverse xs := by
simp
example (xs ys : List α)
: length (reverse (xs ++ ys)) = length xs + length ys := by
simp [Nat.add_comm]
ここで、simp [t]
は [simp]
属性が付けられた恒等式に加えて t
を用いて単純化対象を書き換える。
rw
と同様に、キーワード at
を使うとコンテキスト内の仮説を単純化することができる:
example (x y z : Nat) (p : Nat → Prop)
(h : p ((x + 0) * (0 + y * 1 + z * 0))) : p (x * y) := by
simp at h; assumption
さらに、「ワイルドカード」*
を使うと、コンテキスト内の全ての仮説とターゲットを単純化することができる:
attribute [local simp] Nat.mul_comm Nat.mul_assoc Nat.mul_left_comm
attribute [local simp] Nat.add_assoc Nat.add_comm Nat.add_left_comm
example (w x y z : Nat) (p : Nat → Prop)
(h : p (x * y + z * w * x)) : p (x * w * z + y * x) := by
simp at *; assumption
example (x y z : Nat) (p : Nat → Prop)
(h₁ : p (1 * x + y)) (h₂ : p (x * z * 1))
: p (y + 0 + x) ∧ p (z * x) := by
simp at * <;> constructor <;> assumption
自然数の乗法のような可換性と結合性を満たす演算の場合、単純化器は可換性と結合性に加えてleft commutativity(左可換性)を用いて式を書き換える。乗法の場合、左可換性は次のように表される: x * (y * z) = y * (x * z)
。local
修飾子は、現在のファイル(あるいはセクションや名前空間)内でこれらの規則を使用するように単純化器に指示する。可換性と左可換性は、どちらか片方を繰り返し適用するとループが生じるという点で問題があるように思えるかもしれない。しかし、単純化器は引数を並べ替える恒等式を検出し、ordered rewriting(順序付き書き換え)として知られるテクニックを使用する。これは、システムが項の内部的な順序を保持し、項の順序が小さくなる場合にのみ恒等式を適用することを意味する。上記の可換性、結合性、左可換性の恒等式は全て、部分項中の括弧を右に寄せるという効果を持つ。そのため、項を(多少恣意的ではあるが)正規化された順序で並べることができる。したがって、結合性と可換性の下で等価な式は、同じ正規形に書き換えられる。
attribute [local simp] Nat.mul_comm Nat.mul_assoc Nat.mul_left_comm
attribute [local simp] Nat.add_assoc Nat.add_comm Nat.add_left_comm
example (w x y z : Nat)
: x * y + z * w * x = x * w * z + y * x := by
simp
example (w x y z : Nat) (p : Nat → Prop)
(h : p (x * y + z * w * x)) : p (x * w * z + y * x) := by
simp -- ⊢ p (x * y + w * (x * z))
simp at h -- h: p (x * y + w * (x * z))
assumption
rewrite
と同様に、simp [t_1, ..., t_n]
と書くことで、単純化の際に使用する恒等式のリストに t_1
... t_n
を追加することができる。追加するものは一般的な補題でも仮説でも定義の展開でも複合的な項でもよい。simp
タクティクも rewrite
と同様に ←t
構文を認識する。
def f (m n : Nat) : Nat :=
m + n + m
example {m n : Nat} (h : n = 1) (h' : 0 = m) : (f m n) = n := by
simp [h, ←h', f]
仮説を用いてゴールを単純化するのがよくある使い方である:
example (f : Nat → Nat) (k : Nat) (h₁ : f 0 = 0) (h₂ : k = 0) : f k = 0 := by
simp [h₁, h₂]
単純化の際に全ての仮説を使いたい場合は、ワイルドカード記号 *
を使う:
example (f : Nat → Nat) (k : Nat) (h₁ : f 0 = 0) (h₂ : k = 0) : f k = 0 := by
simp [*]
次は他の例である:
example (u w x y z : Nat) (h₁ : x = y + z) (h₂ : w = u + x)
: w = z + y + u := by
simp [*, Nat.add_assoc, Nat.add_comm, Nat.add_left_comm]
単純化器は命題の書き換えも行う。例えば、仮説 hp : p
を用いて、p ∧ q
を q
に、p v q
を True
に書き換え、最終的に自明な命題に書き換えられたゴールを閉じる。命題の書き換えを繰り返すことで、非自明な命題推論を行うことができる。
example (p q : Prop) (hp : p) : p ∧ q ↔ q := by
simp [*]
example (p q : Prop) (hp : p) : p ∨ q := by
simp [*]
example (p q r : Prop) (hp : p) (hq : q) : p ∧ (q ∨ r) := by
simp [*]
次の例では、単純化器はコンテキスト内の全ての仮説を単純化し、それらをターゲットに適用しゴールを閉じている。
example (u w x x' y y' z : Nat) (p : Nat → Prop)
(h₁ : x + 0 = x') (h₂ : y + 0 = y')
: x + y + 0 = x' + y' := by
simp at *
simp [*]
単純化器の特に便利なところは、ライブラリが発展するにつれてその機能が成長していくところだ。例えば、リスト xs
を受け取ると、xs
に反転 xs.reverse
を追加して xs
を対称化するリスト演算 mk_symm
を定義したとしよう:
def mk_symm (xs : List α) :=
xs ++ xs.reverse
このとき、任意のリスト xs
に対して、List.reverse (mk_symm xs)
が mk_symm xs
と等しいことが、定義を展開することで容易に証明できる:
def mk_symm (xs : List α) :=
xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
: (mk_symm xs).reverse = mk_symm xs := by
simp [mk_symm]
reverse_mk_symm
が証明された今、新しい定理を証明するために reverse_mk_symm
を使った単純化を用いることができる:
def mk_symm (xs : List α) :=
xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
: (mk_symm xs).reverse = mk_symm xs := by
simp [mk_symm]
example (xs ys : List Nat)
: (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
simp [reverse_mk_symm]
example (xs ys : List Nat) (p : List Nat → Prop)
(h : p (xs ++ mk_symm ys).reverse)
: p (mk_symm ys ++ xs.reverse) := by
simp [reverse_mk_symm] at h; assumption
simp [reverse_mk_symm]
を使うのは一般的に決して悪いことではないが、ユーザーが明示的にこれを呼び出す必要がない方がなおよいだろう。定理を定義する際に、「これは単純化の際に使う定理だ」とマークすることで明示的な呼び出しの省略を実現できる:
def mk_symm (xs : List α) :=
xs ++ xs.reverse
@[simp] theorem reverse_mk_symm (xs : List α)
: (mk_symm xs).reverse = mk_symm xs := by
simp [mk_symm]
example (xs ys : List Nat)
: (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
simp
example (xs ys : List Nat) (p : List Nat → Prop)
(h : p (xs ++ mk_symm ys).reverse)
: p (mk_symm ys ++ xs.reverse) := by
simp at h; assumption
記法 @[simp]
は reverse_mk_symm
が [simp]
属性を持つことを宣言する。この宣言はより明示的に記述することができる:
def mk_symm (xs : List α) :=
xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
: (mk_symm xs).reverse = mk_symm xs := by
simp [mk_symm]
attribute [simp] reverse_mk_symm
example (xs ys : List Nat)
: (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
simp
example (xs ys : List Nat) (p : List Nat → Prop)
(h : p (xs ++ mk_symm ys).reverse)
: p (mk_symm ys ++ xs.reverse) := by
simp at h; assumption
定理が宣言された後なら、いつでもその定理に属性を付与することができる:
def mk_symm (xs : List α) :=
xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
: (mk_symm xs).reverse = mk_symm xs := by
simp [mk_symm]
example (xs ys : List Nat)
: (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
simp [reverse_mk_symm]
attribute [simp] reverse_mk_symm
example (xs ys : List Nat) (p : List Nat → Prop)
(h : p (xs ++ mk_symm ys).reverse)
: p (mk_symm ys ++ xs.reverse) := by
simp at h; assumption
しかし、一度属性が付与されると、それを永続的に削除する方法はない。そして、属性は、その属性が割り当てられているファイルをインポートする全てのファイルに適用される。Attributes (属性)の節で詳しく説明するが、local
修飾子を使えば、属性の適用範囲を現在のファイルやセクションに限定することができる:
def mk_symm (xs : List α) :=
xs ++ xs.reverse
theorem reverse_mk_symm (xs : List α)
: (mk_symm xs).reverse = mk_symm xs := by
simp [mk_symm]
section
attribute [local simp] reverse_mk_symm
example (xs ys : List Nat)
: (xs ++ mk_symm ys).reverse = mk_symm ys ++ xs.reverse := by
simp
example (xs ys : List Nat) (p : List Nat → Prop)
(h : p (xs ++ mk_symm ys).reverse)
: p (mk_symm ys ++ xs.reverse) := by
simp at h; assumption
end
local
を使った場合、そのセクションの外では、単純化器はデフォルトで reverse_mk_symm
を使わなくなる。
これまで説明してきた様々な simp
のオプション (ルールの明示的なリストを与える、at
を使って適用対象を指定するなど) は組み合わせることができるが、オプションを記述する順序は厳格であることに注意してほしい。エディタの中では、simp
キーワードにカーソルを合わせて、simp
に関連するドキュメントを読むことで、オプションの正しい順序を確認することができる。
さらに2つの便利な修飾子がある。デフォルトでは、simp
は属性 [simp]
でマークされた全ての定理を利用する。simp only
と書くと、デフォルトで使われる定理は全て除外され、より明確に作られた定理のリストを使うことができる。以下の例では、マイナス記号 -
と only
が reverse_mk_symm
の適用をブロックするために使われている。
def mk_symm (xs : List α) :=
xs ++ xs.reverse
@[simp] theorem reverse_mk_symm (xs : List α)
: (mk_symm xs).reverse = mk_symm xs := by
simp [mk_symm]
example (xs ys : List Nat) (p : List Nat → Prop)
(h : p (xs ++ mk_symm ys).reverse)
: p (mk_symm ys ++ xs.reverse) := by
simp at h; assumption
example (xs ys : List Nat) (p : List Nat → Prop)
(h : p (xs ++ mk_symm ys).reverse)
: p ((mk_symm ys).reverse ++ xs.reverse) := by
simp [-reverse_mk_symm] at h; assumption
example (xs ys : List Nat) (p : List Nat → Prop)
(h : p (xs ++ mk_symm ys).reverse)
: p ((mk_symm ys).reverse ++ xs.reverse) := by
simp only [List.reverse_append] at h; assumption
simp
タクティクには多くの設定オプションがある。例えば、次のように文脈的な単純化(ターゲットの前件を用いた単純化)を有効にすることができる。
example : if x = 0 then y + x = y else x ≠ 0 := by
simp (config := { contextual := true })
contextual := true
のとき、 simp
は y + x = y
を単純化する際は x = 0
という事実を使い、x ≠ 0
を単純化する際には x ≠ 0
を用いる。次は他の例である:
example : ∀ (x : Nat) (h : x = 0), y + x = y := by
simp (config := { contextual := true })
もうひとつの便利な設定オプションは、算術的な単純化を可能にする arith := true
である。これは非常に便利なので、simp_arith
は simp (config := { arith := true })
の省略形になっている。
example : 0 < 1 + x ∧ x + y + 2 ≥ y + 1 := by
simp_arith
Split Tactic (Splitタクティク)
split
タクティクは、入れ子の if-then-else
式や match
式を場合分けするのに便利である。n
個の場合分けを持つ match
式に対して、split
タクティクは最大 n
個のサブゴールを生成する。次に例を示す:
def f (x y z : Nat) : Nat :=
match x, y, z with
| 5, _, _ => y
| _, 5, _ => y
| _, _, 5 => y
| _, _, _ => 1
example (x y z : Nat) : x ≠ 5 → y ≠ 5 → z ≠ 5 → z = w → f x y w = 1 := by
intros
simp [f]
split
. contradiction
. contradiction
. contradiction
. rfl
上記の例のタクティク証明は次のように短縮することができる。
def f (x y z : Nat) : Nat :=
match x, y, z with
| 5, _, _ => y
| _, 5, _ => y
| _, _, 5 => y
| _, _, _ => 1
example (x y z : Nat) : x ≠ 5 → y ≠ 5 → z ≠ 5 → z = w → f x y w = 1 := by
intros; simp [f]; split <;> first | contradiction | rfl
タクティク split <;> first | contradiction | rfl
は、まず split
タクティクを適用し、次に生成された各サブゴールに対して contradiction
を試し、それが失敗したら rfl
を試す。simp
のように、split
をコンテキスト内の特定の仮説に適用することもできる。
def g (xs ys : List Nat) : Nat :=
match xs, ys with
| [a, b], _ => a+b+1
| _, [b, c] => b+1
| _, _ => 1
example (xs ys : List Nat) (h : g xs ys = 0) : False := by
simp [g] at h; split at h <;> simp_arith at h
Extensible Tactics (拡張可能なタクティク)
次の例では、コマンド syntax
を使って triv
という記法を定義する。次に、macro_rules
コマンドを使って、triv
が使われたときの処理を指定する(triv
のマクロ展開を指定する)。triv
に対して複数のマクロ展開を指定することができ、タクティク解釈器はどれかが成功するまで全てのマクロ展開を試す。
-- 新しい記法を定義する
syntax "triv" : tactic
macro_rules
| `(tactic| triv) => `(tactic| assumption)
example (h : p) : p := by
triv
-- 現時点では、`triv` を使って次の定理を証明することはできない
-- example (x : α) : x = x := by
-- triv
-- `triv` を拡張しよう。タクティク解釈器はどれかが成功するまで
-- `triv` のための全てのマクロ展開を試す
macro_rules
| `(tactic| triv) => `(tactic| rfl)
example (x : α) : x = x := by
triv
example (x : α) (h : p) : x = x ∧ p := by
apply And.intro <;> triv
-- (再帰的な)マクロ展開を追加する
macro_rules | `(tactic| triv) => `(tactic| apply And.intro <;> triv)
example (x : α) (h : p) : x = x ∧ p := by
triv
Exercises (練習問題)
-
3章 Propositions and Proofs (命題と証明) と 4章 Quantifiers and Equality (量化子と等号) に戻り、タクティク証明を用いて出来るだけ多くの練習問題を解き直せ。
rw
とsimp
も適切に使うこと。 -
タクティク結合子を使って、次の定理の証明を1行で書け:
example (p q r : Prop) (hp : p)
: (p ∨ q ∨ r) ∧ (q ∨ p ∨ r) ∧ (q ∨ r ∨ p) := by
admit
Interacting with Lean (Leanとの対話)
数学的オブジェクトを定義するための言語としての、そして証明を構築するための言語としての依存型理論の基本は理解していただけただろう。読者がまだ手にしていないのは、新しいデータ型を定義する方法である。このギャップを埋めるため、次の章ではinductive data type(帰納データ型)の概念を紹介する。その前に、この章では型理論そのものの説明は一旦お休みして、Leanのコードを書く際の実用的な機能について学ぶ。
ここに掲載されている情報の全てが、すぐ役に立つとは限らない。この章は軽く読んでLeanの特徴を掴み、後で必要に応じて戻ってくることをお勧めする。
Importing Files (ファイルのインポート)
Leanのフロントエンドの目的は、ユーザーの入力を解釈し、形式的な式を構築し、そしてそれらが正しい構文規則に従っており、正しく型付けされることをチェックすることである。Leanは様々なエディタでの使用をサポートしており、ユーザーはエディタ上で継続的なチェックとフィードバックを受けることができる。詳しくはLeanのdocumentation pagesを参照してほしい。
Leanの標準ライブラリの定義と定理は複数のファイルに散在している。ユーザーは追加のライブラリを使用したり、複数のファイルからなる独自のプロジェクトを開発することができる。Leanが起動すると、ライブラリの Init
フォルダの内容が自動的にインポートされる。Init
フォルダには基本的な定義や構文が多数含まれている。その結果、ここで紹介する例のほとんどは追加設定なしでそのまま動作する。
しかし、追加ファイルを使いたい場合は、ファイルの先頭に import
文を書いて手動でインポートする必要がある。コマンド
import Bar.Baz.Blah
は Bar/Baz/Blah.olean
ファイルを読み込む(.olean
はコンパイル済のLeanファイルの拡張子である)。このコマンドにおいて、Bar.Baz.Blah
はLeanのsearch path(検索パス)からの相対パスとして解釈される。検索パスがどのように決定されるかについては、documentation pagesを参照してほしい。デフォルトでは、標準ライブラリディレクトリと、場合によってはユーザーのローカルプロジェクトのルートが検索パスに含まれる。(Lean4では、カレントディレクトリからの相対パスでインポートファイルを指定することはできない。)
インポートは「推移的」である。言い換えれば、Foo
をインポートして、Foo
が Bar
をインポートする場合、Bar
の内容にもアクセスできるようになる。したがって、Bar
を明示的にインポートする必要はない。
More on Sections (セクションの詳細)
Leanは理論の構造化を手助けするために、様々なセクション分けの仕組みを提供している。Variables and Sections (変数とセクション)の節で、section
コマンドを使うことで、理論の要素をグループ化できるだけでなく、必要に応じて定理や定義の引数として挿入される変数のスコープを区切ることができることを説明した。variable
コマンドのポイントは、次の例のように、定理で使う変数を宣言できることであることを思い出してほしい:
section
variable (x y : Nat)
def double := x + x
#check double y
#check double (2 * x)
attribute [local simp] Nat.add_assoc Nat.add_comm Nat.add_left_comm
theorem t1 : double (x + y) = double x + double y := by
simp [double]
#check t1 y
#check t1 (2 * x)
theorem t2 : double (x * y) = double x * y := by
simp [double, Nat.add_mul]
end
variable (x y : Nat)
が書かれていれば、double
の定義において x
を引数として宣言する必要はない。Leanは double
の定義の中で x
が使われていることを検出し、(x : Nat)
を定義の引数に自動的に挿入する。同様に、Leanは t1
と t2
の中に x
と y
が現れることを検出し、 (x : Nat)
と (y : Nat)
を自動的に挿入する。double
の定義の中に y
は現れていないため、double
は y
を引数として持たないことに注意してほしい。variable
コマンドで宣言された変数は、それらが実際に使用される定義宣言にのみ引数として挿入される。
More on Namespaces (名前空間の詳細)
Leanでは、識別子(定義や定理や定数の名前)は Foo.Bar.baz
のようなhierarchical names(階層名)で与えられる。Leanが階層名を扱うためのメカニズムを提供していることは、Namespaces (名前空間)の節で説明した。コマンド namespace foo
は、end foo
に行き当たるまで、各定義と定理の名前の前に foo
を付加する。コマンド open foo
は、接頭辞 foo
で始まる各定義と定理に、元の「フルネーム」に加えて接頭辞 foo
を除いた一時的な「別名」を与える。
namespace Foo
def bar : Nat := 1
end Foo
open Foo
#check bar
#check Foo.bar
次のような定義
def Foo.bar : Nat := 1
はマクロとして扱われ、次のように展開される。
namespace Foo
def bar : Nat := 1
end Foo
定理や定義の名前は一意でなければならないが、短い「別名」は一意でないことがある。名前空間を開いたとき、識別子の指示対象が曖昧になる可能性がある。Leanは型情報を使って文脈上の意味を曖昧でなくしようとするが、フルネームを記すことで常に曖昧さをなくすことができる。全ての識別子にフルネームを与えるため、空の接頭辞を明示的に記述するための文字列 _root_
が存在する。
def String.add (a b : String) : String :=
a ++ b
def Bool.add (a b : Bool) : Bool :=
a != b
def add (α β : Type) : Type := Sum α β
open Bool
open String
-- #check add -- これは曖昧である
#check String.add -- String → String → String
#check Bool.add -- Bool → Bool → Bool
#check _root_.add -- Type → Type → Type
#check add "hello" "world" -- String
#check add true false -- Bool
#check add Nat Nat -- Type
protected
キーワードを使って定義を宣言することで、短い別名が作られることを防ぐことができる:
protected def Foo.bar : Nat := 1
open Foo
-- #check bar -- error
#check Foo.bar
一般的な名前のオーバーロードを防ぐため、protected
キーワードは Nat.rec
や Nat.recOn
のような名前にも用いられる。
open
コマンドにはバリエーションがある。コマンド open Nat (succ zero gcd)
は列挙された識別子 succ zero gcd
に対してのみ短い別名を生成する:
open Nat (succ zero gcd)
#check zero -- Nat
#eval gcd 15 6 -- 3
コマンド open Nat hiding succ gcd
は、 Nat
名前空間内の列挙された識別子 succ gcd
を除く全てに対して短い別名を生成する:
open Nat hiding succ gcd
#check zero -- Nat
-- #eval gcd 15 6 -- error
#eval Nat.gcd 15 6 -- 3
コマンド open Nat renaming mul → times, add → plus
は、Nat.mul
を times
に、Nat.add
を plus
にリネームした上で短い別名を生成する:
open Nat renaming mul → times, add → plus
#eval plus (times 2 2) 3 -- 7
-- #eval mul 1 2 -- error
#eval Nat.mul 1 2 -- 2
ある名前空間から別の名前空間へ、あるいはルートレベルへ別名を export
することは時に便利である。現在の名前空間 Foo
の中で、コマンド export Nat (succ add sub)
は、Nat.succ
、Nat.add
、Nat.sub
に対して別名 Foo.succ
、Foo.add
、Foo.sub
を生成する。したがって、名前空間が開かれているときは、いつでもこれらの別名を使うことができる。名前空間の外で export
コマンドが使われたときは、短い別名がルートレベルにエクスポートされる。
namespace Foo
export And (intro left right)
#check And.intro -- And.intro {a b : Prop} (left : a) (right : b) : a ∧ b
#check Foo.intro -- And.intro {a b : Prop} (left : a) (right : b) : a ∧ b
#check intro -- And.intro {a b : Prop} (left : a) (right : b) : a ∧ b
#check left -- And.left {a b : Prop} (self : a ∧ b) : a
end Foo
-- #check intro -- error
export And (intro left right)
#check intro -- And.intro {a b : Prop} (left : a) (right : b) : a ∧ b
-- #check _root_.intro -- error
export
コマンドが上手く機能しない場合は、protected
キーワードや属性によって保護されている可能性を考えよう。
Attributes (属性)
Leanの主な機能はユーザーの入力を形式的な式に翻訳することであり、その形式的な式はカーネルによって正しさがチェックされ、後で使用するために環境に保存される。しかし、いくつかのコマンドは、環境内のオブジェクトに属性を割り当てたり、記法を定義したり、10章 Type Classes (型クラス)で説明される型クラスのインスタンスを宣言したりと、環境に別の影響を与える。そのようなコマンドのほとんどはグローバルな効果を持つ、つまり現在のファイル内だけでなく、現在のファイルをインポートした任意のファイル内でその効果が持続する。しかしながら、このようなコマンドは local
修飾子をサポートしていることが多い。local
修飾子を使えば、コマンドの効果を現在のセクションや名前空間が閉じられるまで、あるいは現在のファイルの終わりまでに限定することができる。
Using the Simplifier (単純化器の使用)の節では、定理に [simp]
属性を付与することで、単純化器がそれらの定理を使用できるようになることを説明した。次の例では、リストの「接頭辞関係」を定義し、この関係が反射的であることを証明し、その定理に [simp]
属性を割り当てている。
def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
∃ t, l₁ ++ t = l₂
@[simp] theorem List.isPrefix_self (as : List α) : isPrefix as as :=
⟨[], by simp⟩
example : isPrefix [1, 2, 3] [1, 2, 3] := by
simp
それから、単純化器は isPrefix [1, 2, 3] [1, 2, 3]
を True
に書き換えることでこれを証明している。
定義がなされた後ならいつでも、その定義に属性を割り当てることができる:
def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
∃ t, l₁ ++ t = l₂
theorem List.isPrefix_self (as : List α) : isPrefix as as :=
⟨[], by simp⟩
attribute [simp] List.isPrefix_self
local
修飾子を付けなかった場合、属性は、属性の宣言が行われたファイルをインポートするどのファイルにおいても有効である。local
修飾子を付加すると、属性のスコープは制限される:
def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
∃ t, l₁ ++ t = l₂
section
theorem List.isPrefix_self (as : List α) : isPrefix as as :=
⟨[], by simp⟩
attribute [local simp] List.isPrefix_self
example : isPrefix [1, 2, 3] [1, 2, 3] := by
simp
end
-- Error:
-- example : isPrefix [1, 2, 3] [1, 2, 3] := by
-- simp
他の例として、instance
コマンドを使うと isPrefix
関係に ≤
という表記を割り当てることができる。10章 Type Classes (型クラス)で説明するが、instance
コマンドは関連する定義に [instance]
属性を割り当てることで機能する。
def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
∃ t, l₁ ++ t = l₂
instance : LE (List α) where
le := isPrefix
theorem List.isPrefix_self (as : List α) : as ≤ as :=
⟨[], by simp⟩
instance
を用いた表記の割り当てもローカルにすることができる:
def isPrefix (l₁ : List α) (l₂ : List α) : Prop :=
∃ t, l₁ ++ t = l₂
def instLe : LE (List α) :=
{ le := isPrefix }
section
attribute [local instance] instLe
example (as : List α) : as ≤ as :=
⟨[], by simp⟩
end
-- Error:
-- example (as : List α) : as ≤ as :=
-- ⟨[], by simp⟩
後述のNotation (記法)の節では、Leanの記法を定義するメカニズムについて説明し、このメカニズムが local
修飾子もサポートしていることを確認する。しかし、以下のSetting Options (オプションの設定)の節でLeanのオプション設定のメカニズムを説明するが、これは今までのパターンに従っていない: オプションはローカルに設定することしかできない。つまり、オプション設定のスコープは常に現在のセクションかファイルに限定される。
More on Implicit Arguments (暗黙の引数の詳細)
Implicit Arguments (暗黙の引数)の節で、Leanにおいて項 t
の型を {x : α} → β x
と表すとき、波括弧は x
が t
の暗黙の引数であることを表す、と説明した。これは、t
が記述されたときは常にその後にプレースホルダー(穴)が挿入されることを、つまり t
が @t _
に置換されることを意味する。それを望まない場合は、 t
の代わりに @t
と書く必要がある。
暗黙の引数は貪欲に、可能な限り挿入されることに注意してほしい。y
だけを暗黙の引数にして関数 f (x : Nat) {y : Nat} (z : Nat)
を定義したとする。このとき、2番目以降の引数を書かずに f 7
と書くと、これは構文解析器(パーサ)により f 7 _
とパースされる。Leanは弱い暗黙の引数(より控えめに挿入される暗黙の引数)を指定する記法 {{y : Nat}}
を提供している。これは当該引数の後ろに明示的な引数がある場合にのみ、その明示的な引数の前にプレースホルダーを追加することを指定する。この記法は ⦃y : Nat⦄
と書くこともでき、このunicode括弧はそれぞれ \{{
と \}}
と打つと入力できる。f (x : Nat) ⦃y : Nat⦄ (z : Nat)
と書いた場合、f 7
はそのままパースされ、f 7 3
は f 7 _ 3
とパースされる。
この違いを説明するために、反射的ユークリッド関係が対称的かつ推移的であることを示す次の例を考えてみよう。
def reflexive {α : Type u} (r : α → α → Prop) : Prop :=
∀ (a : α), r a a
def symmetric {α : Type u} (r : α → α → Prop) : Prop :=
∀ {a b : α}, r a b → r b a
def transitive {α : Type u} (r : α → α → Prop) : Prop :=
∀ {a b c : α}, r a b → r b c → r a c
def euclidean {α : Type u} (r : α → α → Prop) : Prop :=
∀ {a b c : α}, r a b → r a c → r b c
theorem th1 {α : Type u} {r : α → α → Prop}
(reflr : reflexive r) (euclr : euclidean r)
: symmetric r :=
fun {a b : α} =>
fun (h : r a b) =>
show r b a from euclr h (reflr a)
theorem th2 {α : Type u} {r : α → α → Prop}
(symmr : symmetric r) (euclr : euclidean r)
: transitive r :=
fun {a b c : α} =>
fun (rab : r a b) (rbc : r b c) =>
show r a c from euclr (symmr rab) rbc
theorem th3 {α : Type u} {r : α → α → Prop}
(reflr : reflexive r) (euclr : euclidean r)
: transitive r :=
th2 (th1 reflr @euclr) @euclr
variable (r : α → α → Prop)
variable (euclr : euclidean r)
#check @euclr -- euclidean r
#check euclr -- r ?m1 ?m2 → r ?m1 ?m3 → r ?m2 ?m3
この例は3つの小さなステップからなっている: th1
は反射的かつユークリッド的な関係が対称的であることを示す。th2
は対称的かつユークリッド的な関係が推移的であることを示す。そして th3
は2つの定理を組み合わせ、反射的かつユークリッド的な関係が推移的であることを示している。しかし、th3
の証明において、euclr
の暗黙の引数を手動で無効にしなければならない。そうしなければ、証明中の euclr
に必要以上に多くの暗黙の引数が挿入されてしまうからである。つまり、証明内の @euclr
は命題 ∀ {a b c : α}, r a b → r a c → r b c
の証明項である一方で、証明内の euclr
は @euclr
を暗黙のメタ引数 ?m1 ?m2 ?m3 : α
に適用してできた、命題 r ?m1 ?m2 → r ?m1 ?m3 → r ?m2 ?m3
の証明項なのである。弱い暗黙の引数を使えばこの問題は解決する:
def reflexive {α : Type u} (r : α → α → Prop) : Prop :=
∀ (a : α), r a a
def symmetric {α : Type u} (r : α → α → Prop) : Prop :=
∀ {{a b : α}}, r a b → r b a
def transitive {α : Type u} (r : α → α → Prop) : Prop :=
∀ {{a b c : α}}, r a b → r b c → r a c
def euclidean {α : Type u} (r : α → α → Prop) : Prop :=
∀ {{a b c : α}}, r a b → r a c → r b c
theorem th1 {α : Type u} {r : α → α → Prop}
(reflr : reflexive r) (euclr : euclidean r)
: symmetric r :=
fun {a b : α} =>
fun (h : r a b) =>
show r b a from euclr h (reflr a)
theorem th2 {α : Type u} {r : α → α → Prop}
(symmr : symmetric r) (euclr : euclidean r)
: transitive r :=
fun {a b c : α} =>
fun (rab : r a b) (rbc : r b c) =>
show r a c from euclr (symmr rab) rbc
theorem th3 {α : Type u} {r : α → α → Prop}
(reflr : reflexive r) (euclr : euclidean r)
: transitive r :=
th2 (th1 reflr euclr) euclr
variable (r : α → α → Prop)
variable (euclr : euclidean r)
#check @euclr -- euclidean r
#check euclr -- euclidean r
角括弧 [
と ]
で表される3種類目の暗黙の引数がある。10章 Type Classes (型クラス)で説明するように、これらは型クラスのために用いられる。
Notation (記法)
Leanの識別子には、ギリシャ文字を含む任意の英数字(依存型理論で特別な意味を持つ∀、Σ、λは除く)を使うことができる。また、\_
と打った後に希望の添字を打つことで、添字を入力することもできる。
Leanのパーサは拡張可能である、つまり、ユーザーは新しい記法を定義することができる。
ユーザーは、Leanの構文を、基本的なmixfix記法からユーザーカスタムのelaboratorまで、あらゆるレベルで拡張したりカスタマイズできる。実際、全てのビルトイン構文は、ユーザー向けに提供されているメカニズムやAPIと同じものを使ってパースされ、処理される。この節では、様々な拡張方法について記述し、説明する。
新しい記法を導入することは、プログラミング言語では比較的まれなことであり、コードを不明瞭にする可能性があるため時には嫌われることさえあるが、形式化においては、各分野で確立された慣習や記法をコードで簡潔に表現するための非常に貴重なツールである。基本的な記法にとどまらず、よくある定型的なコードを抽出して(うまく機能する)マクロに落とし込んだり、カスタムされたドメイン固有言語(DSL)全体を組み込んで部分問題を効率的かつ読みやすくテキストにエンコードするLeanの機能は、プログラマーと証明エンジニアの双方にとって大きなベネフィットとなる。
Notations and Precedence (記法と優先順位)
最も基本的な構文拡張コマンドを使うと、新しい(あるいはオーバーロードされた)前置演算子、中置演算子、後置演算子を導入することができる。
infixl:65 " + " => HAdd.hAdd -- 左結合的な中置演算子
infix:50 " = " => Eq -- 非結合的な中置演算子
infixr:80 " ^ " => HPow.hPow -- 右結合的な中置演算子
prefix:100 "-" => Neg.neg -- 前置演算子
set_option quotPrecheck false
postfix:max "⁻¹" => Inv.inv -- 後置演算子
演算子の種類(その「固定位置」)を表す最初のコマンド名とコロン :
の後に、演算子のparsing precedence(パース優先順位)を与える。次に新規または既存の記号を二重引用符で囲み(空白はコードの見た目を整えるために使われる)、矢印 =>
の後にこの演算子の変換先の関数を指定する。
優先順位とは演算子と引数の結びつきの「強さ」を表す自然数で、演算の順序を表す。上記がマクロ展開されてできるコマンドを見ることで、これらをより正確に理解することができる:
notation:65 lhs:65 " + " rhs:66 => HAdd.hAdd lhs rhs
notation:50 lhs:51 " = " rhs:51 => Eq lhs rhs
notation:80 lhs:81 " ^ " rhs:80 => HPow.hPow lhs rhs
notation:100 "-" arg:100 => Neg.neg arg
set_option quotPrecheck false
notation:1024 arg:1024 "⁻¹" => Inv.inv arg -- ``max`` はパース優先順位1024の略記
最初のコードブロックの全てのコマンドは、実際にはより一般的な notation
コマンドに変換するコマンドマクロであることがわかった。このようなマクロの書き方は後の節で学ぶ。notation
コマンドは、単一の記号の代わりに、記号と「優先順位を持つ名前付き項プレースホルダー」を組み合わせたシーケンスを受け付ける。このシーケンスは =>
の右辺で参照され、右辺にあるプレースホルダーはシーケンス内の位置に基づいてパースされた各項によって置換される。優先順位 p
を持つプレースホルダーは、その場所で p
以上の優先順位を持つ表記のみを受け付ける。したがって、文字列 a + b + c
を a + (b + c)
とパースすることはできない。なぜなら、infixl
コマンドの演算子の右辺は、演算子自体よりも優先順位が1大きいからである。対照的に、infixr
コマンドの演算子の右辺は、演算子と同じ優先順位を持つ。そのため、a ^ b ^ c
は a ^ (b ^ c)
とパースされる。優先順位が結合性を明確に決定しない場合、notation
コマンドを直接使って次のような中置演算子を導入すると、Leanはこの演算子をデフォルトで右結合的にパースすることに注意してほしい:
set_option quotPrecheck false
notation:65 lhs:65 " ~ " rhs:65 => wobble lhs rhs
より正確には、曖昧な文法が存在する場合、Leanのパーサはローカルなlongest parse rule(最長構文解析ルール)に従う: a ~ b ~ c
の中の a ~
の右辺をパースするとき、パーサは(優先順位が許す限り)最も長くパースを続ける。つまり b
をパースした後に停止せず、~ c
もパースする。したがって、項 a ~ b ~ c
は a ~ (b ~ c)
と等価である。(Leanのパーサは、あくまで最も左側に位置する演算子から順にパースを試みることに注意してほしい。)
上記で言及したように、notation
コマンドを使うと、記号とプレースホルダーを自由にミックスした任意のmixfix構文を定義することができる。
set_option quotPrecheck false
notation:max "(" e ")" => e
notation:10 Γ " ⊢ " e " : " τ => Typing Γ e τ
優先順位が明記されていないプレースホルダーの優先順位はデフォルトで 0
であり、つまりその位置にある任意の項を受け入れる。2つの記法が重なる場合、再び最長構文解析ルールを適用する。
notation:65 a " + " b:66 " + " c:66 => a + b - c
#eval 1 + 2 + 3 -- 0
新しい記法は2項記法よりも優先される。なぜなら2項記法はパースが連鎖せず、1 + 2
をパースしたところで構文解析をやめてしまうからである。最長のパースを受け入れる記法が複数ある場合、どちらが選択されるかはelaborationまで遅延され、ただ1つのオーバーロード記法が正しい型を持つ場合のみ成功し、それ以外の場合は曖昧さを解決できず失敗する。
Coercions (型強制)
Leanでは、自然数の型 Nat
と整数の型 Int
は異なる。しかし、自然数を整数の中に埋め込む Int.ofNat
という関数があり、これを使うと必要なときに自然数を整数に変換することができる。Leanはこの種の型変換の必要性を検出して型変換を実行するメカニズムを持っている。このような自動的な型変換をcoercions(強制)と呼ぶ。
variable (m n : Nat)
variable (i j : Int)
#check i + m -- i + Int.ofNat m : Int
#check i + m + j -- i + Int.ofNat m + j : Int
#check i + m + n -- i + Int.ofNat m + Int.ofNat n : Int
Displaying Information (情報の表示)
Leanに現在の状態や、現在のコンテキストで利用可能なオブジェクトや定理に関する情報を問い合わせる方法はいくつもある。最も一般的なコマンド #check
と #eval
は既に紹介した。#check
は @
記号と一緒に使われることが多く、こうすると定理や定義の引数を暗黙の引数を含めて全て明示することができる。さらに、#print
コマンドを使うと、任意の識別子に関する情報を得ることができる。識別子が定義や定理を表す場合、#print
コマンドはその識別子の型と定義式を表示する。識別子が定数や公理である場合、#print
コマンドは「この識別子は定数(または公理)である」という事実を示し、その型を表示する。
-- examples with equality
#check Eq
#check @Eq
#check Eq.symm
#check @Eq.symm
#print Eq.symm
-- examples with And
#check And
#check And.intro
#check @And.intro
-- a user-defined function
def foo {α : Type u} (x : α) : α := x
#check foo
#check @foo
#print foo
-- axiom example
#print propext
Setting Options (オプションの設定)
Leanは、Leanの動作を制御するためにユーザーが設定することができる内部変数をいくつも保持している。そのための構文は次の通り:
set_option <name> <value>
非常に便利なオプション群の1つは、Leanのpretty-printer(プリティプリンタ)が項を表示する方法を制御する。以下のオプションはtrueかfalseを入力として受け取る:
pp.explicit : 暗黙の引数を表示する
pp.universes : 隠れた宇宙パラメータを表示する
pp.notation : 定義された記法を用いて出力を表示する
例として、次のように設定すると、かなり長い出力が得られる:
#check 2 + 2 = 4
#reduce (fun x => x + 2) = (fun x => x + 3)
#check (fun x => x + 1) 1
set_option pp.explicit true
set_option pp.universes true
set_option pp.notation false
#check 2 + 2 = 4
#reduce (fun x => x + 2) = (fun x => x + 3)
#check (fun x => x + 1) 1
コマンド set_option pp.all true
はこれらの設定を一度に実行し、set_option pp.all false
はこれらのオプションを元の値に戻す。証明をデバッグするときや、不可解なエラーメッセージを理解しようとするときに、付加的な情報を表示させることはしばしば非常に役に立つ。しかし、情報が多すぎると圧倒されるかもしれない。普通にLeanを使う場合はデフォルトの情報表示で一般的には十分である。
Using the Library (ライブラリの使用)
Leanを効率的に使おうと思ったら、必然的にライブラリにある定義や定理を利用する必要が出てくるだろう。ファイルの先頭に import
コマンドを書くと他のファイルから既にコンパイルされた結果をインポートできることと、インポートは推移的であることを思い出してほしい。現在のファイルが Foo
をインポートし、Foo
が Bar
をインポートしているなら、現在のファイルで Foo
のみならず Bar
の定義や定理も利用できる。しかし、名前空間を開くという行為(これによりより短い名前が提供される)はインポート先に引き継がれない。各ファイルで、使用したい名前空間を開く必要がある。
一般に、ライブラリとその内容に詳しくなることは重要である。そうすれば、どのような定理、定義、記法、リソースが利用できるかを知ることができる。Leanのエディタモードも必要なものを見つけるのに役に立つが、ライブラリの内容を直接勉強することはしばしば避けられない。Leanの標準ライブラリはGitHubにあり、オンラインで見ることができる:
GitHubのブラウザインターフェースを使えば、これらのディレクトリやファイルの内容を見ることができる。自分のコンピュータにLeanをインストールした場合は、lean
フォルダの中でライブラリ(例えば、.../.elan/toolchains/leanprover--lean4---nightly/src/lean/Lean
)を見つけて、ファイルマネージャで探索することができる。各ファイルの先頭にあるコメントヘッダは、ファイルに関する追加情報を提供する。
Leanのライブラリ開発者は一般的な命名ガイドラインに従っている。そのため、ユーザーが必要な定理の名前を推測することや、Leanモードをサポートしているエディタでタブ補完を使って定理を見つけることがより簡単になっている。これらについては次の節で説明する。識別子は通常 camelCase
で、型は CamelCase
で命名される。定理には、異なる構成要素を _
で区切った説明的な名前を宛てている(変数名は省略される)。多くの場合、定理の名前はシンプルに結論だけを表す:
#check Nat.succ_ne_zero
#check Nat.zero_add
#check Nat.mul_one
#check Nat.le_of_succ_le_succ
Leanにおいて、識別子たちは階層的な名前空間の中で整理できることを覚えてほしい。例えば、名前空間 Nat
の中の le_of_succ_le_succ
という定理は Nat.le_of_succ_le_succ
というフルネームを持っているが、コマンド open Nat
を使うことで、(protected
とマークされていない名前については)より短い名前が利用可能になる。Leanにおいて、構造体と帰納データ型を定義すると、定義した型に関連する操作が生成され、それらは定義中の型の名前と同じ名前の名前空間に格納されることは、7章 Inductive Types (帰納型)と9章 Structures and Records (構造体とレコード)で説明する。例えば、直積型には以下の操作が付属している:
#check @Prod.mk
#check @Prod.fst
#check @Prod.snd
#check @Prod.rec
Prod.mk
はペアを構成するために使われる。一方で、Prod.fst
と Prod.snd
はそれぞれ直積の1つ目の要素と2つ目の要素を射影する。Prod.rec
は直積の2つの構成要素に対する関数から、直積に対する関数を定義するメカニズムを提供する。Prod.rec
のような名前は保護されており、たとえ Prod
名前空間が開いているときでもフルネームを使用しなければならない。
型としての命題対応において、論理的結合子は帰納型の例である。したがって、論理的結合子に対してドット記法を使うことが多い:
#check @And.intro
#check @And.casesOn
#check @And.left
#check @And.right
#check @Or.inl
#check @Or.inr
#check @Or.elim
#check @Exists.intro
#check @Exists.elim
#check @Eq.refl
#check @Eq.subst
Auto Bound Implicit Arguments (自動束縛暗黙引数)
以前の節で、暗黙の引数が関数をより便利に使えるようにすることを示した。しかし、compose
のような関数はまだ定義が冗長である。宇宙多相的な compose
の定義は2章で定義したものよりもさらに冗長であることに注意してほしい。
universe u v w
def compose {α : Type u} {β : Type v} {γ : Type w}
(g : β → γ) (f : α → β) (x : α) : γ :=
g (f x)
compose
を定義するときに宇宙パラメータ .{u, v, w}
を与えることで、universe
コマンドを省略することができる。
def compose.{u, v, w}
{α : Type u} {β : Type v} {γ : Type w}
(g : β → γ) (f : α → β) (x : α) : γ :=
g (f x)
Lean 4は、auto bound implicit arguments(自動束縛暗黙引数)という新機能をサポートしている。この機能により、compose
のような関数をより便利に書くことができる。Leanが定義宣言の前提を処理するとき、まだ束縛されていない識別子が1文字の小文字かギリシャ文字であれば、それらが暗黙引数として自動的に追加される。この機能を使うと、 compose
を次のように書くことができる。
def compose (g : β → γ) (f : α → β) (x : α) : γ :=
g (f x)
#check @compose
-- {β : Sort u_1} → {γ : Sort u_2} → {α : Sort u_3} → (β → γ) → (α → β) → α → γ
Type
の代わりに Sort
を用いることで、Leanはより一般的な型を推論したことに注意してほしい。
私たち(原文筆者たち)はこの機能が大好きで、Leanを実装する際に広く使用しているが、一部のユーザーはこの機能を不快に感じるかもしれないことも認識している。そのため、コマンド set_option autoImplicit false
を使ってこの機能を無効にすることができる。
set_option autoImplicit false
/- 次の定義は `unknown identifier` エラーを生成する -/
-- def compose (g : β → γ) (f : α → β) (x : α) : γ :=
-- g (f x)
Implicit Lambdas (暗黙ラムダ式)
Lean 3の標準ライブラリでは、@
と _
を多用した怖ろしいイディオム(コードパターン)の実例をよく見かける。このイディオムは、期待される型が暗黙引数を持つ関数型で、さらに暗黙引数を取る定数(実例では reader_t.pure
)がある場合によく使われる。Lean 4では、elaboratorにより暗黙引数を消費するためのラムダ式が自動的に導入される。私たちはこの機能を探究し、その影響を分析しているところだが、これまでの経験はとてもポジティブなものである。以下は、上記リンクの実例に対してLean 4の暗黙ラムダ式を使った例である。
variable (ρ : Type) (m : Type → Type) [Monad m]
instance : Monad (ReaderT ρ m) where
pure := ReaderT.pure
bind := ReaderT.bind
@
を使うか、{}
または []
束縛記法でラムダ式を書くことで、暗黙ラムダ式機能を無効にすることができる。以下はその例である:
namespace ex2
def id1 : {α : Type} → α → α :=
fun x => x
def listId : List ({α : Type} → α → α) :=
(fun x => x) :: []
-- 次の例において、``fun`` の前に ``@`` があるときは
-- 暗黙ラムダ式導入機能は無効になっている
def id2 : {α : Type} → α → α :=
@fun α (x : α) => id1 x
def id3 : {α : Type} → α → α :=
@fun α x => id1 x
def id4 : {α : Type} → α → α :=
fun x => id1 x
-- 次の例では、束縛記法 ``{...}`` を使っているため、
-- 暗黙ラムダ式導入機能は無効になっている
def id5 : {α : Type} → α → α :=
fun {α} x => id1 x
end ex2
Sugar for Simple Functions (単純な関数のための糖衣構文)
Lean 3では、括弧を使うことで、中置演算子から簡単な関数を作ることができる。例えば、(+1)
は fun x, x + 1
の糖衣構文である。Lean 4では、·
をプレースホルダーとして使うことでこの表記を一般化する。以下はその例である:
namespace ex3
#check (· + 1)
-- fun a => a + 1
#check (2 - ·)
-- fun a => 2 - a
#eval [1, 2, 3, 4, 5].foldl (·*·) 1
-- 120
def f (x y z : Nat) :=
x + y + z
#check (f · 1 ·)
-- fun a b => f a 1 b
#eval [(1, 2), (3, 4), (5, 6)].map (·.1)
-- [1, 3, 5]
end ex3
Lean 3と同様、この糖衣構文は括弧を使うことでアクティベートされ、ラムダ抽象は括弧で囲まれた ·
を引数として集めることで作られる。引数の収集は、入れ子になった括弧によって中断される。次の例では、2つの異なるラムダ式が生成されている:
#check (Prod.mk · (· + 1))
-- fun a => (a, fun b => b + 1)
Named Arguments (名前付き引数)
名前付き引数を使うと、引数リスト内の位置と引数をマッチさせるだけでなく、関数定義時に指定された引数名と引数をマッチさせることができる。引数の順番は覚えていないが引数の名前は知っている場合、その名前を使えばどんな順番でもその引数を与えることができる。Leanが暗黙引数を推論できなかった場合、名前付き引数を使ってその暗黙引数の値を指定することもできる。名前付き引数は、各引数が何を表しているのかを明確にすることで、コードの読みやすさも向上させる。
def sum (xs : List Nat) :=
xs.foldl (init := 0) (·+·)
#eval sum [1, 2, 3, 4]
-- 10
example {a b : Nat} {p : Nat → Nat → Nat → Prop} (h₁ : p a b b) (h₂ : b = a)
: p a a b :=
Eq.subst (motive := fun x => p a x b) h₂ h₁
以下に、名前付き引数とデフォルト引数の相互作用を例示する。
def f (x : Nat) (y : Nat := 1) (w : Nat := 2) (z : Nat) :=
x + y + w - z
-- ``(y : Nat := 1)`` は ``y`` のデフォルトの値が ``1`` であることを表す
example (x z : Nat) : f (z := z) x = x + 1 + 2 - z := rfl
example (x z : Nat) : f x (z := z) = x + 1 + 2 - z := rfl
example (x y : Nat) : f x y = fun z => x + y + 2 - z := rfl
example : f = (fun x z => x + 1 + 2 - z) := rfl
example (x : Nat) : f x = fun z => x + 1 + 2 - z := rfl
example (y : Nat) : f (y := 5) = fun x z => x + 5 + 2 - z := rfl
def g {α} [Add α] (a : α) (b? : Option α := none) (c : α) : α :=
match b? with
| none => a + c
| some b => a + b + c
variable {α} [Add α]
example : g = fun (a c : α) => a + c := rfl
example (x : α) : g (c := x) = fun (a : α) => a + x := rfl
example (x : α) : g (b? := some x) = fun (a c : α) => a + x + c := rfl
example (x : α) : g x = fun (c : α) => x + c := rfl
example (x y : α) : g x y = fun (c : α) => x + y + c := rfl
..
を使えば、足りない明示的引数全てに _
を指定することができる。この機能と名前付き引数を組み合わせると、パターンを書くのに便利である。以下はその例である:
inductive Term where
| var (name : String)
| num (val : Nat)
| app (fn : Term) (arg : Term)
| lambda (name : String) (type : Term) (body : Term)
def getBinderName : Term → Option String
| Term.lambda (name := n) .. => some n
| _ => none
def getBinderType : Term → Option Term
| Term.lambda (type := t) .. => some t
| _ => none
省略記号は、明示的な引数が自動的に推論され、かつ _
の連続を避けたい場合にも便利である。
example (f : Nat → Nat) (a b c : Nat) : f (a + b + c) = f (a + (b + c)) :=
congrArg f (Nat.add_assoc ..)
Inductive Types (帰納型)
Leanの形式的基礎は Prop, Type 0, Type 1, Type 2, ...
という基本的な型を含み、(x : α) → β
といった依存関数型の形成を可能にすることが分かった。例の中では、Bool
、Nat
、Int
などの追加的な型や、List
、直積型 ×
などの型コンストラクタも使用した。実際、Leanのライブラリにおいて、宇宙以外の全ての具体的な型と、依存関数型以外の全ての型コンストラクタは、inductive types(帰納型)と呼ばれる型コンストラクタの集まりである(逆に、宇宙と依存関数型はfoundational types(基礎型)として知られる)。型宇宙、依存関数型、そして帰納型のみで巨大で頑丈な数学の体系を構築できることは驚くべきことである。それ以外の全てはこの3種類の型から派生する。
直感的には、帰納型は指定されたコンストラクタのリストから構築される。Leanにおいて、帰納型を指定する構文は次の通りである:
inductive Foo where
| constructor₁ : ... → Foo
| constructor₂ : ... → Foo
...
| constructorₙ : ... → Foo
直感的には、各コンストラクタは、以前に構築された項から Foo
の新しい項を構築する方法を指定する。Foo
型は各コンストラクタによって構築された項全てからなり、それ以外は何も含まない。帰納的宣言の最初の文字 |
は省略可能である。|
の代わりにコンマ ,
を使ってコンストラクタを区切ることもできる。
以下では、コンストラクタの引数に Foo
型の項を含めることができるが、その際に Foo
の任意の要素がボトムアップで構築されることを保証する、ある「正の」制約が適用されることを説明する。大雑把に言えば、各コンストラクタの引数は Foo
型と以前に定義された型からなる任意の依存関数型を持つことができる。その依存関数型の中で、Foo
は、もし現れるとすれば、「ターゲット」としてのみ現れる。
以下に帰納型の例をいくつか提供する。また、上記のスキームを少し一般化して、相互に定義された帰納型や、いわゆるinductive families(帰納族)についても考える。
論理的結合子と同様、全ての帰納型には導入則と除去則がある。導入則はその型の項の構築方法を示す。除去則は、その型の項を別の項の構築のために「使う」方法を示す。論理的結合子と帰納型の類似性は驚くにはあたらない。なぜなら、後述するように、論理的結合子たちも帰納的な型構築の例だからである。帰納型の導入則とは、すでに見た通りで型の定義で指定されるコンストラクタにすぎない。除去則はその型における再帰の原理を規定するものである。帰納法の原理は再帰の原理の特別な場合である。
次の章では、Leanの関数定義パッケージについて説明する。このパッケージは帰納型上の関数を定義し、帰納的証明を実行するためのさらに便利な方法を提供する。しかし、帰納型の概念は非常に基礎的で重要なため、低レベルで実践的な理解から始めることが重要だと考えている。ここでは、帰納型の基本的な例から始め、より精巧で複雑な例へとステップアップしていく。
Enumerated Types (列挙型)
最も単純な帰納型は、列挙型、すなわち有限個の項を列挙したリストを持つ型である。
inductive Weekday where
| sunday : Weekday
| monday : Weekday
| tuesday : Weekday
| wednesday : Weekday
| thursday : Weekday
| friday : Weekday
| saturday : Weekday
inductive
コマンドは新しい型 Weekday
を作成する。全てのコンストラクタは Weekday
名前空間の中に格納される。
inductive Weekday where
| sunday : Weekday
| monday : Weekday
| tuesday : Weekday
| wednesday : Weekday
| thursday : Weekday
| friday : Weekday
| saturday : Weekday
#check Weekday.sunday
#check Weekday.monday
open Weekday
#check sunday
#check monday
帰納型 Weekday
を宣言する際は、: Weekday
を省略できる。
inductive Weekday where
| sunday
| monday
| tuesday
| wednesday
| thursday
| friday
| saturday
sunday
、monday
、...、saturday
は Weekday
の互いに異なる項であり、それ以外に互いに異なる項はないと考えてほしい。除去則 Weekday.rec
は 型 Weekday
とそのコンストラクタを用いて定義されている。除去則はrecursor(再帰子)とも呼ばれ、この型をinductive(帰納的)にしている: 除去則は、各コンストラクタに対応する値を割り当てることで、Weekday
上の関数を定義することを可能にしている。直感的には、帰納型の項は各コンストラクタによって網羅的に生成され、帰納型はコンストラクタが構築する項以外の項を持たないということである。
Weekday
から自然数への関数を定義するには、match
式を使うことができる:
inductive Weekday where
| sunday : Weekday
| monday : Weekday
| tuesday : Weekday
| wednesday : Weekday
| thursday : Weekday
| friday : Weekday
| saturday : Weekday
open Weekday
def numberOfDay (d : Weekday) : Nat :=
match d with
| sunday => 1
| monday => 2
| tuesday => 3
| wednesday => 4
| thursday => 5
| friday => 6
| saturday => 7
#eval numberOfDay Weekday.sunday -- 1
#eval numberOfDay Weekday.monday -- 2
#eval numberOfDay tuesday -- 3
match
式は帰納型を宣言したときに生成された再帰子 Weekday.rec
を使ってコンパイルされることに注意してほしい。
inductive Weekday where
| sunday : Weekday
| monday : Weekday
| tuesday : Weekday
| wednesday : Weekday
| thursday : Weekday
| friday : Weekday
| saturday : Weekday
open Weekday
def numberOfDay (d : Weekday) : Nat :=
match d with
| sunday => 1
| monday => 2
| tuesday => 3
| wednesday => 4
| thursday => 5
| friday => 6
| saturday => 7
set_option pp.all true -- 詳細な情報を表示させるオプション
#print numberOfDay
-- 定義中に``numberOfDay.match_1``というものが現れている
#print numberOfDay.match_1
-- 定義中に``Weekday.casesOn``というものが現れている
#print Weekday.casesOn
-- 定義中に``Weekday.rec``というものが現れている
#check @Weekday.rec
/-
@Weekday.rec.{u}
: {motive : Weekday → Sort u} →
motive Weekday.sunday →
motive Weekday.monday →
motive Weekday.tuesday →
motive Weekday.wednesday →
motive Weekday.thursday →
motive Weekday.friday →
motive Weekday.saturday →
(t : Weekday) → motive t
-/
帰納データ型を宣言するとき、deriving Repr
と書くと、Weekday
の項をテキストに変換する関数を生成するようLeanに指示することができる。#eval
コマンドはこの関数を使って Weekday
の項を表示する。
inductive Weekday where
| sunday
| monday
| tuesday
| wednesday
| thursday
| friday
| saturday
deriving Repr
open Weekday
#eval tuesday -- Weekday.tuesday (``deriving Repr`` を外すとエラーになる)
ある帰納型に関連する定義や定理を、型と同じ名前の名前空間にまとめると便利なことが多い。例えば、numberOfDay
関数を Weekday
名前空間に置くとよい。そうすると、名前空間 Weekday
を開いたときに、コンストラクタにも定義・定理・関数にも短い名前を用いてアクセスすることができる。
Weekday
から Weekday
への関数を定義することができる:
inductive Weekday where
| sunday : Weekday
| monday : Weekday
| tuesday : Weekday
| wednesday : Weekday
| thursday : Weekday
| friday : Weekday
| saturday : Weekday
deriving Repr
namespace Weekday
def next (d : Weekday) : Weekday :=
match d with
| sunday => monday
| monday => tuesday
| tuesday => wednesday
| wednesday => thursday
| thursday => friday
| friday => saturday
| saturday => sunday
def previous (d : Weekday) : Weekday :=
match d with
| sunday => saturday
| monday => sunday
| tuesday => monday
| wednesday => tuesday
| thursday => wednesday
| friday => thursday
| saturday => friday
#eval next (next tuesday) -- Weekday.thursday
#eval next (previous tuesday) -- Weekday.tuesday
example : next (previous tuesday) = tuesday :=
rfl
end Weekday
どうすればより一般的な定理「任意のWeekday d
に対して、next (previous d) = d
」が証明できるだろうか。match
を使うと各コンストラクタに対して主張の証明を与えることができる:
inductive Weekday where
| sunday : Weekday
| monday : Weekday
| tuesday : Weekday
| wednesday : Weekday
| thursday : Weekday
| friday : Weekday
| saturday : Weekday
deriving Repr
namespace Weekday
def next (d : Weekday) : Weekday :=
match d with
| sunday => monday
| monday => tuesday
| tuesday => wednesday
| wednesday => thursday
| thursday => friday
| friday => saturday
| saturday => sunday
def previous (d : Weekday) : Weekday :=
match d with
| sunday => saturday
| monday => sunday
| tuesday => monday
| wednesday => tuesday
| thursday => wednesday
| friday => thursday
| saturday => friday
theorem next_previous (d : Weekday) : next (previous d) = d :=
match d with
| sunday => rfl
| monday => rfl
| tuesday => rfl
| wednesday => rfl
| thursday => rfl
| friday => rfl
| saturday => rfl
タクティク証明を使えば、定理 next_previous
の証明はより簡潔になる:
inductive Weekday where
| sunday : Weekday
| monday : Weekday
| tuesday : Weekday
| wednesday : Weekday
| thursday : Weekday
| friday : Weekday
| saturday : Weekday
deriving Repr
namespace Weekday
def next (d : Weekday) : Weekday :=
match d with
| sunday => monday
| monday => tuesday
| tuesday => wednesday
| wednesday => thursday
| thursday => friday
| friday => saturday
| saturday => sunday
def previous (d : Weekday) : Weekday :=
match d with
| sunday => saturday
| monday => sunday
| tuesday => monday
| wednesday => tuesday
| thursday => wednesday
| friday => thursday
| saturday => friday
def next_previous (d : Weekday) : next (previous d) = d := by
cases d <;> rfl
以下の節 Tactics for Inductive Types (帰納型のためのタクティク) では、帰納型を利用するのに特化したタクティクを紹介する。
「型としての命題対応」の下では、定理証明と関数定義の両方に match
を使えることに注目してほしい。言い換えれば、「型としての命題対応」の下では、場合分けによる証明は場合分けによる定義の一種なのである。そこには「定義」されるものが「データ型の項」なのか「命題型の証明」なのかの違いしかない。
Leanのライブラリにおいて、Bool
型は列挙型の一例である。
namespace Hidden
inductive Bool where
| false : Bool
| true : Bool
end Hidden
(この例において、標準ライブラリ内の Bool
と今定義する Bool
の衝突を防ぐために、今定義する Bool
を名前空間 Hidden
の中に置いた。この処置は必須である。なぜなら、Bool
はLeanの標準ライブラリ内の最初のファイル Init/Prelude.lean
の一部であり、Leanが起動したときに自動的にインポートされるからである。)
練習として、「Bool
型の導入則と除去則は何か」を考えるといいだろう。発展問題として、名前空間 Hidden
の中で、自分の手で Bool
型と Bool
型上の演算子(ブーリアン演算子) and
、or
、not
を定義し、それらに関する基本的な恒等式を証明してみることをお勧めする。and
のような2項演算子は match
を使って定義できることに注目してほしい:
namespace Hidden
def and (a b : Bool) : Bool :=
match a with
| true => b
| false => false
end Hidden
同様に、ほとんどの恒等式は適切に match
を使ってから rfl
を使うことで証明できる。
Constructors with Arguments (引数を持つコンストラクタ)
列挙型は、そのコンストラクタが全く引数を取らないという点で帰納型の非常に特殊な例である。一般的に、コンストラクタは入力データ(引数)を使って項を構築することができる。ライブラリ内の直積型と直和型の定義について考えてみよう:
namespace Hidden
inductive Prod (α : Type u) (β : Type v)
| mk : α → β → Prod α β
inductive Sum (α : Type u) (β : Type v) where
| inl : α → Sum α β
| inr : β → Sum α β
end Hidden
この例の中で何が起きているか考えてみよう。直積型は唯一のコンストラクタ Prod.mk
を持ち、これは2つの引数を取る。Prod α β
型の項を引数にとる関数を定義するには、入力が Prod.mk a b
の形であると仮定し、a
と b
を用いて出力を指定する必要がある。この関数定義法は Prod α β
型の項のための2つの射影を定義する際にも使える。標準ライブラリにおいて、記法 α × β
は Prod α β
を表し、(a, b)
は Prod.mk a b
を表すことを覚えてほしい。
namespace Hidden
inductive Prod (α : Type u) (β : Type v)
| mk : α → β → Prod α β
def fst {α : Type u} {β : Type v} (p : Prod α β) : α :=
match p with
| Prod.mk a b => a
def snd {α : Type u} {β : Type v} (p : Prod α β) : β :=
match p with
| Prod.mk a b => b
end Hidden
関数 fst
はペア p
を受け取る。match
は p
をペア Prod.mk a b
として解釈する。2章 Dependent Type Theory (依存型理論)において、これらの定義に出来るだけ強い普遍性を与えるには、型 α
と β
がどの型宇宙に属していてもよいように定義を書く必要がある、と学んだことを思い出してほしい。
以下は match
の代わりに再帰子 Prod.casesOn
を使った例である。
def prod_example (p : Bool × Nat) : Nat :=
Prod.casesOn (motive := fun _ => Nat) p (fun b n => cond b (2 * n) (2 * n + 1))
#eval prod_example (true, 3)
#eval prod_example (false, 3)
引数 motive
は構築したい項の型を指定するために使われる。そして、構築したい項の型は入力データ(この場合はペア)に依存して変わりうるので、motive
は関数である。cond
関数はブール値を用いた条件分岐を実現する: cond b t1 t2
は b
が真なら t1
を返し、そうでなければ t2
を返す。上記の例で、関数 prod_example
はブール値 b
と自然数 n
を取り、b
が真なら 2 * n
を返し、b
が偽なら 2 * n + 1
を返す。
対照的に、直和型は2つのコンストラクタ inl
と inr
("insert left"と"insert right"の略) を持ち、それぞれは1つの(明示的な)引数を取る。Sum α β
型の項を引数にとる関数を定義するには、2つの場合に対応しなければならない: 入力が inl a
の形なら a
を使って出力する値を指定する必要がある。入力が inr b
の形なら b
を使って出力する値を指定する必要がある。
def sum_example (s : Sum Nat Nat) : Nat :=
Sum.casesOn (motive := fun _ => Nat) s
(fun n => 2 * n)
(fun n => 2 * n + 1)
#eval sum_example (Sum.inl 3)
#eval sum_example (Sum.inr 3)
def sum_example2 (s : Sum Nat Nat) : Nat :=
match s with
| Sum.inl a => 2 * a
| Sum.inr b => 2 * b + 1
#eval sum_example2 (Sum.inl 3)
#eval sum_example2 (Sum.inr 3)
この例は1つ前の例に似ている。しかし、この例では sum_example
への入力は暗黙的に inl n
か inr n
のいずれかの形をとる。1つ目の場合なら関数は 2 * n
を返し、2つ目の場合なら関数は 2 * n + 1
を返す。
直積型は引数 α β : Type
を持つことに注意してほしい。これらは Prod
の引数であると同時に、Prod
のコンストラクタの引数の型でもある。引数 α β
がコンストラクタへの後続の引数や戻り値の型から推論できる場合、Leanは引数 α β
が何であるかを特定し、それらを暗黙の引数にする。
Defining the Natural Numbers (自然数を定義する)の節では、帰納型のコンストラクタがその帰納型自体の項を引数として取るときに何が起こるかを説明する。その節で考える例の特徴は、各コンストラクタが当該型の項として既に特定された項のみを引数として取ることである。
複数のコンストラクタを持つ型は「選言的」であることに注意してほしい: Sum α β
の項は inl a
または inr b
の形をしている。複数の引数を取るコンストラクタは「連言的」な情報を提供する: Prod α β
の項 Prod.mk a b
からは a
と b
を抽出することができる。任意の帰納型は、任意個のコンストラクタを持つことができ、各コンストラクタは任意個の引数を取ることができるので、選言的かつ連言的な特徴を持つ可能性がある。
関数定義と同様に、Leanの帰納型定義構文内では、コンストラクタ名とコロンの間に名前付き引数を与えることができる:
namespace Hidden
inductive Prod (α : Type u) (β : Type v) where
| mk (fst : α) (snd : β) : Prod α β
inductive Sum (α : Type u) (β : Type v) where
| inl (a : α) : Sum α β
| inr (b : β) : Sum α β
end Hidden
これらの定義は上で与えた定義と本質的に同じである。
Prod
のように、ただ1つのコンストラクタを持つ型は純粋に連言的である: そのコンストラクタは単に引数のリストを1つのデータにまとめる。このようなデータは後続の要素の型が最初の要素の型に依存することができるタプルと本質的に同じである。このような型はrecord(レコード)あるいはstructure(構造体)と呼ばれる。Leanでは、キーワード structure
を使うと、レコードや構造体の定義とその射影の定義を同時に行うことができる。
namespace Hidden
structure Prod (α : Type u) (β : Type v) where
mk :: (fst : α) (snd : β)
end Hidden
この例では、帰納型 Prod
、そのコンストラクタ mk
、通常のエリミネータ(rec
と recOn
)と射影 fst
と snd
(上で定義したものと同じ)を同時に定義している。
コンストラクタに名前を付けなかった場合、デフォルトのコンストラクタの名前として mk
が使われる。次の例は、RGB値の3つ組として色を保存するレコードを定義する:
structure Color where
(red : Nat) (green : Nat) (blue : Nat)
deriving Repr
def yellow := Color.mk 255 255 0
#print Color.red -- ``structure`` キーワードにより生成された射影関数
#eval Color.red yellow
yellow
の定義は3つの値を持ったレコードを形成する。そして射影 Color.red
は赤の要素を返す。
各フィールドの間に改行を挟めば、括弧の使用を避けることができる。
structure Color where
red : Nat
green : Nat
blue : Nat
deriving Repr
structure
コマンドは代数的構造を定義するときに特に有用である。Leanは代数的構造を扱うための実質的なインフラを提供している。例えば、以下は半群の定義である:
structure Semigroup where
carrier : Type u
mul : carrier → carrier → carrier
mul_assoc : ∀ a b c, mul (mul a b) c = mul a (mul b c)
9章 Structures and Recordsではさらに例を見ていく。
依存直積型 Sigma
は既に紹介した:
namespace Hidden
inductive Sigma {α : Type u} (β : α → Type v) where
| mk : (a : α) → β a → Sigma β
end Hidden
次はライブラリ内にあるさらに2つの帰納型の例である:
namespace Hidden
inductive Option (α : Type u) where
| none : Option α
| some : α → Option α
inductive Inhabited (α : Type u) where
| mk : α → Inhabited α
end Hidden
依存型理論の意味論では、全ての関数は全域関数である。関数型 α → β
または依存関数型 (a : α) → β
の各項は、全ての入力に対して一意の値を持つと想定される。したがって、依存型理論の意味論に部分関数の概念は組み込まれていない。しかし、Option
型を使うと(全域関数を使って)部分関数を表現することができる。Option β
の項は、none
か some b
(ここで、b : β
) の形をとる。したがって、α → Option β
型の項 f
は、α
から β
への部分関数であると考えることができる: 任意の a : α
に対して、f a
は none
か some b
を返す。none
は f a
が「未定義」であることを表す。
Inhabited α
の項は、単に α
の項が存在することの証人となる。後ほど、Leanにおいて Inhabited
がtype class(型クラス)の一例であることを説明する: Leanに適切な基底型がinhabited(有項)であると指示することができる。Leanはその指示に基づいて、他の構築された型が有項であることを自動的に推論することができる。
練習として、α
から β
への部分関数と β
から γ
への部分関数の合成の概念を定義し、それが期待通りの振る舞いをすることを示すことを勧める。また、Bool
と Nat
が有項であること、2つの有項な型の直積型が有項であること、有項な型への関数の型は有項であることを示すことも勧める。
Inductively Defined Propositions (帰納的に定義された命題)
帰納的に定義された型は、一番下の型である Prop
を含め、どの階層の型宇宙にも住むことができる。実際、論理的結合子は次のように定義されている。
namespace Hidden
inductive False : Prop
inductive True : Prop where
| intro : True
inductive And (a b : Prop) : Prop where
| intro : a → b → And a b
inductive Or (a b : Prop) : Prop where
| inl : a → Or a b
| inr : b → Or a b
end Hidden
これらの定義が、すでに見た論理的結合子の導入則と除去則をどのように生み出すのかを考える必要がある。帰納型のエリミネータがその型を除去して何の型を作れるかを規定するルールがある。つまり、どのような型を再帰子の対象とすることができるかを規定するルールがある。大雑把に言えば、Prop
に属する帰納型には、その型を除去することで Prop
に属する他の型しか作ることができないというルールがある。これは、「もし p : Prop
なら、hp : p
は何のデータも持たない」という理解と矛盾しない。しかしながら、Inductive Families (帰納族)の節で説明するように、このルールには小さな例外がある。
存在量化子でさえ帰納的に定義されている:
namespace Hidden
inductive Exists {α : Sort u} (p : α → Prop) : Prop where
| intro (w : α) (h : p w) : Exists p
end Hidden
記法 ∃ x : α, p
は Exists (fun x : α => p)
の糖衣構文であることを思い出してほしい。
False
、True
、And
、Or
の定義は、Empty
、Unit
、Prod
、Sum
の定義と完全に類似している。違いは、最初のグループは Prop
の項を生成し、2番目のグループはある宇宙パラメータ u
に対して Type u
の項を生成することである。同様に、∃ x : α, p
と Σ x : α, p
は類似しているが、前者は Prop
の項で、後者は Type u
の項である。
もうひとつの帰納型 {x : α // p}
に触れる良い機会だろう。これは、∃ x : α, P
と Σ x : α, P
のハイブリッドのようなものである。
namespace Hidden
inductive Subtype {α : Type u} (p : α → Prop) where
| mk : (x : α) → p x → Subtype p
end Hidden
実際には、Leanでは Subtype
は structure
コマンドを使って定義されている:
namespace Hidden
structure Subtype {α : Sort u} (p : α → Prop) where
val : α
property : p val
end Hidden
記法 {x : α // p x}
は Subtype (fun x : α => p x)
の糖衣構文である。これは集合論における部分集合の内包的表記をモデルにしている: {x : α // p x}
は性質 p
を持つ α
の要素全体からなる集合を表す。
Defining the Natural Numbers (自然数を定義する)
これまで見てきた帰納型は「フラット」である: コンストラクタはデータ(引数)を包んである型の項を作り、対応する再帰子はその項を展開してそれに作用する。コンストラクタがそのコンストラクタたちによってまさに今帰納的に定義されている型の項を受け取ると、物事はもっと面白くなる。典型的な例が自然数の型 Nat
である:
namespace Hidden
inductive Nat where
| zero : Nat
| succ : Nat → Nat
end Hidden
Nat
のコンストラクタは2つある。まずは zero : Nat
から始める。コンストラクタ zero
は引数を取らないので、Nat
は最初から項 zero
を持っている。対照的に、コンストラクタ succ
は既に構築された Nat
の項に対してのみ適用できる。これを zero
に適用すると、succ zero : Nat
が得られる。もう一度適用すると succ (succ zero) : Nat
が得られる。直感的には、Nat
はこのようなコンストラクタを持つ「最小の」型であるといえる。このようなコンストラクタを持つことは、Nat
の項は、 zero
から始めて succ
を繰り返し適用することで網羅的に(そして自由に)生成されることを意味する。
以前と同様に、Nat
のための再帰子は、Nat
から任意の型への依存関数 f
、つまりある motive : Nat → Sort u
について (n : Nat) → motive n
の項 f
を定義するように設計されている。再帰子は、入力が zero
の場合と、入力がある n : Nat
について succ n
の形をとる場合の2種類の場合に対応しなければならない。最初のケースでは、先ほどと同じように、適切な型を持つ出力値を指定するだけでよい。2番目のケースはそうはいかない。しかし、2番目のケースでは、再帰子は n
における f
の値がすでに計算されていると想定することができる。その結果、再帰子の次の引数は n
と f n
を用いて f (succ n)
の値を指定する。再帰子 Nat.rec
の型をチェックしてみると、
namespace Hidden
inductive Nat where
| zero : Nat
| succ : Nat → Nat
#check @Nat.rec
end Hidden
次のような表示が得られる:
{motive : Nat → Sort u}
→ motive Nat.zero
→ ((n : Nat) → motive n → motive (Nat.succ n))
→ (t : Nat) → motive t
暗黙の引数 motive
は、定義される関数のコドメインである。型理論では、motive
は除去/再帰のmotive(動機)であると言うのが一般的である。それは motive t
が入力 t
に対して構築したい項の型を表しているからである。motive
の次の2つの引数は、上述した2つの場合、つまり入力が zero
の場合と succ n
の形をとる場合の計算方法をそれぞれ指定する。この2つの引数はminor premises(小前提)としても知られている。最後に、t : Nat
は定義したい関数への入力である。入力 t
はmajor premise(大前提)としても知られている。
Nat.recOn
は Nat.rec
とほとんど同じだが、大前提が小前提の前に現れている。
@Nat.recOn :
{motive : Nat → Sort u}
→ (t : Nat)
→ motive Nat.zero
→ ((n : Nat) → motive n → motive (Nat.succ n))
→ motive t
例えば、自然数に対する加法関数 add m n
を定義することを考えてみよう。m
を固定すると、n
に関する再帰によって加法を定義することができる。base caseでは、add m zero
を m
と定義する。successor stepでは、add m n
の値が既に決定されていると仮定して、add m (succ n)
を succ (add m n)
と定義する。
namespace Hidden
inductive Nat where
| zero : Nat
| succ : Nat → Nat
deriving Repr
def add (m n : Nat) : Nat :=
match n with
| Nat.zero => m
| Nat.succ n => Nat.succ (add m n)
open Nat
#eval add (succ (succ zero)) (succ zero)
end Hidden
このような関数定義は名前空間 Nat
に入れておくと便利である。それから、その名前空間の中でお馴染みの記法 +
を定義することができる。ここまですると、加法に関する2つの等式が成立することが定義により示せる:
namespace Hidden
inductive Nat where
| zero : Nat
| succ : Nat → Nat
deriving Repr
namespace Nat
def add (m n : Nat) : Nat :=
match n with
| Nat.zero => m
| Nat.succ n => Nat.succ (add m n)
instance : Add Nat where
add := add
theorem add_zero (m : Nat) : m + zero = m := rfl
theorem add_succ (m n : Nat) : m + succ n = succ (m + n) := rfl
end Nat
end Hidden
instance
コマンドがどのように機能するかは、10章 Type Classes (型クラス)で説明する。以下の例では、Leanの標準ライブラリで定義された自然数を使う。
上記では rfl
を使うだけで定理が証明できた。しかし、zero + m = m
のような定理は帰納法による証明を必要とする。上述の通り、帰納法の原理は再帰の原理の特殊な場合にすぎない。帰納法において、motive n
のコドメインは一般的な型 Sort u
ではなく Prop
である。これは帰納法による証明のお馴染みのパターンを表現している: ∀ n, motive n
を証明するために、まず motive 0
を証明する。次に任意の n : Nat
について ih : motive n
を仮定し、その上で motive (succ n)
を証明する。
namespace Hidden
open Nat
theorem zero_add (n : Nat) : 0 + n = n :=
Nat.recOn (motive := fun x => 0 + x = x)
n
(show 0 + 0 = 0 from rfl)
(fun (n : Nat) (ih : 0 + n = n) =>
show 0 + succ n = succ n from
calc 0 + succ n
_ = succ (0 + n) := rfl
_ = succ n := by rw [ih])
end Hidden
繰り返しになるが、証明内で Nat.recOn
を使うことは、証明内で帰納法の原理を使うことを意味することに注意してほしい。このような証明において、rewrite
タクティクと simp
タクティクは非常に効果的である。今回の場合、simp
タクティクを使うと次のように証明を短くすることができる:
namespace Hidden
open Nat
theorem zero_add (n : Nat) : 0 + n = n :=
Nat.recOn (motive := fun x => 0 + x = x) n
rfl
(fun n ih => by simp [add_succ, ih])
end Hidden
もう一つの例として、加法の結合性 ∀ m n k, m + n + k = m + (n + k)
を証明しよう。ここで、+
という記法は左結合的なので、m + n + k
とは (m + n) + k
のことである。一番難しいのは、どの変数に関して帰納法を行うかの見当をつけることだ。加法は2番目の引数に関する再帰によって定義されるため、k
を選ぶのはよい判断である。一度変数を選択すれば、証明はほとんど自ずと導かれる:
namespace Hidden
open Nat
theorem add_assoc (m n k : Nat) : m + n + k = m + (n + k) :=
Nat.recOn (motive := fun k => m + n + k = m + (n + k)) k
(show m + n + 0 = m + (n + 0) from rfl)
(fun k (ih : m + n + k = m + (n + k)) =>
show m + n + succ k = m + (n + succ k) from
calc m + n + succ k
_ = succ (m + n + k) := rfl
_ = succ (m + (n + k)) := by rw [ih]
_ = m + succ (n + k) := rfl
_ = m + (n + succ k) := rfl)
end Hidden
再び、simp
タクティクを使って証明を短くすることができる:
open Nat
theorem add_assoc (m n k : Nat) : m + n + k = m + (n + k) :=
Nat.recOn (motive := fun k => m + n + k = m + (n + k)) k
rfl
(fun k ih => by simp [Nat.add_succ, ih])
次は加法の可換性を証明してみよう。2番目の引数に関して帰納法を行うことを選択すると、次のように証明を始めることができる:
open Nat
theorem add_comm (m n : Nat) : m + n = n + m :=
Nat.recOn (motive := fun x => m + x = x + m) n
(show m + 0 = 0 + m by rw [Nat.zero_add, Nat.add_zero])
(fun (n : Nat) (ih : m + n = n + m) =>
show m + succ n = succ n + m from
calc m + succ n
_ = succ (m + n) := rfl
_ = succ (n + m) := by rw [ih]
_ = succ n + m := sorry)
ここで、もう1つの補題 succ n + m = succ (n + m)
が必要であるとわかる。これは m
に関する帰納法で証明できる:
open Nat
theorem succ_add (n m : Nat) : succ n + m = succ (n + m) :=
Nat.recOn (motive := fun x => succ n + x = succ (n + x)) m
(show succ n + 0 = succ (n + 0) from rfl)
(fun (m : Nat) (ih : succ n + m = succ (n + m)) =>
show succ n + succ m = succ (n + succ m) from
calc succ n + succ m
_ = succ (succ n + m) := rfl
_ = succ (succ (n + m)) := by rw [ih]
_ = succ (n + succ m) := rfl)
それから、上記の証明の sorry
を succ_add
に置き換えて、証明を完結させることができる。再び、これらの証明は短くできる:
namespace Hidden
open Nat
theorem succ_add (n m : Nat) : succ n + m = succ (n + m) :=
Nat.recOn (motive := fun x => succ n + x = succ (n + x)) m
rfl
(fun m ih => by simp only [add_succ, ih])
theorem add_comm (m n : Nat) : m + n = n + m :=
Nat.recOn (motive := fun x => m + x = x + m) n
(by simp)
(fun m ih => by simp [add_succ, succ_add, ih])
end Hidden
Other Recursive Data Types (他の再帰データ型)
帰納的に定義された型の例をもう少し考えてみよう。任意の型 α
に対して、いくつかの α
の項からなるリストの型 List α
がライブラリで定義されている。
namespace Hidden
inductive List (α : Type u) where
| nil : List α
| cons : α → List α → List α
namespace List
def append (as bs : List α) : List α :=
match as with
| nil => bs
| cons a as => cons a (append as bs)
theorem nil_append (as : List α) : append nil as = as :=
rfl
theorem cons_append (a : α) (as bs : List α)
: append (cons a as) bs = cons a (append as bs) :=
rfl
end List
end Hidden
α
型の項を要素に持つリストは、空リスト nil
か cons h t
の形をとるかのいずれかである。ここで、cons h t
は項 h : α
の後にリスト t : List α
が続くリストである。最初の要素 h
は一般にリストの"head"と呼ばれ、残りの要素 t
は"tail"と呼ばれる。
練習問題として、以下の定理を証明せよ:
namespace Hidden
inductive List (α : Type u) where
| nil : List α
| cons : α → List α → List α
namespace List
def append (as bs : List α) : List α :=
match as with
| nil => bs
| cons a as => cons a (append as bs)
theorem nil_append (as : List α) : append nil as = as :=
rfl
theorem cons_append (a : α) (as bs : List α)
: append (cons a as) bs = cons a (append as bs) :=
rfl
theorem append_nil (as : List α) : append as nil = as :=
sorry
theorem append_assoc (as bs cs : List α)
: append (append as bs) cs = append as (append bs cs) :=
sorry
end List
end Hidden
また、リストの長さを返す関数 length : {α : Type u} → List α → Nat
を定義せよ。さらにそれが期待通りに振る舞うことを証明せよ。例えば、length (append as bs) = length as + length bs
を証明せよ。
別の例として、binary trees((全)二分木)の型を定義することができる:
/- 1. ただ1つの頂点からなる有向グラフは全二分木である。
2. 次数0の頂点vと2つの全二分木A,Bを用意し、
ラベル付き有向辺(左,v,Aの根)と(右,v,Bの根)を追加し、vを根としたものは全二分木である。
3. 以上の手続きで全二分木だとわかるものだけが全二分木である。 -/
inductive BinaryTree where
| leaf : BinaryTree
| root (left : BinaryTree) (right : BinaryTree) : BinaryTree
countably branching trees((全)可算無限分木)の型を定義することさえできる:
/- 1. ただ1つの頂点からなる有向グラフは全可算無限分木である。
2. 次数0の頂点vと2つの全可算無限分木T_0,T_1,...を用意し、
ラベル付き有向辺(0,v,T_0の根),(1,v,T_1の根),...を追加し、vを根としたものは全可算無限分木である。
3. 以上の手続きで全可算無限分木だとわかるものだけが全可算無限分木である。 -/
inductive CBTree where
| leaf : CBTree
| sup : (Nat → CBTree) → CBTree
namespace CBTree
def succ (t : CBTree) : CBTree :=
sup (fun _ => t)
def toCBTree : Nat → CBTree
| 0 => leaf
| n+1 => succ (toCBTree n)
def omega : CBTree :=
sup toCBTree
end CBTree
Tactics for Inductive Types (帰納型のためのタクティク)
Leanにおける帰納型の基礎的な重要性を鑑みれば、帰納型を効率的に扱うためにデザインされたタクティクが数多くあることは驚くことではない。ここではそのようなタクティクのいくつかを紹介する。
cases
タクティクは帰納的に定義された型の項に対して機能し、その名前が示す通りの働きをする: cases
タクティクは存在する各コンストラクタに従って項を分解する。最も基本的な形では、cases
タクティクは現在のコンテキスト内の項 x
に適用される。そして、x
を各コンストラクタで置換して、ゴールを場合分けする。
example (p : Nat → Prop) (hz : p 0) (hs : ∀ n, p (Nat.succ n)) : ∀ n, p n := by
intro n
cases n
. exact hz -- goal is p 0
. apply hs -- goal is a : Nat ⊢ p (succ a)
さらに便利な機能がある。まず、with
キーワードを使うと、cases
タクティク使用時の各場合分けにおいて変数に名前を付けることができる。次の例では、succ
の引数に m
という名前を付け、2番目の場合で n
を succ m
で置換するように指示している。さらに重要なのは、cases
タクティクが、現在のコンテキスト内にある、場合分け対象の変数に依存する型を持つ項を検出することである。cases
タクティクはこれらの項をゴールのターゲットに戻し、ゴールを場合分けし、再び項をゴールのコンテキストに導入する。次の例では、仮説 h : n ≠ 0
が最初の分岐では h : 0 ≠ 0
になり、2番目の分岐では h : succ m ≠ 0
になることに注目してほしい。
open Nat
example (n : Nat) (h : n ≠ 0) : succ (pred n) = n := by
cases n with
| zero =>
-- goal: h : 0 ≠ 0 ⊢ succ (pred 0) = 0
apply absurd rfl h
| succ m =>
-- second goal: h : succ m ≠ 0 ⊢ succ (pred (succ m)) = succ m
rfl
cases
は命題を証明するだけでなく、データを生成するためにも使用できることに注目してほしい。
def f (n : Nat) : Nat := by
cases n; exact 3; exact 7
example : f 0 = 3 := rfl
example : f 5 = 7 := rfl
繰り返しになるが、cases x
はコンテキスト内にある x
に依存する型を持つ項をターゲットに戻し、ゴールを場合分けし、再び項をコンテキストに導入する。
def Tuple (α : Type) (n : Nat) :=
{ as : List α // as.length = n }
def f {n : Nat} (t : Tuple α n) : Nat := by
cases n; exact 3; exact 7
def myTuple : Tuple Nat 3 :=
⟨[0, 1, 2], rfl⟩
example : f myTuple = 7 :=
rfl
次は引数をとる複数のコンストラクタを持つ帰納型における例である。
inductive Foo where
| bar1 : Nat → Nat → Foo
| bar2 : Nat → Nat → Nat → Foo
def silly (x : Foo) : Nat := by
cases x with
| bar1 a b => exact b
| bar2 c d e => exact e
#eval silly (Foo.bar1 1 2) -- 2
#eval silly (Foo.bar2 3 4 5) -- 5
各コンストラクタによって生成された各ゴールは、帰納型の定義におけるコンストラクタの宣言順に解く必要はない。
inductive Foo where
| bar1 : Nat → Nat → Foo
| bar2 : Nat → Nat → Nat → Foo
def silly (x : Foo) : Nat := by
cases x with
| bar2 c d e => exact e
| bar1 a b => exact b
with
の構文は構造化された証明を書くのに便利である。また、Leanは cases
を補完する case
タクティクを提供する。これを使うと、各ケースで変数名を割り当てることに集中することができる。
inductive Foo where
| bar1 : Nat → Nat → Foo
| bar2 : Nat → Nat → Nat → Foo
def silly (x : Foo) : Nat := by
cases x
case bar1 a b => exact b
case bar2 c d e => exact e
case
タクティクは賢く、各コンストラクタを適切なゴールにマッチさせることができる。例えば、上記のゴールを逆順で埋めることができる:
inductive Foo where
| bar1 : Nat → Nat → Foo
| bar2 : Nat → Nat → Nat → Foo
def silly (x : Foo) : Nat := by
cases x
case bar2 c d e => exact e
case bar1 a b => exact b
さらに、任意の式 e
に対して cases e
を使うこともできる。もしその式がゴールのターゲットに現れるなら、cases
タクティクはその式を一般化し、その結果生じる全称量化された変数を導入し、その上でゴールを場合分けする。
open Nat
example (p : Nat → Prop) (hz : p 0) (hs : ∀ n, p (succ n)) (m k : Nat)
: p (m + 3 * k) := by
cases m + 3 * k
. exact hz -- goal is p 0
. apply hs -- goal is a : Nat ⊢ p (succ a)
これは、「(m + 3 * k
は自然数なので、)m + 3 * k
が0か、ある数の後者かで場合分けする」と言っているのだと考えてほしい。この結果は機能的には次の例と等価である:
open Nat
example (p : Nat → Prop) (hz : p 0) (hs : ∀ n, p (succ n)) (m k : Nat)
: p (m + 3 * k) := by
generalize m + 3 * k = n
cases n
. exact hz -- goal is p 0
. apply hs -- goal is a : Nat ⊢ p (succ a)
式 m + 3 * k
は generalize
によって消去されることに注意してほしい; 重要なのは、それが 0
か succ a
のどちらの形式を持つかだけである。 この使い方の場合、cases
は等式(この場合は m + 3 * k = n
)中の項について言及している仮説をゴールのターゲットに戻さない。そのような仮説がコンテキストの中にあり、それについても一般化したい場合は、明示的にそれを revert
で戻す必要がある。
場合分けの対象となる式がゴールのターゲット内にない場合、cases
タクティクは have
を使い、分解後の式を型として持つ項をコンテキストに導入する。以下はその例である:
example (p : Prop) (m n : Nat)
(h₁ : m < n → p) (h₂ : m ≥ n → p) : p := by
cases Nat.lt_or_ge m n
case inl hlt => exact h₁ hlt
case inr hge => exact h₂ hge
Nat.lt_or_ge m n
とは m < n ∨ m ≥ n
の証明項であり、上の証明はこの2つの場合に分かれると考えるのが自然である。最初の分岐では仮説 hlt : m < n
を持つ。2番目の分岐では仮説 hge : m ≥ n
を持つ。上の証明は、機能的には次と等価である:
example (p : Prop) (m n : Nat)
(h₁ : m < n → p) (h₂ : m ≥ n → p) : p := by
have h : m < n ∨ m ≥ n := Nat.lt_or_ge m n
cases h
case inl hlt => exact h₁ hlt
case inr hge => exact h₂ hge
have
タクティクにより仮説 h : m < n ∨ m ≥ n
が得られるので、その仮説に対して cases
を適用する。
次の例では、自然数における等式の決定可能性を利用して、m = n
と m ≠ n
の場合に分けて証明する。
#check Nat.sub_self
theorem t1 (m n : Nat) : m - n = 0 ∨ m ≠ n := by
cases Decidable.em (m = n) with
| inl heq => rw [heq]; apply Or.inl; exact Nat.sub_self n
| inr hne => apply Or.inr; exact hne
/- ``Decidable.em`` は排中律 ``Classical.em`` を必要としない -/
#print axioms t1 -- 't1' does not depend on any axioms
open Classical
を使えば、任意の命題に対して排中律 em
が使えることを思い出してほしい。しかし、型クラス推論(10章 Type Classes (型クラス)を参照)を使えば、Leanは排中律に似て非なる決定手続きを見つけることができる。つまり、計算可能関数において場合分け p ∨ ¬p
を使うことができるのである。
cases
タクティクが場合分けによる証明を行うのに使えるように、induction
タクティクは帰納法による証明を行うのに使える。induction
タクティクの構文は cases
タクティクの構文と似ているが、前者は引数が現在のコンテキストの項でなければならない点が異なる。以下はその例である:
namespace Hidden
theorem zero_add (n : Nat) : 0 + n = n := by
induction n with
| zero => rfl
| succ n ih => rw [Nat.add_succ, ih]
end Hidden
cases
と同様に、induction
タクティクでは with
キーワードの代わりに case
タクティクを使うことができる。
namespace Hidden
theorem zero_add (n : Nat) : 0 + n = n := by
induction n
case zero => rfl
case succ n ih => rw [Nat.add_succ, ih]
end Hidden
以下に例を追加する:
namespace Hidden
theorem add_zero (n : Nat) : n + 0 = n := Nat.add_zero n
open Nat
theorem zero_add (n : Nat) : 0 + n = n := by
induction n <;> simp [*, add_zero, add_succ]
theorem succ_add (m n : Nat) : succ m + n = succ (m + n) := by
induction n <;> simp [*, add_zero, add_succ]
theorem add_comm (m n : Nat) : m + n = n + m := by
induction n <;> simp [*, add_zero, add_succ, succ_add, zero_add]
theorem add_assoc (m n k : Nat) : m + n + k = m + (n + k) := by
induction k <;> simp [*, add_zero, add_succ]
end Hidden
induction
タクティクは複数の引数(大前提)をとるユーザー定義の帰納法原理もサポートしている。
/-
theorem Nat.mod.inductionOn
{motive : Nat → Nat → Sort u}
(x y : Nat)
(ind : ∀ x y, 0 < y ∧ y ≤ x → motive (x - y) y → motive x y)
(base : ∀ x y, ¬(0 < y ∧ y ≤ x) → motive x y)
: motive x y :=
-/
example (x : Nat) {y : Nat} (h : y > 0) : x % y < y := by
induction x, y using Nat.mod.inductionOn with
| ind x y h₁ ih =>
rw [Nat.mod_eq_sub_mod h₁.2]
exact ih h
| base x y h₁ =>
have : ¬ 0 < y ∨ ¬ y ≤ x := Iff.mp (Decidable.not_and_iff_or_not ..) h₁
match this with
| Or.inl h₁ => exact absurd h h₁
| Or.inr h₁ =>
have hgt : y > x := Nat.gt_of_not_le h₁
rw [← Nat.mod_eq_of_lt hgt] at hgt
assumption
タクティク証明の中で match
を使うこともできる:
example : p ∨ q → q ∨ p := by
intro h
match h with
| Or.inl _ => apply Or.inr; assumption
| Or.inr h2 => apply Or.inl; exact h2
便利なことに、パターンマッチングは intro
や funext
のようなタクティクに統合されている。
example : s ∧ q ∧ r → p ∧ r → q ∧ p := by
intro ⟨_, ⟨hq, _⟩⟩ ⟨hp, _⟩
exact ⟨hq, hp⟩
example :
(fun (x : Nat × Nat) (y : Nat × Nat) => x.1 + y.2)
=
(fun (x : Nat × Nat) (z : Nat × Nat) => z.2 + x.1) := by
funext (a, b) (c, d)
show a + d = d + a
rw [Nat.add_comm]
最後に、injection
タクティクを紹介してセクションを閉じる。このタクティクは帰納型を扱いやすくするためにデザインされている。Leanの設計上、帰納型の項は自由に生成される。つまり、各コンストラクタは単射であり、各コンストラクタの値域は互いに交わりを持たない。injection
タクティクはこの事実を利用するようにデザインされている:
open Nat
example (m n k : Nat) (h : succ (succ m) = succ (succ n))
: n + k = m + k := by
injection h with h'
injection h' with h''
rw [h'']
証明の1行目は仮説 h' : succ m = succ n
をコンテキストに追加し、証明の2行目は仮説 h'' : m = n
をコンテキストに追加する。
また、injection
タクティクは「異なるコンストラクタ(あるいは異なる入力を受けたコンストラクタ)の出力が互いに等しい」としたときに生じる矛盾を検出し、その矛盾を使ってゴールを閉じる。
open Nat
example (m n : Nat) (h : succ m = 0) : n = n + 7 := by
injection h
example (m n : Nat) (h : succ m = 0) : n = n + 7 := by
contradiction
example (h : 7 = 4) : False := by
contradiction
2番目の例は、contradiction
タクティクもこの形の矛盾を検出することを示している。
Inductive Families (帰納族)
Leanが受け入れる帰納的定義のほとんど全てを説明し終えた。ここまでの説明で、Leanでは任意個の再帰的コンストラクタを持つ帰納型を導入できることが分かった。実際、今までの知識を応用して、これから説明する方法を使うと、ただ1つの帰納的定義を使って帰納型の添字付きfamily(族)を導入することもできる。
帰納族とは、次のような形の1つの帰納的定義によって同時に定義される、添字を持つ型の族である:
inductive foo : ... → Sort u where
| constructor₁ : ... → foo ...
| constructor₂ : ... → foo ...
...
| constructorₙ : ... → foo ...
ある Sort u
の項を構築する通常の帰納的定義に対して、より一般的な帰納的定義は関数 ... → Sort u
を構築する。ここで、...
はindices(添字)とも呼ばれる引数の型の列を表す。そして、各コンストラクタは族のいくつかの要素を構築する。その一例が Vector α n
の定義である。これは α
の項を要素に持つ長さ n
のベクトルの型である:
namespace Hidden
inductive Vector (α : Type u) : Nat → Type u where
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
end Hidden
cons
コンストラクタは α
と Vector α n
の項を取り、Vector α (n+1)
の項を返す。これにより、族の1つの要素(型)の項を利用して別の要素(型)の項を構築することができる。
より風変わりな例は、Leanにおける等式型の定義である:
namespace Hidden
inductive Eq {α : Sort u} (a : α) : α → Prop where
| refl : Eq a a
end Hidden
固定された各 α : Sort u
と各 a : α
に対して、この定義は x : α
を添字とする型の族 Eq a x
を構築する。しかし、注目すべきは、族 Eq a x
には Eq a a
の項を構築するただ1つのコンストラクタ refl
しかないことである。直感的には、Eq a x
の証明を構築するには、x
が a
である場合に反射律を使うしかないと言い換えることができる。Eq a a
は型の族 Eq a x
の中で唯一の有項型であることに注意してほしい。
Leanにより生成された等号の除去則は次の通り:
universe u v
#check (@Eq.rec : {α : Sort u} → {a : α} → {motive : (x : α) → a = x → Sort v}
→ motive a rfl → {b : α} → (h : a = b) → motive b h)
コンストラクタ refl
とエリミネータ Eq.rec
だけから等式の基本的な公理の全てが導かれるのは驚くべき事実である。ただし、節 Axiomatic Details (公理の詳細) で説明するように、等式の定義は非典型的である。
再帰子 Eq.rec
は代入の定義にも使われる:
namespace Hidden
theorem subst {α : Type u} {a b : α} {p : α → Prop} (h₁ : Eq a b) (h₂ : p a) : p b :=
Eq.rec (motive := fun x _ => p x) h₂ h₁
end Hidden
match
を使って subst
を定義(証明)することもできる。
namespace Hidden
theorem subst {α : Type u} {a b : α} {p : α → Prop} (h₁ : Eq a b) (h₂ : p a) : p b :=
match h₁ with
| rfl => h₂
end Hidden
実際、Leanは Eq.rec
に基づいた定義を用いて match
式をコンパイルする。
namespace Hidden
theorem subst {α : Type u} {a b : α} {p : α → Prop} (h₁ : Eq a b) (h₂ : p a) : p b :=
match h₁ with
| rfl => h₂
set_option pp.all true
#print subst
-- ... subst.match_1 ...
#print subst.match_1
-- ... Eq.casesOn ...
#print Eq.casesOn
-- ... Eq.rec ...
end Hidden
h₁ : a = b
に対して再帰子 Eq.rec
または match
を使うと、a
と b
が同じだと仮定することができる。その下で、p b
と p a
は同じである。
Eq
が対称的かつ推移的であることを証明するのは難しくない。以下の例では、等号の対称性 symm
を示す。推移性 trans
とcongruence(合同性) congr
の証明は練習問題として残す。
namespace Hidden
theorem symm {α : Type u} {a b : α} (h : Eq a b) : Eq b a :=
match h with
| rfl => rfl
theorem trans {α : Type u} {a b c : α} (h₁ : Eq a b) (h₂ : Eq b c) : Eq a c :=
sorry
theorem congr {α β : Type u} {a b : α} (f : α → β) (h : Eq a b) : Eq (f a) (f b) :=
sorry
end Hidden
型理論の研究においては、帰納的定義の更なる一般化が存在する。例えば、induction-recursion と induction-induction の原理がある。これらはLeanではサポートされていない。
Axiomatic Details (公理の詳細)
これまで、例を通して帰納型とその構文について説明してきた。この節では、公理的な基礎に興味のある人のために、追加の情報を提供する。
帰納型のコンストラクタは、parameters(パラメータ, 帰納的な構築の間、固定されたままの引数)とindices(添字, 同時進行で構築される型の族をパラメータ化する引数)を取りうることを見てきた。各コンストラクタは、型を持つ必要がある。ここで、コンストラクタの引数の型は既に定義された型、パラメータと添字の型、現在定義中の帰納族の要素だけから構成される必要がある。コンストラクタの引数の型に現在定義中の帰納族の要素が現れる場合、それはstrictly positivelyに出現しなければならないという要件がある。これは単純に、任意のコンストラクタの任意の引数の型は、定義中の帰納型が結果としてのみ出現する依存関数型でなければならないことを意味する。ここで、添字は定数と前の引数を使って与えられる。
帰納型はある u
が存在して Sort u
の項なので、当該帰納型を項としてみたとき、その項が持つ型宇宙としてどの宇宙レベル u
がふさわしいかを問うのは合理的である。帰納族 C
の定義内の各コンストラクタ c
は以下の形式をとる。
c : (a : α) → (b : β[a]) → C a p[a,b]
ここで、a
はデータ型パラメータの列、b
はコンストラクタへの引数の列、p[a, b]
は添字である。この添字 p[a, b]
によって、そのコンストラクタが帰納族のどの要素の項を構築するかが決まる。(この説明はやや不正確である。実際には、コンストラクタへの引数は、依存関係が意味をなす限りどのような順番で現れてもよい。)C
の宇宙レベルは、その帰納型が Prop
(つまり、Sort 0
)に属するように指定されているかどうかによって、2種類の制約を持つ。
まず、帰納型が Prop
に属するように指定されていない場合を考えよう。このとき、宇宙レベル u
は次を満たすように制約される:
上記の各コンストラクタ
c
と、列β[a]
内の各βk[a]
に対して、もしβk[a] : Sort v
なら、u
≥v
である。
言い換えれば、当該帰納型の宇宙レベル u
は、各コンストラクタの各引数の型の宇宙レベル以上であることが要求される。
帰納型が Prop
に属するように指定されている場合、コンストラクタの引数の型の宇宙レベルには制約がない。しかし、コンストラクタの引数の型の宇宙レベルは除去則に影響を与える。一般的に、Prop
に属する帰納型の場合、除去則のmotive(動機)の型は Prop
に属することが要求される。
この最後のルールには例外がある: コンストラクタが1つしかなく、コンストラクタの各引数が Prop
の項あるいは添字である場合、除去則によって帰納的に定義された Prop
を除去して任意の Sort
の項を作ることが許される。この場合、直感的には、エリミネータは引数の型が有項であるという事実なしに引数の情報を利用することはない、と言うことができる。この特別なケースはsingleton elimination(シングルトン除去)として知られている。
帰納的に定義された等式型のエリミネータ Eq.rec
の実用例で、シングルトン除去が活躍するのをすでに見てきた。p a
と p b
が Prop
に限らない任意の型を持つ場合でも、項 h : Eq a b
を使って項 t' : p a
を p b
にキャストすることができる。なぜなら、このキャストは新しいデータを生成せず、すでに持っているデータを再解釈するだけだからだ。シングルトン除去はheterogeneous equality(異型等式)やwell-founded recursion(整礎再帰)でも使われるが、これについては次章のWell-Founded Recursion and Induction (整礎再帰と整礎帰納法)の節で説明する。
Mutual and Nested Inductive Types (相互帰納型と入れ子帰納型)
ここで、帰納型を一般化して得られるしばしば便利な2つの型の概念Mutual Inductive Types(相互帰納型)とNested Inductive Types(入れ子帰納型)について考えよう。Leanは、これらを上で説明したようなよりプリミティブな種類の帰納型に「コンパイル」することでこの2つの概念をサポートする。言い換えると、Leanはより一般的な定義を構文解析し、それに基づいて補助的な帰納型を定義し、その補助的な型を使って本当に必要なものを定義する。これらの型を効果的に利用するためには、次の章で説明するLeanの等式コンパイラが必要である。それでも、これらの型の宣言は通常の帰納的定義の簡単な変化形であるため、ここで説明することに意味がある。
まず、Leanはmutually defined(相互に定義された)帰納型をサポートする。これは、同時に定義される、それぞれが定義中の他の型を参照する帰納型たちである。
mutual
inductive Even : Nat → Prop where
| even_zero : Even 0
| even_succ : (n : Nat) → Odd n → Even (n + 1)
inductive Odd : Nat → Prop where
| odd_succ : (n : Nat) → Even n → Odd (n + 1)
end
open Even Odd
example : Even 2 := even_succ 1 (odd_succ 0 even_zero)
この例では、2つの帰納型が同時に定義されている: 自然数 n
は、0
であるか奇数 Odd
より1大きいときは偶数 Even
であり、偶数 Even
より1大きいときは奇数 Odd
である。以下の練習問題では、その詳細を記述せよ。
相互帰納型は、α
の項でラベル付けされた頂点を持つ有限木を定義するのにも使える:
mutual
inductive Tree (α : Type u) where
| node : α → TreeList α → Tree α
inductive TreeList (α : Type u) where
| nil : TreeList α
| cons : Tree α → TreeList α → TreeList α
end
この定義により、α
の項 a
と、空であってもよい部分木のリストを与えることで、a
を根とする Tree α
の項を構築することができる。部分木のリストは型 TreeList α
の項として表現され、これは空リスト nil
か、Tree α
の項と TreeList α
の項の cons
のいずれかであると定義される。
しかしながら、この Tree
と TreeList
の定義は扱いにくい。特にLeanのライブラリにはリストを扱うための関数や定理が数多く含まれているため、部分木のリストが型 List (Tree α)
の項として与えられたら、これらの定義はもっと扱いやすくなるだろう。型 TreeList α
が List (Tree α)
と「同型」であることを示すことはできる。しかし、この同型に沿って片方で得られた結果を他方へ翻訳するのは面倒である。
実際、Leanでは本当に必要としている帰納型を定義することができる:
inductive Tree (α : Type u) where
| mk : α → List (Tree α) → Tree α
これはnested inductive type(入れ子帰納型)として知られている。この Tree
の定義は前節で示した帰納型の厳密な仕様から外れている。なぜなら、mk
の引数の型の中で、Tree
はstrictly positivelyに現れず、List
型コンストラクタの中に入れ子になっているからである。Leanはカーネルの中で TreeList α
と List (Tree α)
の間に同型を構築し、その同型の観点からこの入れ子帰納型 Tree
のコンストラクタを定義する。
Exercises (練習問題)
-
自然数に対する他の演算、例えば乗法、前者関数(
pred 0 = 0
とする)、切り捨て減法(m
がn
以上のときn - m = 0
)、べき乗などを定義してみよ。次に、既に証明した定理を基に、それらの基本的な性質をいくつか証明してみよ。それらの多くはLeanのコアライブラリで既に定義されている。名前衝突を避けるため、
Hidden
のような名前の名前空間の中で作業することを勧める。 -
リストに関する
length
関数やreverse
関数のような操作をいくつか定義せよ。それらについて、次のような性質をいくつか証明せよ。a.
length (s ++ t) = length s + length t
b.
length (reverse t) = length t
c.
reverse (reverse t) = t
-
以下のコンストラクタから構築される項からなる帰納データ型を定義せよ:
const n
, 自然数n
を表す定数var n
,n
番目の変数plus s t
,s
とt
の和を表すtimes s t
,s
とt
の積を表す
今定義した型の項を評価する関数を再帰的に定義せよ。ただし、変数には値を割り当てることができるとする。
-
同様に、命題論理式の型と、その型に関する関数を定義せよ: 例えば、評価関数、式の複雑さを測る関数、与えられた変数に別の式を代入する関数などを定義せよ。
Induction and Recursion (帰納と再帰)
前章では、帰納的定義がLeanに新しい型を導入する強力な手段となることを説明した。さらに言えば、コンストラクタと再帰子(エリミネータ)は、帰納型から他の型への関数を定義する唯一の手段である。型としての命題対応により、この事実は帰納法が証明の基本的な方法であることを意味する。
Leanは再帰関数の定義、パターンマッチングの実行、帰納的証明の記述に対して自然な方法を提供する。関数を定義するには、その関数が満たすべき等式を指定する。定理を証明するには、起こりうる全てのケースをどのように扱うかを指定する。裏では、これらの記述はequation compiler(等式コンパイラ)と呼ばれるものを用いて、プリミティブな再帰子へと「コンパイル」される。等式コンパイラはtrusted code base(システムの信頼性保証において最も基礎的で重要なコード)の一部ではない。等式コンパイラの出力はカーネルによって独立にチェックされる項で構成される。
用語に関する注意
この節は翻訳に際して追加した節である。
この章では、次のような定義
open Nat
def foo : Nat → Nat → Nat
| zero , zero => 0
| zero , succ y => 1
| succ x, zero => 2
| succ x, succ y => 3
があるとき、zero
や succ y
などを「パターン(pattern)」、| zero , zero
などを「ケース(case)」、定義として与えられたケース全てをまとめたものを「ケースリスト(list of cases)」、不足なくケースが与えられたケースリストを用いてパターンマッチングすることを「場合分け(by cases)」あるいは「場合分けする(split on cases)」と呼ぶ。
Pattern Matching (パターンマッチング)
schematic patternsの解釈は、コンパイルの最初のステップである。帰納型のコンストラクタと casesOn
再帰子を使って、関数を定義したり、場合分けによる定理の証明が行えることを見てきた。しかし、複雑な定義は、入れ子になった casesOn
適用をいくつも使うことがあり、そのような記述は読みにくく理解しにくいかもしれない。パターンマッチングはより便利で、関数型プログラミング言語ユーザーに馴染みのあるアプローチを提供する。
帰納的に定義された自然数の型について考える。全ての自然数は zero
か succ x
のどちらかの形をとるため、それぞれのケースにおいて出力の値を指定することで、自然数から任意の型への関数を定義することができる:
open Nat
/- 等式コンパイラによるパターンマッチングを使った関数定義の構文
この構文を使って帰納型から任意の型への関数を定義する場合、
`:=` は不要である(書くとコンパイルエラーになる)ことに注意。
定義したい項が型Tを持つとき、この構文は型Tの(1つ以上の)前件とパターンマッチングする -/
def sub1 : Nat → Nat
| zero => zero
| succ x => x
def isZero : Nat → Bool
| zero => true
| succ x => false
以上の関数を定義するために使われる等式の集まり(例えば、sub1 zero = zero
は上記の関数定義の一部だとみなせる)はdefinitionallyに成立する:
open Nat
def sub1 : Nat → Nat
| zero => zero
| succ x => x
def isZero : Nat → Bool
| zero => true
| succ x => false
example : sub1 0 = 0 := rfl
example (x : Nat) : sub1 (succ x) = x := rfl
example : isZero 0 = true := rfl
example (x : Nat) : isZero (succ x) = false := rfl
example : sub1 7 = 6 := rfl
example (x : Nat) : isZero (x + 3) = false := rfl
zero
や succ
の代わりに、より馴染みのある表記を使うことができる:
def sub1 : Nat → Nat
| 0 => 0
| x+1 => x
def isZero : Nat → Bool
| 0 => true
| x+1 => false
加法とゼロ表記には [match_pattern]
属性が割り当てられているため、これらの表記をパターンマッチングで使うことができる。Leanは、コンストラクタ zero
や succ
が出現するまで、加法やゼロ表記を含む式を単純に正規化する。
パターンマッチングは直積型や Option
型など、任意の帰納型に対して機能する:
def swap : α × β → β × α
| (a, b) => (b, a)
def foo : Nat × Nat → Nat
| (m, n) => m + n
def bar : Option Nat → Nat
| some n => n + 1
| none => 0
パターンマッチングは関数定義だけでなく、場合分けによる証明にも使うことができる:
namespace Hidden
def not : Bool → Bool
| true => false
| false => true
theorem not_not : ∀ (b : Bool), not (not b) = b
| true => rfl -- proof that not (not true) = true
| false => rfl -- proof that not (not false) = false
theorem not_not' : (b : Bool) → not (not b) = b
| true => rfl
| false => rfl
end Hidden
パターンマッチングは帰納的に定義された命題を分解するためにも使うことができる:
example (p q : Prop) : p ∧ q → q ∧ p
| And.intro h₁ h₂ => And.intro h₂ h₁
example (p q : Prop) : p ∨ q → q ∨ p
| Or.inl hp => Or.inr hp
| Or.inr hq => Or.inl hq
パターンマッチングは論理的結合子を含む仮説を分解するコンパクトな方法も提供する。
以上の全ての例で、パターンマッチングは「フラットな」場合分けを実行するために使われている。さらに興味深いことに、パターンは次のように入れ子になったコンストラクタを含むこともある。
def sub2 : Nat → Nat
| 0 => 0
| 1 => 0
| x+2 => x
この例において、等式コンパイラはまず入力が zero
か succ x
の形であるかで最初の場合分けを行う。入力が zero
のときは 0
を返す。入力が succ x
の形のときは、その x
が zero
か succ x
の形であるかで2回目の場合分けを行う。等式コンパイラは提示されたケースリストから場合分けの方法を決定し、適切な場合分けに失敗したときはエラーを生じる。ここでも、次のように算術の記法を使うことができる。いずれにせよ、関数を定義する等式はdefinitionallyに成立する。
def sub2 : Nat → Nat
| 0 => 0
| 1 => 0
| x+2 => x
example : sub2 0 = 0 := rfl
example : sub2 1 = 0 := rfl
example : sub2 (x+2) = x := rfl
example : sub2 5 = 3 := rfl
#print sub2
と書けば、この関数が再帰子を含むどんな式にコンパイルされたかが分かる。(Leanは sub2
が内部の補助関数 sub2.match_1
を使って定義されていることを伝えるかもしれないが、#print
コマンドを使って sub2.match_1
の定義を表示させることもできる。)Leanはこれらの補助関数を使って match
式をコンパイルする。実際には、上記の定義 sub2
は次のように展開される。
def sub2 : Nat → Nat :=
fun x =>
match x with
| 0 => 0
| 1 => 0
| x+2 => x
入れ子になったパターンマッチングの例をさらに挙げる:
example (p q : α → Prop)
: (∃ x, p x ∨ q x) → (∃ x, p x) ∨ (∃ x, q x)
| Exists.intro x (Or.inl px) => Or.inl (Exists.intro x px)
| Exists.intro x (Or.inr qx) => Or.inr (Exists.intro x qx)
def foo : Nat × Nat → Nat
| (0, n) => 0
| (m+1, 0) => 1
| (m+1, n+1) => 2
等式コンパイラは複数の引数を連続して処理することができる。例えば、一つ上の例 foo
は2つの引数を持つ関数として定義する方が自然だろう:
def foo : Nat → Nat → Nat
| 0, n => 0
| m+1, 0 => 1
| m+1, n+1 => 2
別の例を挙げる:
-- `a :: as` は `cons a as` の糖衣構文
def bar : List Nat → List Nat → Nat
| [], [] => 0
| a :: as, [] => a
| [], b :: bs => b
| a :: as, b :: bs => a + b
複数の引数を持つケースは、パターン毎にカンマで区切られることに注意してほしい。
以下の各例では、2番目以降の引数がケースに含まれているが、最初の引数での場合分けのみが行われる。
namespace Hidden
def and : Bool → Bool → Bool
| true, a => a
| false, _ => false
def or : Bool → Bool → Bool
| true, _ => true
| false, a => a
def cond : Bool → α → α → α
| true, x, y => x
| false, x, y => y
end Hidden
また、ある引数の値が出力を定義するのに必要ない場合は、その引数のパターンにアンダースコアを使うことができることに注意してほしい。このアンダースコアはwildcard pattern(ワイルドカードパターン)あるいはanonymous variable(無名変数)として知られている。等式コンパイラ以外での使い方とは違い、ここでアンダースコアは暗黙の引数を表すものではない。ワイルドカードにアンダースコアを使うのは関数型プログラミング言語では一般的なので、Leanもその表記を採用した。節Wildcards and Overlapping Patterns (ワイルドカードとケースの重複)ではワイルドカードの概念を拡張し、節Inaccessible Patterns (アクセス不能パターン)ではパターン内で暗黙の引数を使用する方法を説明する。
7章 Inductive Types (帰納型)で説明したように、帰納データ型はパラメータに依存しうる。次の例では、パターンマッチングを用いて tail
関数を定義している。引数 α : Type u
は帰納データ型のパラメータであり、パターンマッチングに参加しないことを示すために(関数名・引数リストと型名を区切る)コロンの前に置かれる。Leanは :
の後に帰納型のパラメータが来ることも許可するが、パラメータをパターンマッチさせることはできない。
def tail1 {α : Type u} : List α → List α
| [] => []
| a :: as => as
def tail2 : {α : Type u} → List α → List α
| α, [] => []
| α, a :: as => as
この2つの例では、パラメータ α
の出現位置が異なるにもかかわらず、どちらの例でも場合分けに参加しないという意味で同じように扱われている。
Leanは、依存型の引数が場合分けにおいて「このケースが生じることはない」という追加の制約を与えるような、より複雑な形のパターンマッチングも扱うことができる。このようなdependent pattern matching(依存パターンマッチング)の例については、Dependent Pattern Matching (依存パターンマッチング)の節で説明する。
Wildcards and Overlapping Patterns (ワイルドカードとケースの重複)
前節の例の1つについて考える:
def foo : Nat → Nat → Nat
| 0, n => 0
| m+1, 0 => 1
| m+1, n+1 => 2
この例は次のように表現することもできる:
def foo : Nat → Nat → Nat
| 0, n => 0
| m, 0 => 1
| m, n => 2
2つ目の表現では、ケースが重複している。例えば、引数のペア 0 0
は3つのケース全てにマッチする。しかし、Leanは上のケースから順にパターンマッチングを試し、一番最初にマッチしたケースを使うことで曖昧さを解消するので、この2つの例は結果的に同じ関数を定義している。実際、2つ目の表現について以下の等式がdefinitionallyに成立する:
def foo : Nat → Nat → Nat
| 0, n => 0
| m, 0 => 1
| m, n => 2
example : foo 0 0 = 0 := rfl
example : foo 0 (n+1) = 0 := rfl
example : foo (m+1) 0 = 1 := rfl
example : foo (m+1) (n+1) = 2 := rfl
m
と n
の値は出力の定義に必要ないので、代わりにワイルドカードパターンを使ってもよい。
def foo : Nat → Nat → Nat
| 0, _ => 0
| _, 0 => 1
| _, _ => 2
直前の定義と同様に、この foo
の定義が4つの恒等式をdefinitionallyに満たすことは容易に確認できる。
関数型プログラミング言語の中には、incomplete pattern matching(不完全なパターンマッチング)をサポートするものがある。これらの言語では、インタプリタはケースリスト内のどのケースともマッチしない入力に対して例外を生成するか、任意の値を返す。Leanでは、Inhabited
型クラスを使うと、「どのケースともマッチしない入力に対して任意の値を返す」アプローチをシミュレートできる。大雑把に言うと、Inhabited α
は α
の要素が存在することの証人である。10章 Type Classes (型クラス)では、Leanに適切な基底型がinhabited(有項)であることを指示でき、Leanはその指示に基づいて、他の構築された型が有項であることを自動的に推論できることを説明する。これに基づいて、標準ライブラリは任意の有項型のデフォルト項 default
を提供する。
型 Option α
を使って不完全なパターンマッチングをシミュレートすることもできる。このアプローチでは、入力が適切に提供されたケースとマッチしたときは some a
を返し、不完全なケースとマッチしたときは none
を返す。次の例は、両方のアプローチを示している。
def f1 : Nat → Nat → Nat
| 0, _ => 1
| _, 0 => 2
| _, _ => default -- the "incomplete" case
example : f1 0 0 = 1 := rfl
example : f1 0 (a+1) = 1 := rfl
example : f1 (a+1) 0 = 2 := rfl
example : f1 (a+1) (b+1) = default := rfl
def f2 : Nat → Nat → Option Nat
| 0, _ => some 1
| _, 0 => some 2
| _, _ => none -- the "incomplete" case
example : f2 0 0 = some 1 := rfl
example : f2 0 (a+1) = some 1 := rfl
example : f2 (a+1) 0 = some 2 := rfl
example : f2 (a+1) (b+1) = none := rfl
等式コンパイラは賢い。以下の定義のどれかのケースを省くと、エラーメッセージでどんなケースがカバーされていないかを知らせてくれる。
def bar : Nat → List Nat → Bool → Nat
| 0, _, false => 0
| 0, b :: _, _ => b
| 0, [], true => 7
| a+1, [], false => a
| a+1, [], true => a + 1
| a+1, b :: _, _ => a + b
また、等式コンパイラは適切な状況では casesOn
の代わりに "if ... then ... else" 構文を用いる。
def foo : Char → Nat
| 'A' => 1
| 'B' => 2
| _ => 3
#print foo.match_1
Structural Recursion and Induction (構造的再帰と構造的帰納法)
再帰的定義もサポートしていることが、等式コンパイラを強力なものにしている。次の3つの節では、ここに挙げる3つの概念それぞれについて説明する:
- structurally recursive definitions(構造的再帰的定義)
- well-founded recursive definitions(整礎再帰的定義)
- mutually recursive definitions(相互再帰的定義)
一般的に、等式コンパイラは次の形式の入力を処理する:
def foo (a : α) : (b : β) → γ
| [patterns₁] => t₁
...
| [patternsₙ] => tₙ
ここで、(a : α)
はパラメータの列、(b : β)
はパターンマッチングが行われる引数の列、γ
は任意の型であり、γ
は a
と b
に依存することができる。[patterns₁]
から [patternsₙ]
は同じ数のパターンを含むべきであり、1つのパターンが β
の各要素と対応する。これまで見てきたように、パターンは変数、他のパターンにコンストラクタを適用したもの、またはそのような形式に正規化される式のいずれかである(ここで、コンストラクタでないものは [match_pattern]
属性でマークされる)。コンストラクタの出現はケースの分割を促す。ここで、コンストラクタへの引数は与えられた変数で表される。節Dependent Pattern Matching (依存パターンマッチング)では、パターンマッチングでは何の役割も果たさないが、式の型チェックを行うために必要となる明示的な項をパターンに含める必要がある場合があることを説明する。この明示的な項は今述べた理由によりinaccessible patterns(アクセス不能パターン)と呼ばれる。しかし、節Dependent Pattern Matching (依存パターンマッチング)より前では、アクセス不能パターンを使う必要はない。
前節で見たように、出力を定義する項 t₁, ..., tₙ
は、任意のパラメータ a
だけでなく、対応するパターン内で導入された変数を利用することができる。再帰と帰納が可能なのは、出力を定義する項が foo
への再帰的呼び出しを含むことすら可能だからである。この節では、structural recursion(構造的再帰)を扱う。構造的再帰では、=>
の右辺に現れる foo
への引数は =>
の左辺のパターンの部分項である。これは、部分項はマッチした引数よりstructurally small(構造的に小さい)であるため、帰納型の項としてマッチした引数より先に構築されるという考え方である。前章の構造的再帰の例を、今度は等式コンパイラを使って定義しよう:
open Nat
def add : Nat → Nat → Nat
| m, zero => m
| m, succ n => succ (add m n)
theorem add_zero (m : Nat) : add m zero = m := rfl
theorem add_succ (m n : Nat) : add m (succ n) = succ (add m n) := rfl
theorem zero_add : ∀ n, add zero n = n
| zero => rfl
| succ n => congrArg succ (zero_add n)
def mul : Nat → Nat → Nat
| n, zero => zero
| n, succ m => add (mul n m) n
theorem mul_zero (m : Nat) : mul m zero = zero := rfl
theorem mul_succ (m n : Nat) : mul m (succ n) = add (mul m n) m := rfl
この zero_add
の証明は、Leanにおいて帰納法による証明が再帰の一つの形であることを明らかにしている。
上の例は、add
と mul
を定義する式がdefinitionallyに成立することを示している。等式コンパイラは構造的帰納法による証明内でも、可能な限り関数を定義する式がdefinitionallyに成立するようにする。例えば、zero_add
の証明において、引数が zero
のケースは rfl
を使うだけで示せる。しかしながら、他の状況では、引数の部分項に関する当該定理(例えば zero_add n
)はpropositionallyにしか成立しない。つまり、これは明示的に適用されなければならない等式定理である。等式コンパイラは zero_add n
のような定理を内部的に作成するが、これらの定理はユーザーが直接使うものではなく、simp
タクティクが必要に応じて使うように設定されている。したがって、次の zero_add
の証明も機能する:
open Nat
def add : Nat → Nat → Nat
| m, zero => m
| m, succ n => succ (add m n)
theorem zero_add : ∀ n, add zero n = n
| zero => by simp [add]
| succ n => by simp [add, zero_add]
パターンマッチングによる定義と同様に、構造的再帰や構造的帰納法のパラメータがコロンの前に現れることがある。このようなパラメータは、定義が処理される前にローカルコンテキストに追加される。例えば、加法の定義は次のように書くこともできる:
open Nat
def add (m : Nat) : Nat → Nat
| zero => m
| succ n => succ (add m n)
この例を match
を使って書くこともできる。
/- `match` を使う場合はもちろん `:=` が必要になる -/
open Nat
def add (m n : Nat) : Nat :=
match n with
| zero => m
| succ n => succ (add m n)
構造的再帰のもっと面白い例はフィボナッチ関数 fib
である。
def fib : Nat → Nat
| 0 => 1
| 1 => 1
| n+2 => fib (n+1) + fib n
example : fib 0 = 1 := rfl
example : fib 1 = 1 := rfl
example : fib (n + 2) = fib (n + 1) + fib n := rfl
example : fib 7 = 21 := rfl
ここで、n + 2
( succ (succ n)
とdefinitionally equal)における fib
関数の値は、n + 1
( succ n
とdefinitionally equal)における値と n
における値で定義される。しかし、これはフィボナッチ関数を計算する方法としてはきわめて非効率的で、実行時間は指数 n
の指数関数となる。もっと良い方法がある:
def fibFast (n : Nat) : Nat :=
(loop n).2
where
loop : Nat → Nat × Nat
| 0 => (0, 1)
| n+1 => let p := loop n; (p.2, p.1 + p.2)
#eval fibFast 100
where
の代わりに let rec
を用いた定義は次の通り:
def fibFast (n : Nat) : Nat :=
let rec loop : Nat → Nat × Nat
| 0 => (0, 1)
| n+1 => let p := loop n; (p.2, p.1 + p.2)
(loop n).2
どちらの例でも、Leanは補助関数 fibFast.loop
を生成する。
構造的再帰を処理するために、等式コンパイラは各帰納型の定義時に自動生成される定数 below
と brecOn
を用いて、course-of-values recursion(累積再帰)を使用する。Nat.below
と Nat.brecOn
の型を見れば、それらがどのように機能するかを知ることができる:
variable (C : Nat → Type u)
#check (@Nat.below C : Nat → Type u)
#reduce @Nat.below C (3 : Nat)
#check (@Nat.brecOn C : (n : Nat) → ((n : Nat) → @Nat.below C n → C n) → C n)
型 @Nat.below C (3 : nat)
は、C 0
、C 1
、C 2
の項を格納するデータ構造である。累積再帰は Nat.brecOn
によって実装される。Nat.brecOn
は型 (n : Nat) → C n
を持つ依存関数の入力 m
における値を、(@Nat.below C m
の要素として表される)その関数の以前の全ての値を使って定義することを可能にする。
累積再帰の利用は、等式コンパイラがLeanのカーネルに対して特定の関数が停止することを正当に主張するために使うテクニックの一つである。他の関数型プログラミング言語のコンパイラと同様、再帰関数をコンパイルするコードジェネレータに影響を与えることはない。#eval fib n
の実行時間は指数 n
の指数関数となることを思い出してほしい。一方で、#reduce fib n
は brecOn
による構築に基づいた定義を使用するため効率的である。
def fib : Nat → Nat
| 0 => 1
| 1 => 1
| n+2 => fib (n+1) + fib n
-- #eval fib 50 -- slow
#reduce fib 50 -- fast
#print fib
再帰的定義のもう一つの良い例がリストの append
関数である。
def append : List α → List α → List α
| [], bs => bs
| a::as, bs => a :: append as bs
example : append [1, 2, 3] [4, 5] = [1, 2, 3, 4, 5] := rfl
もう一つ例を挙げる: listAdd x y
は2つのリストのどちらかの要素がなくなるまで、最初のリストの先頭の要素 a
と2番目のリストの先頭の要素 b
を削除して a + b
をリスト z
に追加する操作を繰り返し、最後に z
を返す。
def listAdd [Add α] : List α → List α → List α
| [], _ => []
| _, [] => []
| a :: as, b :: bs => (a + b) :: listAdd as bs
#eval listAdd [1, 2, 3] [4, 5, 6, 6, 9, 10]
-- [5, 7, 9]
以下の練習問題で、これに似た例に関して実験してみることをお勧めする。
Local Recursive Declarations (ローカルな再帰的定義)
let rec
キーワードを使うと、ローカルな再帰的定義を宣言することができる。
def replicate (n : Nat) (a : α) : List α :=
let rec loop : Nat → List α → List α
| 0, as => as
| n+1, as => loop n (a::as)
loop n []
#check @replicate.loop
-- {α : Type} → α → Nat → List α → List α
Leanは各 let rec
に対して補助定義を作成する。上の例では、replicate
の定義内にある let rec loop
のために replicate.loop
の定義を作成している。Leanは、let rec
宣言内で使われたローカル変数を定義中の関数のパラメータに追加することで、宣言を「閉じる」ことに注意してほしい。例えば、ローカル変数 a
は let rec loop
内で使われている。
let rec
をタクティクモード内で使うこともできる。また、帰納法による証明を作るために let rec
を使うこともできる。
def replicate (n : Nat) (a : α) : List α :=
let rec loop : Nat → List α → List α
| 0, as => as
| n+1, as => loop n (a::as)
loop n []
theorem length_replicate (n : Nat) (a : α) : (replicate n a).length = n := by
let rec aux (n : Nat) (as : List α)
: (replicate.loop a n as).length = n + as.length := by
match n with
| 0 => simp [replicate.loop]
| n+1 => simp [replicate.loop, aux n, Nat.add_succ, Nat.succ_add]
exact aux n []
定義を記述した後に where
キーワードを使って補助再帰的定義を導入することもできる。Leanはこれらを let rec
に変換する。
def replicate (n : Nat) (a : α) : List α :=
loop n []
where
loop : Nat → List α → List α
| 0, as => as
| n+1, as => loop n (a::as)
theorem length_replicate (n : Nat) (a : α) : (replicate n a).length = n := by
exact aux n []
where
aux (n : Nat) (as : List α)
: (replicate.loop a n as).length = n + as.length := by
match n with
| 0 => simp [replicate.loop]
| n+1 => simp [replicate.loop, aux n, Nat.add_succ, Nat.succ_add]
Well-Founded Recursion and Induction (整礎再帰と整礎帰納法)
構造的再帰が使えない場合は、整礎再帰を使えば再帰的定義中の関数が停止することを証明できる。整礎再帰に必要なのは、整礎関係と、各再帰適用(f t
の形をとる)が特定の項 t
をこの関係において「減少させる」ことの証明である。依存型理論は整礎再帰を表現し、その正当性を証明するのに十分強力である。これらの仕組みを理解するために必要な論理的背景の説明から始めよう。
Leanの標準ライブラリは2つの述語 Acc r a
と WellFounded r
を定義している。ここで、r
は型 α
上の二項関係であり、a
は型 α
の要素である。
variable (α : Sort u)
variable (r : α → α → Prop)
#check (Acc r : α → Prop)
#check (WellFounded r : Prop)
1つ目 Acc
は帰納的に定義された述語である。定義(唯一のコンストラクタ Acc.intro
)によると、Acc r x
は ∀ y, r y x → Acc r y
と同値である。Acc r x
は r
の下で x
がアクセス可能であることを意味する。r y x
が一種の順序関係 y ≺ x
を表すと考えるなら、Acc r x
は x
の全ての前者がアクセス可能であることと同値である。特に、x
が前者を持たない場合、x
はアクセス可能である。任意の型 α
の任意のアクセス可能な項 x
に対して、x
の全ての前者に先に値を割り当てることで、x
にも値を割り当てることができるはずである。
noncomputable def f {α : Sort u}
(r : α → α → Prop)
(h : WellFounded r)
(C : α → Sort v)
(F : (x : α) → ((y : α) → r y x → C y) → C x)
: (x : α) → C x := WellFounded.fix h F
f
の引数が長い列をなしているが、前半はすでに見たとおりである: α
は型、r
は二項関係、そして h
は「r
が整礎である」という仮説である。変数 C
は再帰的定義の動機を表す: 各項 x : α
について、C x
の項を構築したい。関数 F
は C x
の項を構築するための帰納的レシピを提供する: F
は x
の各前者 y
について C y
の項が与えられたとき、要素 C x
を構築する方法を教えてくれる。
WellFounded.fix
は帰納法においても同様に機能する。つまり、もし ≺
が整礎で、∀ x, C x
を証明したいなら、任意の x
に対して「∀ y ≺ x, C y
ならば C x
」を示せば十分である。
上の例では、コードジェネレータが現在 WellFounded.fix
をサポートしていないため、noncomputable
という修飾子を使用している。関数 WellFounded.fix
はLeanが関数の停止を正当に主張するために使う(累積再帰とは別の)ツールである。
Leanは、自然数に関する通常の順序 <
が整礎であることを知っている。また、Leanは既存の順序から新しい整礎順序を構築する方法もいくつか知っている。例えば、辞書式順序を使う方法がある。
以下は、標準ライブラリ内にある自然数の除法の定義である。
open Nat
theorem div_lemma {x y : Nat} : 0 < y ∧ y ≤ x → x - y < x :=
fun h => sub_lt (Nat.lt_of_lt_of_le h.left h.right) h.left
def div.F (x : Nat) (f : (x₁ : Nat) → x₁ < x → Nat → Nat) (y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
(f (x - y) (div_lemma h) y) + 1
else
zero
noncomputable def div := WellFounded.fix (measure id).wf div.F
#reduce div 8 2 -- 4
この定義はやや分かりにくい。ここで再帰は x
に対して行われ、div.F x f : Nat → Nat
はその固定された x
に対する「y
で割る」関数を返す。再帰のレシピである div.F
の第2引数 f
は、x
より小さい全ての値 x₁
に対して「y
で割る」関数を返す(と想定される)関数であることを理解する必要がある。
elaboratorはこのような定義をより便利に作成できるようにデザインされている。次のような定義の書き方が認められる:
def div (x y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
have : x - y < x := Nat.sub_lt (Nat.lt_of_lt_of_le h.1 h.2) h.1
(div (x - y) y) + 1
else
0
再帰的な定義に遭遇すると、Leanはまず構造的再帰を試み、それが失敗したときだけ整礎再帰を試みる。Leanは decreasing_tactic
というタクティクを使って、再帰適用後の項が元の項より「小さい」ことを示す。上の例の補助命題 x - y < x
はこのタクティクのためのヒントとみなされる。
div
を定義する等式はdefinitionallyに成立しない。しかし、unfold
タクティクを使えば div
を展開することはできる。conv
を使うと、展開したい div
適用後の項を選択できる。
def div (x y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
have : x - y < x := Nat.sub_lt (Nat.lt_of_lt_of_le h.1 h.2) h.1
div (x - y) y + 1
else
0
example (x y : Nat) : div x y = if 0 < y ∧ y ≤ x then div (x - y) y + 1 else 0 := by
conv => lhs; unfold div -- 等式の左辺の `div` を展開する
example (x y : Nat) (h : 0 < y ∧ y ≤ x) : div x y = div (x - y) y + 1 := by
conv => lhs; unfold div
simp [h]
次の例も同様の構文を使って整礎再帰を行う: natToBin
は任意の自然数を0と1のリストとして表される2進表記に変換する。まず、再帰適用後の項が元の項より減少する証拠を示さなければならないが、これは sorry
で行う。sorry
はインタプリタが関数を正常に評価することを妨げるものではない。(訳者注: #eval
を使って関数を評価するだけなら証拠は不要ということだと思われる。しかし証拠がない場合、任意の引数 n
に対して #eval natToBin n
が停止する保証はないと思われる。)
def natToBin : Nat → List Nat
| 0 => [0]
| 1 => [1]
| n + 2 =>
have : (n + 2) / 2 < n + 2 := sorry
natToBin ((n + 2) / 2) ++ [n % 2]
#eval natToBin 1234567
最後の例として、Leanではアッカーマン関数が本来の定義をそのまま書くだけで定義できることを見る。なぜなら、この整礎再帰は自然数の辞書式順序の整礎性によって正当化されるからである。termination_by
キーワードはLeanに辞書式順序を使うように指示している。実際には、このキーワードは関数の2つの引数を Nat × Nat
型の項にマッピングしている。そして、Leanは型クラス解決を使って WellFoundedRelation (Nat × Nat)
型の要素を合成する。
def ack : Nat → Nat → Nat
| 0, y => y+1
| x+1, 0 => ack x 1
| x+1, y+1 => ack x (ack (x+1) y)
termination_by x y => (x, y)
#eval ack 3 5
-- アッカーマン関数は入力値の増加に伴い出力値が急速に増加する関数であり、
-- 例えば `#eval ack 4 1` などはバッファオーバーフロー等のエラーを引き起こす可能性が高いため、
-- 実行しないことをお勧めする。
インスタンス WellFoundedRelation (α × β)
が辞書式順序を使うため、上の定義の中では辞書式順序が使われていることに注意してほしい。また、Leanは標準ライブラリ内で次のインスタンス instWellFoundedRelation
も定義している。
instance (priority := low) [SizeOf α] : WellFoundedRelation α :=
sizeOfWFRel
次の例では、as.size - i
が再帰適用によって減少することを示すことで、go
の停止性を証明する。
-- Array `as` を先頭から見て、
-- `as` の要素 `a` が `p a` を満たす限りArray `r` に `a` を追加し、`r` を返す関数
def takeWhile (p : α → Bool) (as : Array α) : Array α :=
go 0 #[]
where
go (i : Nat) (r : Array α) : Array α :=
if h : i < as.size then
let a := as.get ⟨i, h⟩
if p a then
go (i+1) (r.push a)
else
r
else
r
termination_by as.size - i
#eval takeWhile (fun n : Nat => if n % 2 = 1 then true else false) #[1, 3, 5, 6, 7]
この例では、補助関数 go
は再帰的だが、takeWhile
は再帰的でないことに注意してほしい。
デフォルトでは、Leanは decreasing_tactic
タクティクを使って再帰適用後の項が元の項より小さいことを証明する。decreasing_by
修飾子を使うと、独自のタクティクを提供することができる。以下はその例である。
theorem div_lemma {x y : Nat} : 0 < y ∧ y ≤ x → x - y < x :=
fun ⟨ypos, ylex⟩ => Nat.sub_lt (Nat.lt_of_lt_of_le ypos ylex) ypos
def div (x y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
div (x - y) y + 1
else
0
decreasing_by apply div_lemma; assumption
decreasing_by
は termination_by
を置き換えるものではなく、互いに補完し合うものである。termination_by
は整礎関係を指定するのに使われ、decreasing_by
は再帰適用後の項が元の項より小さいことを示すために独自のタクティクを提供するために使われる。次の例ではその両方を使う。
def ack : Nat → Nat → Nat
| 0, y => y+1
| x+1, 0 => ack x 1
| x+1, y+1 => ack x (ack (x+1) y)
termination_by x y => (x, y)
decreasing_by
all_goals simp_wf -- unfolds well-founded recursion auxiliary definitions
· apply Prod.Lex.left; simp_arith
· apply Prod.Lex.right; simp_arith
· apply Prod.Lex.left; simp_arith
decreasing_by sorry
を使えば、Leanに関数が停止することを「信じ」させることができる。
def natToBin : Nat → List Nat
| 0 => [0]
| 1 => [1]
| n + 2 => natToBin ((n + 2) / 2) ++ [n % 2]
decreasing_by sorry
#eval natToBin 1234567
sorry
を使うことは新しい公理を導入することと同じであり、避けるべきであることを思い出してほしい。次の例では、sorry
を使って False
を証明した。コマンド #print axioms
は、unsound
が sorry
を実装するために使われている不健全な公理 sorryAx
に依存していることを示す。
def unsound (x : Nat) : False :=
unsound (x + 1)
decreasing_by sorry
#check unsound 0
-- `unsound 0` is a proof of `False`
#print axioms unsound
-- 'unsound' depends on axioms: [sorryAx]
要約:
-
termination_by
がない場合、Leanはある引数を選択し、型クラス解決を使って選択した引数の型上の整礎関係を合成することで、(可能であれば)整礎関係が導出される。 -
termination_by
が指定されている場合、termination_by
は関数の引数をα
型にマッピングし、再び型クラス解決が使われる。β × γ
に関するデフォルトインスタンスはβ
とγ
上の整礎関係に基づいた辞書式順序であることを思い出してほしい。 -
Nat
に関するデフォルトの整礎関係インスタンスは<
である。 -
デフォルトでは、選択された整礎関係について、再帰適用後の項が元の項より小さいことを示すために
decreasing_tactic
タクティクが使われる。decreasing_tactic
が失敗した場合に表示されるエラーメッセージは残りのゴール... |- G
を含む。decreasing_tactic
はassumption
タクティクを使うことに注意。つまり、have
式を使ってコンテキストに仮説を追加することがターゲットG
の証明の役に立つことがある。decreasing_by
を使って独自のタクティクを提供することもできる。
Mutual Recursion (相互再帰)
Leanはmutual recursive definitions(相互再帰的定義)もサポートしている。その構文は相互帰納型と似ている。以下に例を挙げる:
mutual
def even : Nat → Bool
| 0 => true
| n+1 => odd n
def odd : Nat → Bool
| 0 => false
| n+1 => even n
end
example : even (a + 1) = odd a := by
simp [even]
example : odd (a + 1) = even a := by
simp [odd]
theorem even_eq_not_odd : ∀ a, even a = not (odd a) := by
intro a; induction a
. simp [even, odd]
. simp [even, odd, *]
even
は odd
を用いて定義され、odd
は even
を用いて定義されているため、この定義は相互定義になっている。内部では、この定義は単一の再帰的定義としてコンパイルされる。内部で定義された関数は、直和型の項を引数として取る。直和型の項の片側は even
への入力、もう片側は odd
への入力と解釈される。そして、入力に適した出力を返す。この関数を定義するために、Leanは適切な整礎関係を使用するが、その内部はユーザーから隠されている。このような定義を利用する正規の方法は、前節でやったように simp
または unfold
を使うことである。
また、相互再帰的定義は相互帰納型および入れ子帰納型を扱う自然な方法を提供する。前章で示した、相互帰納述語型としての Even
と Odd
の定義を思い出してほしい。
mutual
inductive Even : Nat → Prop where
| even_zero : Even 0
| even_succ : ∀ n, Odd n → Even (n + 1)
inductive Odd : Nat → Prop where
| odd_succ : ∀ n, Even n → Odd (n + 1)
end
コンストラクタ even_zero
、even_succ
、odd_succ
はある自然数が偶数か奇数かを示す積極的な手段を提供する。0が奇数でないこと、そして後の2つのコンストラクタの意味が逆であることを知るには、この相互帰納型がこれらのコンストラクタによって生成されたという事実を使う必要がある。いつものように、コンストラクタは定義された型の名前を持つ名前空間に保管されており、コマンド open Even Odd
を使えば、コンストラクタに便利にアクセスできるようになる。
mutual
inductive Even : Nat → Prop where
| even_zero : Even 0
| even_succ : ∀ n, Odd n → Even (n + 1)
inductive Odd : Nat → Prop where
| odd_succ : ∀ n, Even n → Odd (n + 1)
end
open Even Odd
theorem not_odd_zero : ¬ Odd 0 :=
fun h => nomatch h
theorem even_of_odd_succ : ∀ n, Odd (n + 1) → Even n
| _, odd_succ n h => h
theorem odd_of_even_succ : ∀ n, Even (n + 1) → Odd n
| _, even_succ n h => h
別の例を挙げる。入れ子帰納型を使って、型 Term
の項を帰納的に定義することを考える。ここで、Term
の項は定数(文字列によって与えられる名前を持つ)か、定数のリストに定数を適用した結果のどちらかになるとする。
inductive Term where
| const : String → Term
| app : String → List Term → Term
そして、相互再帰を使って、各項の中に登場する定数の数を数える関数と、項のリストの中に登場する要素の数を数える関数を定義することができる。
inductive Term where
| const : String → Term
| app : String → List Term → Term
namespace Term
mutual
def numConsts : Term → Nat
| const _ => 1
| app _ cs => numConstsLst cs
def numConstsLst : List Term → Nat
| [] => 0
| c :: cs => numConsts c + numConstsLst cs
end
def sample := app "f" [app "g" [const "x"], const "y"]
def sample2 := [app "g" [const "x", const "y"], const "y"]
#eval numConsts sample
#eval numConstsLst sample2
end Term
最後の例として、項 e
内の定数 a
を b
に置き換える関数 replaceConst a b e
を定義し、e
が持つ定数の個数と replaceConst a b e
が持つ定数の個数が同じであることを証明する。この証明は、相互帰納法を使っていることに注意してほしい。
inductive Term where
| const : String → Term
| app : String → List Term → Term
namespace Term
mutual
def numConsts : Term → Nat
| const _ => 1
| app _ cs => numConstsLst cs
def numConstsLst : List Term → Nat
| [] => 0
| c :: cs => numConsts c + numConstsLst cs
end
mutual
def replaceConst (a b : String) : Term → Term
| const c => if a == c then const b else const c
| app f cs => app f (replaceConstLst a b cs)
def replaceConstLst (a b : String) : List Term → List Term
| [] => []
| c :: cs => replaceConst a b c :: replaceConstLst a b cs
end
mutual
theorem numConsts_replaceConst (a b : String) (e : Term)
: numConsts (replaceConst a b e) = numConsts e := by
match e with
| const c => simp [replaceConst]; split <;> simp [numConsts]
| app f cs => simp [replaceConst, numConsts, numConsts_replaceConstLst a b cs]
theorem numConsts_replaceConstLst (a b : String) (es : List Term)
: numConstsLst (replaceConstLst a b es) = numConstsLst es := by
match es with
| [] => simp [replaceConstLst, numConstsLst]
| c :: cs =>
simp [replaceConstLst, numConstsLst, numConsts_replaceConst a b c,
numConsts_replaceConstLst a b cs]
end
Dependent Pattern Matching (依存パターンマッチング)
Pattern Matching (パターンマッチング)の節で説明した全ての例は、casesOn
と recOn
を使って簡単に書くことができる。しかし、Vector α n
のような添字付き帰納族では、ケース分割が添字の値に制約を課すため、簡単に書けないことがよくある。もし等式コンパイラが無かったら、再帰子だけを使って map
、zip
、unzip
などの非常に単純な関数を定義するために、多くの定型的なコードが必要になっただろう。その難しさを理解するために、ベクトル v : Vector α (succ n)
を受け取り、最初の要素を削除したベクトルを返す関数 tail
を定義するためには何が必要かを考えてみよう。まずは、casesOn
関数を使うことが考えられる:
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
#check @Vector.casesOn
/-
{α : Type u}
→ {motive : (a : Nat) → Vector α a → Sort v} →
→ {a : Nat} → (t : Vector α a)
→ motive 0 nil
→ ((a : α) → {n : Nat} → (a_1 : Vector α n) → motive (n + 1) (cons a a_1))
→ motive a t
-/
end Vector
しかし、入力が nil
の場合は何を返せばいいだろうか。何かおかしなことが起こっている: v
の型が Vector α (succ n)
なら、v
が nil
であるはずがない。しかし、それを casesOn
に伝える方法は明らかではない。
1つの解決策は、補助関数を定義することである:
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def tailAux (v : Vector α m) : m = n + 1 → Vector α n :=
Vector.casesOn (motive := fun x _ => x = n + 1 → Vector α n) v
(fun h : 0 = n + 1 => Nat.noConfusion h)
(fun (a : α) (m : Nat) (as : Vector α m) =>
fun (h : m + 1 = n + 1) =>
Nat.noConfusion h (fun h1 : m = n => h1 ▸ as))
def tail (v : Vector α (n+1)) : Vector α n :=
tailAux v rfl
end Vector
補助関数 tailAux
において、v
が nil
の場合、m
の値は 0
に決定し、noConfusion
は 0 = succ n
は成立しえないという事実を使う。そうでなければ、v
は a :: w
の形をとり、w
を長さ m
のベクトルから長さ n
のベクトルへキャストした後、単純に w
を返すことができる(h1 ▸ as
)。
tail
を定義する上で難しいのは、添字間の関係を維持することである。tailAux
内の仮説 e : m = n + 1
は、n
と小前提(Vector.casesOn
の4番目と5番目の引数)に関連した添字(0
と m + 1
)の関係を noConfusion
等に伝えるために使われる。さらに、zero = n + 1
のケースは到達不能である。このようなケースを破棄する正規の方法は noConfusion
を使うことである。
しかし、実際は tail
関数は再帰を使って簡単に定義できる。そして、等式コンパイラが全ての定型コードを自動的に生成してくれる。似た例をいくつか紹介しよう:
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def head : {n : Nat} → Vector α (n+1) → α
| n, cons a as => a
def tail : {n : Nat} → Vector α (n+1) → Vector α n
| n, cons a as => as
theorem eta : ∀ {n : Nat} (v : Vector α (n+1)), cons (head v) (tail v) = v
| n, cons a as => rfl
def map (f : α → β → γ) : {n : Nat} → Vector α n → Vector β n → Vector γ n
| 0, nil, nil => nil
| n+1, cons a as, cons b bs => cons (f a b) (map f as bs)
def zip : {n : Nat} → Vector α n → Vector β n → Vector (α × β) n
| 0, nil, nil => nil
| n+1, cons a as, cons b bs => cons (a, b) (zip as bs)
end Vector
再帰的定義において、head nil
のような「到達不能」なケースはケースリストから除外できることに注意してほしい。自動生成される添字付き帰納族の定義は単純とは言いがたいものである。次の例について、#print
コマンドの出力を見てほしい:
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def map (f : α → β → γ) : {n : Nat} → Vector α n → Vector β n → Vector γ n
| 0, nil, nil => nil
| n+1, cons a as, cons b bs => cons (f a b) (map f as bs)
#print map
#print map.match_1
end Vector
map
関数を手で定義するのは tail
関数よりもさらに面倒である。自信のある人は recOn
、casesOn
、noConfusion
を使って map
関数を手作りしてみてほしい。
Inaccessible Patterns (アクセス不能パターン)
依存パターンマッチングにおいて、項の型を適切に特殊化するために、定義には必要のない引数を含まなければならない場合がある。Leanでは、このような補助項を、パターンマッチングにおいてinaccessible(アクセス不能)なものとしてマークすることができる。例えば、左辺に出現する項が変数単体でも変数にコンストラクタを適用したものでもない場合、これらの注釈は不可欠である。なぜなら、それらの項はパターンマッチングにおいて不適切なターゲットだからである。このようなアクセス不能パターンは、ケースの左辺のdon't careな構成要素とみなすことができる。.(t)
と書くことで、補助項へのアクセスが不能であることを宣言することができる。アクセス不能パターンの形が推論できる場合は、_
と書いてもよい。
次の例では、「f
のimage(像)の中にある」という性質を定義する帰納型を宣言する。型 ImageOf f b
の項は、b
が f
の像の中にあることの証拠だと見なすことができる。コンストラクタ imf
はそのような証拠を構築するために使われる。それから、f
の像の中にある項 b
を受け取り、証拠 imf a
に基づいて、f
によって b
にマップされた要素の1つ a
を返す「逆関数」を持つ関数 f
を定義することができる。型付けのルールに従うと、最初の引数を f a
と書かなければならないが、この項は変数単体でも変数にコンストラクタを適用したものでもないため、パターンマッチングを用いた定義において何の役割も果たさない。以下の逆関数 inverse
を定義するためには、f a
にアクセス不能であるとマークしなければならない。
inductive ImageOf {α β : Type u} (f : α → β) : β → Type u where
| imf : (a : α) → ImageOf f (f a)
open ImageOf
/-
def bad_inverse {f : α → β} : (b : β) → ImageOf f b → α
| b, imf a => a -- `imf a` has type `ImageOf f (f a)` but is expected to have type `ImageOf f b`
def bad_inverse' {f : α → β} : (b : β) → ImageOf f b → α
| f a, imf a => a -- invalid pattern
-/
def inverse {f : α → β} : (b : β) → ImageOf f b → α
| .(f a), imf a => a
def inverse' {f : α → β} : (b : β) → ImageOf f b → α
| _, imf a => a
上記の例では、アクセス不能注釈は f
がパターンマッチング対象の変数ではないことを明確にしている。
アクセス不能パターンは、依存パターンマッチングを利用する定義を明確にし、制御するために使用することができる。ある型 α
に関連する加法演算があると仮定して、α
の項を要素に持つ2つのベクトルを足し合わせる関数 Vector.add
を定義することを考えてみよう:
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] : {n : Nat} → Vector α n → Vector α n → Vector α n
| 0, nil, nil => nil
| n+1, cons a as, cons b bs => cons (a + b) (add as bs)
end Vector
引数 {n : Nat}
はコロンの後に現れるが、これはパターンマッチングを用いた定義内で n
を固定し続けることができないからである。この定義を実装したとき、等式コンパイラは最初の引数が 0
か n+1
の形をとるかのケース判別から始める。続いて、次の2つの引数についてネストされたケース判別がなされる。それぞれのケースについて、等式コンパイラは最初の引数 n
のパターンと整合性のないケースを除外する(例えば、n+1, nil, nil
というパターンたちを持つケースを除外する)。
しかし、実際には最初の引数 n
についてケース判別をする必要はない。Vector
の casesOn
エリミネータは2番目の引数でケース判別をするときに、引数 n
の値を自動的に抽象化して 0
か n + 1
に置き換える。アクセス不能パターンを使うことで、等式コンパイラに n
でのケース判別を避けるように促すことができる。
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] : {n : Nat} → Vector α n → Vector α n → Vector α n
| .(_), nil, nil => nil
| .(_), cons a as, cons b bs => cons (a + b) (add as bs)
end Vector
この位置をアクセス不能パターンとしてマークすることは、等式コンパイラに次の2つのことを伝える。第一に、最初の引数の形式は他の引数によってもたらされる制約から推論されるべきである。第二に、最初の引数はパターンマッチングに参加すべきではない。
アクセス不能パターン .(_)
は簡便のため _
と書くことができる。
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] : {n : Nat} → Vector α n → Vector α n → Vector α n
| _, nil, nil => nil
| _, cons a as, cons b bs => cons (a + b) (add as bs)
end Vector
上述したように、引数 {n : Nat}
は定義内で固定し続けることができないため、パターンマッチングの一部のならざるを得ない。Leanの以前のバージョンでは、ユーザーはこのような余分な判別子を含めなければならないことが面倒だとしばしば感じていた。そこで、Lean 4は新機能discriminant refinement(判別子の絞り込み)を実装した。この機能は余分な判別子を自動でパターンマッチングに含める。
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] {n : Nat} : Vector α n → Vector α n → Vector α n
| nil, nil => nil
| cons a as, cons b bs => cons (a + b) (add as bs)
end Vector
auto bound implicits(自動束縛暗黙引数)機能と組み合わせると、add
の定義をより簡潔に書くことができる:
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def add [Add α] : Vector α n → Vector α n → Vector α n
| nil, nil => nil
| cons a as, cons b bs => cons (a + b) (add as bs)
end Vector
これらの新機能を使うことで、前節で定義した他のベクトル関数を次のようによりコンパクトに書くことができる:
inductive Vector (α : Type u) : Nat → Type u
| nil : Vector α 0
| cons : α → {n : Nat} → Vector α n → Vector α (n+1)
namespace Vector
def head : Vector α (n+1) → α
| cons a as => a
def tail : Vector α (n+1) → Vector α n
| cons a as => as
theorem eta : (v : Vector α (n+1)) → cons (head v) (tail v) = v
| cons a as => rfl
def map (f : α → β → γ) : Vector α n → Vector β n → Vector γ n
| nil, nil => nil
| cons a as, cons b bs => cons (f a b) (map f as bs)
def zip : Vector α n → Vector β n → Vector (α × β) n
| nil, nil => nil
| cons a as, cons b bs => cons (a, b) (zip as bs)
end Vector
Match Expressions (マッチ式)
Leanは、多くの関数型言語で見られるmatch-with式のコンパイラも提供している。
def isNotZero (m : Nat) : Bool :=
match m with
| 0 => false
| n+1 => true
match
は普通のパターンマッチングによる定義とあまり変わらないように見える。しかし、match
のポイントは、定義内のどこでも使えることと、任意の引数に対して使えることである。
def isNotZero (m : Nat) : Bool :=
match m with
| 0 => false
| n+1 => true
def filter (p : α → Bool) : List α → List α
| [] => []
| a :: as =>
match p a with
| true => a :: filter p as
| false => filter p as
example : filter isNotZero [1, 0, 0, 3, 0] = [1, 3] := rfl
別の例を挙げる:
def foo (n : Nat) (b c : Bool) :=
5 + match n - 5, b && c with
| 0, true => 0
| m+1, true => m + 7
| 0, false => 5
| m+1, false => m + 3
#eval foo 7 true false
example : foo 7 true false = 9 := rfl
Leanは、システムの全ての部分で、パターンマッチングを実装するために内部で match
式を使用する。したがって、以下の4つの定義は全て同じ効果を持つ。
def bar₁ : Nat × Nat → Nat
| (m, n) => m + n
def bar₂ (p : Nat × Nat) : Nat :=
match p with
| (m, n) => m + n
def bar₃ : Nat × Nat → Nat :=
fun (m, n) => m + n
def bar₄ (p : Nat × Nat) : Nat :=
let (m, n) := p; m + n
命題を分解する際には、上記の変形版が役に立つ:
variable (p q : Nat → Prop)
example : (∃ x, p x) → (∃ y, q y) → ∃ x y, p x ∧ q y
| ⟨x, px⟩, ⟨y, qy⟩ => ⟨x, y, px, qy⟩
example (h₀ : ∃ x, p x) (h₁ : ∃ y, q y)
: ∃ x y, p x ∧ q y :=
match h₀, h₁ with
| ⟨x, px⟩, ⟨y, qy⟩ => ⟨x, y, px, qy⟩
example : (∃ x, p x) → (∃ y, q y) → ∃ x y, p x ∧ q y :=
fun ⟨x, px⟩ ⟨y, qy⟩ => ⟨x, y, px, qy⟩
example (h₀ : ∃ x, p x) (h₁ : ∃ y, q y)
: ∃ x y, p x ∧ q y :=
let ⟨x, px⟩ := h₀
let ⟨y, qy⟩ := h₁
⟨x, y, px, qy⟩
Exercises (練習問題)
-
名前の衝突を避けるために名前空間
Hidden
を開き、等式コンパイラを使って自然数上の加法、乗法、べき乗を定義せよ。次に、等式コンパイラを使って、それらの基本的な性質を証明せよ。 -
同様に、等式コンパイラを使ってリストに対する基本的な操作(
reverse
関数など)を定義し、リストに関する定理を帰納法で証明せよ。例えば、任意のリストに対して、reverse (reverse xs) = xs
となることを示せ。 -
累積再帰を実行する自然数上の関数を自分で定義せよ。同様に、
WellFounded.fix
を自分で定義する方法を見つけられるか試してみよ。 -
節Dependent Pattern Matching (依存パターンマッチング)の例に従って、2つのベクトル
va
vb
を受け取り、va
の末尾にvb
を追加したベクトルを返す関数を定義せよ。これは厄介で、補助関数を定義しなければならないだろう。 -
次のような算術式の型を考える。ここで、
var n
は変数vₙ
を、const n
は値がn
である定数を表す。
inductive Expr where
| const : Nat → Expr
| var : Nat → Expr
| plus : Expr → Expr → Expr
| times : Expr → Expr → Expr
deriving Repr
open Expr
def sampleExpr : Expr :=
plus (times (var 0) (const 7)) (times (const 2) (var 1))
ここで、sampleExpr
は (v₀ * 7) + (2 * v₁)
を表す。
各 var n
を v n
に評価した上で、このような式(Expr
の項)を評価する関数を書け。
inductive Expr where
| const : Nat → Expr
| var : Nat → Expr
| plus : Expr → Expr → Expr
| times : Expr → Expr → Expr
deriving Repr
open Expr
def sampleExpr : Expr :=
plus (times (var 0) (const 7)) (times (const 2) (var 1))
def eval (v : Nat → Nat) : Expr → Nat
| const n => sorry
| var n => v n
| plus e₁ e₂ => sorry
| times e₁ e₂ => sorry
def sampleVal : Nat → Nat
| 0 => 5
| 1 => 6
| _ => 0
-- 次のコマンドを実行せよ。`47` が出力されたら正解である。
-- #eval eval sampleVal sampleExpr
補助関数 simpConst
を使って、5 + 7
のような部分項を 12
に単純化する「定数融合関数」fuse
を実装せよ。plus
や times
を単純化するために、まず引数を再帰的に単純化せよ。次に simpConst
を適用して結果の単純化を試みよ。
inductive Expr where
| const : Nat → Expr
| var : Nat → Expr
| plus : Expr → Expr → Expr
| times : Expr → Expr → Expr
deriving Repr
open Expr
def eval (v : Nat → Nat) : Expr → Nat
| const n => sorry
| var n => v n
| plus e₁ e₂ => sorry
| times e₁ e₂ => sorry
def simpConst : Expr → Expr
| plus (const n₁) (const n₂) => const (n₁ + n₂)
| times (const n₁) (const n₂) => const (n₁ * n₂)
| e => e
def fuse : Expr → Expr := sorry
theorem simpConst_eq (v : Nat → Nat)
: ∀ e : Expr, eval v (simpConst e) = eval v e :=
sorry
theorem fuse_eq (v : Nat → Nat)
: ∀ e : Expr, eval v (fuse e) = eval v e :=
sorry
最後の2つの定理は、simpConst
や fuse
が式の値を保存することを表す。
Structures and Records (構造体とレコード)
Leanの基礎システムは帰納型を含むことを見てきた。さらに、型宇宙、依存関数型、そして帰納型のみで巨大で頑丈な数学の体系を構築できるという驚くべき事実を説明した。それ以外の全てはこの3種類の型から派生するのである。Leanの標準ライブラリには帰納型の具体例(例えば Nat
、Prod
、List
)が多数含まれており、論理的結合子でさえも帰納型を用いて定義されている。
コンストラクタを1つだけ持つ非再帰的帰納型はstructure(構造体)またはrecord(レコード)と呼ばれることを思い出してほしい。直積型は構造体であり、依存直積型(シグマ型)も同様に構造体である。一般に、構造体 S
が定義されるとき、S
の各インスタンス(レコード or オブジェクト)を「分解」し、そのフィールドに格納されている値を取り出すことができるprojection(射影)関数も同時に定義することが多い。直積ペアの1番目の要素を返す関数 prod.fst
と2番目の要素を返す関数 prod.snd
はそのような射影関数の例である。
プログラムを書いたり数学を形式化するとき、多くのフィールドを含む構造を定義することは珍しくない。Leanでは、structure
コマンドが構造体の定義をサポートするインフラを提供する。このコマンドを使って構造体を定義すると、Leanは各フィールドに対する射影関数を自動生成する。structure
コマンドは、以前に定義した構造体に基づいて新しい構造体を定義することもできる。さらに、Leanは与えられた構造体のインスタンスを定義するための便利な記法も提供する。
Declaring Structures (構造体を定義する)
structure
コマンドは、言わば帰納データ型を定義するための「フロントエンド」である。全ての structure
宣言は、構造体に与えられた名前と同じ名前の名前空間を導入する。structure
コマンドの構文の一般的な形式は次の通りである:
structure <name> <parameters> <parent-structures> where
<constructor> :: <fields>
ほとんどの部分はオプションである。構造体定義の例を挙げる:
structure Point (α : Type u) where
mk :: (x : α) (y : α)
Point
型の値はコンストラクタ Point.mk a b
を使って生成され、点 p
のフィールドには Point.x p
と Point.y p
を使ってアクセスする(以下で見るように、p.x
と p.y
も同様に機能する)。structure
コマンドは定義した構造体に関する有用な再帰子や定理も自動生成する。上の Point
型の宣言の際に自動生成されたもののいくつかを以下に挙げる。
structure Point (α : Type u) where
mk :: (x : α) (y : α)
#check Point -- a Type
#check @Point.rec -- the eliminator
#check @Point.mk -- the constructor
#check @Point.x -- a projection
#check @Point.y -- a projection
コンストラクタ名を指定しなかった場合、デフォルトでコンストラクタは mk
と名付けられる。また、各フィールドの間に改行を入れると、フィールド名を括弧で囲むのを省略することができる。
structure Point (α : Type u) where
x : α
y : α
structure
コマンドにより自動生成されたものを使った簡単な定理や式をいくつか紹介しよう。いつものように、open Point
コマンドを使えば Point
という接頭辞を省略した名前を使えるようになる。
structure Point (α : Type u) where
x : α
y : α
#eval Point.x (Point.mk 10 20)
#eval Point.y (Point.mk 10 20)
open Point
example (a b : α) : x (mk a b) = a :=
rfl
example (a b : α) : y (mk a b) = b :=
rfl
p : Point Nat
が与えられたとき、ドット記法 p.x
は Point.x p
の略記である。これは構造体のフィールドにアクセスする便利な方法である。
structure Point (α : Type u) where
x : α
y : α
def p := Point.mk 10 20
#check p.x -- Nat
#eval p.x -- 10
#eval p.y -- 20
ドット記法はレコードの射影関数にアクセスするときだけでなく、同じ名前空間内で定義された他の関数を適用するときにも便利である。節Conjunction (連言)の内容を思い出してほしい。p
が Point
型を持ち、foo
の最初の非暗黙引数が Point
型を持つなら、式 p.foo
は Point.foo p
と解釈される。したがって、次の例のように、式 p.add q
は Point.add p q
の略記となる。
structure Point (α : Type u) where
x : α
y : α
deriving Repr
def Point.add (p q : Point Nat) :=
mk (p.x + q.x) (p.y + q.y)
def p : Point Nat := Point.mk 1 2
def q : Point Nat := Point.mk 3 4
#eval p.add q -- {x := 4, y := 6}
次の章では、型 α
に関連する加法演算があるという仮定の下、add
のような関数を定義して、それが Point Nat
だけでなく Point α
の項に対して汎用的に機能するようにする方法を学ぶ。
より一般的には、項 p : Point
と式 p.foo x y z
が与えられると、Leanは Point.foo
の「Point
型の」最初の引数として p
を挿入する。例えば、以下のスカラー倍の定義では、p.smul 3
は Point.smul 3 p
と解釈される。
structure Point (α : Type u) where
x : α
y : α
deriving Repr
def Point.smul (n : Nat) (p : Point Nat) :=
Point.mk (n * p.x) (n * p.y)
def p : Point Nat := Point.mk 1 2
#eval p.smul 3 -- {x := 3, y := 6}
List.map
関数では同様のトリックがよく使われる。List.map
関数は2番目の非暗黙引数としてリストを取る:
#check @List.map
def xs : List Nat := [1, 2, 3]
def f : Nat → Nat := fun x => x * x
#eval xs.map f -- [1, 4, 9]
ここで、xs.map f
は List.map f xs
と解釈されている。
Objects (オブジェクト)
これまでコンストラクタを使って構造体の項を作成してきた。多くのフィールドを含む構造体の場合、コンストラクタを使って構造体の項を作成する方法は、フィールドが定義された順番を覚えておく必要があるため、しばしば不便である。そこで、Leanでは構造体の項を定義するための次のような代替記法を用意している。(訳者注: *
は括弧内が1回以上の繰り返しからなることを表す。実際にこの構文を用いるときに括弧を書く必要はない。)
{ (<field-name> := <expr>)* : structure-type }
or
{ (<field-name> := <expr>)* }
接尾辞 : structure-type
は、期待される構造体の型が与えられた情報から推論できる場合はいつでも省略できる。例えば、Point
型のオブジェクト「点」を定義するためにこの記法を用いる。フィールドを指定する順番は任意であるため、以下の式は全て同じ点を定義する。
structure Point (α : Type u) where
x : α
y : α
#check { x := 10, y := 20 : Point Nat } -- { (<field-name> := <expr>)* : structure-type }
#check { y := 20, x := 10 : Point _ } -- フィールドを指定する順番は任意
#check ({ x := 10, y := 20 } : Point Nat) -- { (<field-name> := <expr>)* } 構造体の型が明らか
example : Point Nat :=
{ y := 20, x := 10 } -- { (<field-name> := <expr>)* } 構造体の型が明らか
フィールドの値が指定されていない場合、Leanはその値を推論しようとする。指定されていないフィールドの値を推論できなかった場合、Leanは対応するプレースホルダーを埋められなかったことを示すエラーフラグを立てる。
structure MyStruct where
{α : Type u}
{β : Type v}
a : α
b : β
#check { a := 10, b := true : MyStruct }
Record update(レコード更新)は、古いレコード(オブジェクト)の1つまたは複数のフィールドの値を変更して新しいレコードを作成する、もう1つの一般的操作である。Leanでは、フィールドへの値の割り当ての前に s with
という注釈を追加することで、新しいレコード内の値未割り当てのフィールドを古いレコード s
から取得するように指示することができる。複数の古いレコードが提供された場合、新しいレコード内でまだ値が指定されていないフィールドを含むレコードを見つけるまで、Leanは提供されたレコードを順番に参照する。全てのオブジェクトを参照した後、新しいレコード内に値未指定のフィールドが存在した場合、Leanはエラーを発生させる。
structure Point (α : Type u) where
x : α
y : α
deriving Repr
def p : Point Nat :=
{ x := 1, y := 2 }
#eval { p with y := 3 } -- { x := 1, y := 3 }
#eval { p with x := 4 } -- { x := 4, y := 2 }
structure Point3 (α : Type u) where
x : α
y : α
z : α
def q : Point3 Nat :=
{ x := 5, y := 5, z := 5 }
def r : Point3 Nat :=
{ p, q with x := 6 }
example : r.x = 6 := rfl
example : r.y = 2 := rfl
example : r.z = 5 := rfl
Inheritance (継承)
新しいフィールドを追加することで、既存の構造体をextend(拡張)させることができる。この機能によって、一種のinheritance(継承)をシミュレートすることができる。
structure Point (α : Type u) where
x : α
y : α
inductive Color where
| red | green | blue
structure ColorPoint (α : Type u) extends Point α where
c : Color
次の例では、多重継承(複数の親構造体を一度に継承すること)を使って新しい構造体 RedGreenPoint
を定義し、各親構造体のオブジェクトを使って RedGreenPoint
型のオブジェクトを定義する。
structure Point (α : Type u) where
x : α
y : α
z : α
structure RGBValue where
red : Nat
green : Nat
blue : Nat
structure RedGreenPoint (α : Type u) extends Point α, RGBValue where
no_blue : blue = 0
def p : Point Nat :=
{ x := 10, y := 10, z := 20 }
def rgp : RedGreenPoint Nat :=
{ p with red := 200, green := 40, blue := 0, no_blue := rfl }
example : rgp.x = 10 := rfl
example : rgp.red = 200 := rfl
Type Classes (型クラス)
型クラスは、関数型プログラミング言語においてアドホックな多相性を実現する原理的な方法として導入された。まず、次のことを見る: もし関数が型固有の加法の実装を引数として取り、残りの引数に対してその加法の実装を呼び出すだけであれば、加法のようなアドホックな多相性関数を実装するのは簡単である。例えば、Leanで加法の実装を保持する構造体を宣言したとしよう。
namespace Ex
structure Add (a : Type) where
add : a → a → a
#check @Add.add
-- Add.add : {a : Type} → Add a → a → a → a
-- `Add.add` はstructure宣言によって自動生成される射影関数
end Ex
このLeanコードでは、add
フィールドへの射影関数は Add.add : {a : Type} → Add a → a → a → a
という型を持っている。ここで、型 a
を囲んでいる波括弧は、a
が暗黙の引数であることを示している。次のようにして double
関数を実装することができる:
namespace Ex
structure Add (a : Type) where
add : a → a → a
def double (s : Add a) (x : a) : a :=
s.add x x
#eval double { add := Nat.add } 10
-- 20
#eval double { add := Nat.mul } 10
-- 100
#eval double { add := Int.add } 10
-- 20
end Ex
double { add := Nat.add } n
と書くことで自然数 n
を2倍することができる。もちろん、このように手動で実装(を保持するレコード)を渡すのはユーザーにとって非常に面倒である。実際、そのような面倒さがあれば、アドホック多相性の潜在的な利点のほとんどを失うことになる。
型クラスの主な考え方は、まず Add a
のような型の引数を暗黙にすることである。それから、ユーザーが定義したインスタンスを保管するデータベースを使用して、typeclass resolution(型クラス解決)として知られるプロセスを通じて、目的のインスタンス {s : Add a}
を自動合成することである。Leanでは、上の例で structure
を class
に書き換えることで、Add.add
の型は次のように変化する:
namespace Ex
class Add (a : Type) where
add : a → a → a
#check @Add.add
-- Add.add : {a : Type} → [self : Add a] → a → a → a
end Ex
ここで、角括弧 []
は Add a
型の引数がinstance implicit(インスタンス暗黙引数)であること、つまり型クラス解決を使って合成されるべきであることを示している。class
宣言によって自動生成された add
射影関数は、Haskellの add :: Add a => a -> a -> a
のLean版である。そして、ユーザー定義インスタンスは次のように登録できる:
namespace Ex
class Add (a : Type) where
add : a → a → a
instance : Add Nat where
add := Nat.add
instance : Add Int where
add := Int.add
instance : Add Float where
add := Float.add
end Ex
インスタンスの登録後、n : Nat
と m : Nat
に対して、Add.add n m
という項は、Add Nat
型のインスタンス合成を目標とする型クラス解決を引き起こす。型クラス解決は上で instance
宣言を用いて登録した Add Nat
のインスタンスを参照し、目標のインスタンスを合成する。インスタンス暗黙引数を使って、double
を再実装することができる:
namespace Ex
class Add (a : Type) where
add : a → a → a
instance : Add Nat where
add := Nat.add
instance : Add Int where
add := Int.add
instance : Add Float where
add := Float.add
def double [Add a] (x : a) : a :=
Add.add x x
#check @double
-- @double : {a : Type} → [inst : Add a] → a → a
#eval double 10
-- 20
#eval double (10 : Int)
-- 20
#eval double (7 : Float)
-- 14.000000
#eval double (239.0 + 2)
-- 482.000000
end Ex
一般的に、インスタンスは複雑な方法で他のインスタンスに依存することがある。例えば、「もし a
が加法を持つなら、Array a
も加法を持つ」と主張する(無名)インスタンスを宣言することができる:
instance [Add a] : Add (Array a) where
add x y := Array.zipWith x y (· + ·)
#eval Add.add #[1, 2] #[3, 4]
-- #[4, 6]
#eval #[1, 2] + #[3, 4]
-- #[4, 6]
Leanにおいて (· + ·)
は fun x y => x + y
の略記であることに注意してほしい。
上記の例では、記法をオーバーロード(多重定義)するために型クラスを使う方法を実践した。別の応用例も見てみよう。まず、Leanにおいて、型は項を1つも持たないことがあることを思い出してほしい。しかし、型が有項なら、その型について様々なことができるようになる。Leanを使っていると、ある型の任意の項が必要になることがよくある。例えば、「コーナーケース」において任意の項を返す関数を定義したいと思うことがよくある。また、xs
が List a
型を持つとき、head xs
は a
型を持ってほしいと思うかもしれない。同様に、型が空でないという付加的な仮定の下では、多くの付加的な定理が成立する。例えば、a
が型であるとき、exists x : a, x = x
が成立するためには a
が空でないことが必要である。標準ライブラリは、有項型のdefault要素を推論できるようにするために、Inhabited
という型クラスを定義している。今述べた応用例を実践するために、まず適切なクラスを宣言することから始めよう:
namespace Ex
class Inhabited (a : Type u) where
default : a
#check @Inhabited.default
-- Inhabited.default : {a : Type u} → [self : Inhabited a] → a
end Ex
Inhabited.default
は明示的な引数を持たないことに注意してほしい。
Inhabited a
クラスの項とは、ある項 x : a
に対する Inhabited.mk x
という形の式である。射影 Inhabited.default
を使えば、Inhabited a
の項から a
の項 x
を「抽出」することができる。次に、このクラスにいくつかのインスタンスを登録する:
namespace Ex
class Inhabited (a : Type _) where
default : a
instance : Inhabited Bool where
default := true
instance : Inhabited Nat where
default := 0
instance : Inhabited Unit where
default := ()
instance : Inhabited Prop where
default := True
#eval (Inhabited.default : Nat)
-- 0
#eval (Inhabited.default : Bool)
-- true
end Ex
export
コマンドを使うと、Inhabited.default
に対して別名 default
を生成することができる(正確には、export
コマンドは名前空間 Foo
内で Bar.baz
に対して別名 Foo.baz
を生成する)。
namespace Ex
class Inhabited (a : Type _) where
default : a
instance : Inhabited Bool where
default := true
instance : Inhabited Nat where
default := 0
instance : Inhabited Unit where
default := ()
instance : Inhabited Prop where
default := True
export Inhabited (default)
#eval (default : Nat)
-- 0
#eval (default : Bool)
-- true
end Ex
Chaining Instances (インスタンスの連鎖)
型クラス推論で出来ることがこれで終わりだとしたら、それほど印象的なものではないだろう。もしそうなら、型クラス推論はユーザー定義インスタンスを保存して、elaboratorがルックアップテーブル(配列や連想配列などのデータ構造)からそれらを見つけられるようにする仕組みに過ぎないからである。型クラス推論が強力なのは、インスタンスを「連鎖」させることができるからである。つまり、インスタンス宣言は、他の型クラスの暗黙のインスタンスに依存することができる。これにより、型クラス推論は、Prolog-likeな探索を用いて、必要に応じてバックトラッキングしながら、再帰的にインスタンスを連鎖させることができる。
例えば、次の定義は、2つの型 a
と b
が有項なら、その直積型 a × b
も有項であることを示している:
instance [Inhabited a] [Inhabited b] : Inhabited (a × b) where
default := (default, default)
前節のインスタンス宣言に今の宣言を加えることで、例えば Nat × Bool
のデフォルト項を推論できるようになる:
namespace Ex
class Inhabited (a : Type u) where
default : a
instance : Inhabited Bool where
default := true
instance : Inhabited Nat where
default := 0
opaque default [Inhabited a] : a :=
Inhabited.default
instance [Inhabited a] [Inhabited b] : Inhabited (a × b) where
default := (default, default)
#eval (default : Nat × Bool)
-- (0, true)
end Ex
同様に、適切な定数関数の存在により、型 b
が有項なら関数型 a → b
も有項であることを示すことができる:
instance [Inhabited b] : Inhabited (a → b) where
default := fun _ => default
練習として、List
型や Sum
型などの他の型のデフォルトインスタンスを定義してみよう。
Leanの標準ライブラリには inferInstance
という定義がある。これは型 {α : Sort u} → [i : α] → α
を持ち、期待される型がインスタンスを持つときに型クラス解決手続きを実行させるのに便利である。
#check (inferInstance : Inhabited Nat) -- Inhabited Nat
def foo : Inhabited (Nat × Nat) :=
inferInstance
#eval foo.default -- (0, 0)
theorem ex : foo.default = (default, default) :=
rfl
#print
コマンドを使うと、inferInstance
がいかにシンプルかを見ることができる。
#print inferInstance
ToString (ToString
型クラス)
ToString
型クラスの多相メソッド toString
は型 {α : Type u} → [ToString α] → α → String
を持つ。ユーザー定義の型 Foo
に対して型 ToString Foo
のインスタンスを宣言すると、連鎖を利用して複雑な値を文字列に変換することができる。Leanでは、ほとんどのビルトイン型 α
について ToString α
のインスタンスが付属している。
structure Person where
name : String
age : Nat
instance : ToString Person where
toString p := p.name ++ "@" ++ toString p.age
#eval toString { name := "Leo", age := 542 : Person }
#eval toString ({ name := "Daniel", age := 18 : Person }, "hello") -- `instToStringProd` と `instToStringPerson` の連鎖
Numerals (数字)
Leanでは数字は多相である。型クラス OfNat
に関するインスタンスを持つ任意の型の項を表すために、数字(例えば 2
)を使うことができる。
structure Rational where
num : Int
den : Nat
inv : den ≠ 0
instance instOfNatRational (n : Nat) : OfNat Rational n where
ofNat := { num := n, den := 1, inv := by decide }
instance : ToString Rational where
toString r := s!"{r.num}/{r.den}"
#eval (2 : Rational) -- 2/1
#check (2 : Rational) -- Rational
#check (2 : Nat) -- Nat
#check @OfNat.ofNat Nat 2 (instOfNatNat 2) -- Nat
#check @OfNat.ofNat Rational 2 (instOfNatRational 2) -- Rational
Leanのelaboratorは、項 (2 : Nat)
と (2 : Rational)
をそれぞれ @OfNat.ofNat Nat 2 (instOfNatNat 2)
と @OfNat.ofNat Rational 2 (instOfNatRational 2)
に変換する。変換後の項中に出現する数字 2
は「生の」自然数と呼ばれる。マクロ nat_lit 2
を使うと生の自然数 2
を入力することができる。
#check nat_lit 2 -- Nat
生の自然数は多相ではない。
OfNat
インスタンスは生の自然数を引数に取る。そのため、特定の数字に対して OfNat
のインスタンスを定義することができる。OfNat
型クラスの2番目の引数は、上の例のように変数であることが多いが、生の自然数の場合もある。
class Monoid (α : Type u) where
unit : α
op : α → α → α
instance [s : Monoid α] : OfNat α (nat_lit 1) where
ofNat := s.unit
def getUnit [Monoid α] : α :=
1
Output Parameters (出力パラメータ)
デフォルトでは、Leanは型 T
のデフォルト項が既知であり、T
が欠落部分を含まない場合にのみ、Inhabited T
のインスタンスを合成しようとする。次のコマンドは、型が欠落部分(つまり _
)を含むため、"failed to create type class instance for Inhabited (Nat × ?m.1499)" というエラーを生成する。
#check_failure (inferInstance : Inhabited (Nat × _))
Inhabited
型クラスのパラメータは、Inhabited
型クラスのコンストラクタの「入力」の型とみなすことができる。型クラスが複数のパラメータを持つ場合、そのうちのいくつかを出力パラメータ(型クラスのインスタンス合成時に、既に与えられた型から推論される型)としてマークすることができる。出力パラメータの推論のために使われる型は入力パラメータと呼ばれる。出力パラメータに欠落部分があっても、Leanは型クラスのインスタンス合成を開始する。以下の例では、出力パラメータを使ってheterogeneous(異種)多相乗法を定義している。
namespace Ex
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
hMul : α → β → γ
export HMul (hMul)
instance : HMul Nat Nat Nat where
hMul := Nat.mul
instance : HMul Nat (Array Nat) (Array Nat) where
hMul a bs := bs.map (fun b => hMul a b)
#eval hMul 4 3 -- 12
#eval hMul 4 #[2, 3, 4] -- #[8, 12, 16]
end Ex
パラメータ α
と β
は入力パラメータ、γ
は出力パラメータとみなされる。関数適用 hMul a b
が与えられると、a
と b
の型が分かっているなら型クラスインスタンス合成器が呼び出され、型クラスインスタンス合成器は hMul a b
の出力の型情報を(入力パラメータ α
と β
から推論された)出力パラメータ γ
から得る。上の例では、2つのインスタンスを定義した。最初のインスタンスは自然数上の同種乗法である。2つ目のインスタンスは配列のスカラー倍である。2つ目のインスタンスの定義では、インスタンスの連鎖が使われていることに注意してほしい。
namespace Ex
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
hMul : α → β → γ
export HMul (hMul)
instance : HMul Nat Nat Nat where
hMul := Nat.mul
instance : HMul Int Int Int where
hMul := Int.mul
instance [HMul α β γ] : HMul α (Array β) (Array γ) where
hMul a bs := bs.map (fun b => hMul a b)
#eval hMul 4 3 -- 12
#eval hMul 4 #[2, 3, 4] -- #[8, 12, 16]
#eval hMul (-2) #[3, -1, 4] -- #[-6, 2, -8]
#eval hMul 2 #[#[2, 3], #[0, 4]] -- #[#[4, 6], #[0, 8]]
end Ex
この新しい配列スカラー倍インスタンスは、HMul α β γ
のインスタンスがあれば、いつでも Array β
型の配列と α
型のスカラーに対して使用することができる。最後の #eval
では、HMul Nat Nat Nat
のインスタンスから HMul Nat (Array Nat) (Array Nat)
のインスタンスが合成され、HMul Nat (Array Nat) (Array Nat)
のインスタンスから HMul Nat (Array (Array Nat)) (Array (Array Nat))
のインスタンスが合成されていることに注意してほしい。
Default Instances (デフォルトインスタンス)
型クラス HMul
では、パラメータ α
と β
は入力パラメータとして扱われる。したがって、型クラスインスタンス合成はこれら2つの型が特定されてから開始される。場合によっては、この制約は強すぎるかもしれない。
namespace Ex
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
hMul : α → β → γ
export HMul (hMul)
instance : HMul Int Int Int where
hMul := Int.mul
def xs : List Int := [1, 2, 3]
-- Error "typeclass instance problem is stuck, it is often due to metavariables HMul ?m.89 ?m.90 ?m.91"
-- `y` の型を明示的に与えればエラーは消える
#check_failure fun y => xs.map (fun x => hMul x y)
end Ex
y
の型が与えられていないため、HMul
のインスタンスはLeanによって合成されない。しかし、このような状況では y
と x
の型は同じはずだと考えるのが自然である。default instances(デフォルトインスタンス)を使えば、まさにそれを実現できる。
namespace Ex
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
hMul : α → β → γ
export HMul (hMul)
@[default_instance]
instance : HMul Int Int Int where
hMul := Int.mul
def xs : List Int := [1, 2, 3]
#check fun y => xs.map (fun x => hMul x y) -- Int → List Int
#eval (fun y => xs.map (fun x => hMul x y)) 3 -- [3, 6, 9]
end Ex
上記のインスタンスに default_instance
属性を付けることで、保留中の型クラスインスタンス合成問題においてこのインスタンスを使用するようLeanに指示することができる。実際のLeanの実装では、各算術演算子について同種型クラス(Add
など)と異種型クラス(HAdd
など)が定義されている。さらに言うと、a+b
、a*b
、a-b
、a/b
、a%b
は異種版演算子を表す。OfNat Nat n
のインスタンスは OfNat
型クラスのデフォルトインスタンス(優先度100)である。これが、期待される型が不明な場合に、数字 2
が Nat
型を持つ理由である。より高い優先度を持つデフォルトインスタンスを定義することで、ビルトインのデフォルトインスタンスをオーバーライドすることができる。
structure Rational where
num : Int
den : Nat
inv : den ≠ 0
@[default_instance 200]
instance : OfNat Rational n where
ofNat := { num := n, den := 1, inv := by decide }
instance : ToString Rational where
toString r := s!"{r.num}/{r.den}"
#check 2 -- Rational
優先度は、異なるデフォルトインスタンス間の相互作用を制御するのにも便利である。例えば、xs
が List α
型を持つとする。elaboratorが式 xs.map (fun x => 2 * x)
を解釈するとき、この式が多相性を持つために、同種乗法のデフォルトインスタンスが OfNat
のデフォルトインスタンスより高い優先度を持つようにしたい。これは、HMul α α α
のインスタンスを実装し、HMul Nat α α
のインスタンスは実装しなかった場合に特に重要である。ここで、Leanにおいて表記 a*b
がどう定義されているかを種明かしする。
namespace Ex
class OfNat (α : Type u) (n : Nat) where
ofNat : α
@[default_instance]
instance (n : Nat) : OfNat Nat n where
ofNat := n
class HMul (α : Type u) (β : Type v) (γ : outParam (Type w)) where
hMul : α → β → γ
class Mul (α : Type u) where
mul : α → α → α
@[default_instance 10]
instance [Mul α] : HMul α α α where
hMul a b := Mul.mul a b
infixl:70 " * " => HMul.hMul
end Ex
Mul
型クラスは、同種乗法は実装されているが異種乗法は実装されていない型を扱う際に便利である。
Local Instances (ローカルインスタンス)
Leanにおいて、型クラスのインスタンスは属性を用いて実装される。そのため、local
修飾子を使うことで、そのインスタンスが現在のセクションや名前空間が閉じられるまで、あるいは現在のファイルの終わりまでしか効果がないことを示すことができる。
structure Point where
x : Nat
y : Nat
section
local instance : Add Point where
add a b := { x := a.x + b.x, y := a.y + b.y }
def double (p : Point) :=
p + p
end -- もうインスタンス `Add Point` は使えない
-- def triple (p : Point) :=
-- p + p + p -- Error: failed to synthesize instance
コマンド attribute [-instance]
を使えば、現在のセクションや名前空間が閉じられるまで、あるいは現在のファイルの終わりまで、指定したインスタンスを一時的に無効化することもできる。
structure Point where
x : Nat
y : Nat
instance addPoint : Add Point where
add a b := { x := a.x + b.x, y := a.y + b.y }
def double (p : Point) :=
p + p
attribute [-instance] addPoint
-- def triple (p : Point) :=
-- p + p + p -- Error: failed to synthesize instance
コマンド attribute [-instance]
は問題の分析時にのみ使うことを勧める。
Scoped Instances (スコープ付きインスタンス)
scoped instance
を用いてスコープ付きのインスタンスを宣言することもできる。スコープ付きインスタンスは、名前空間の中にいるとき、または名前空間を開いているときのみ使用可能である。
structure Point where
x : Nat
y : Nat
namespace Point
scoped instance : Add Point where
add a b := { x := a.x + b.x, y := a.y + b.y }
def double (p : Point) :=
p + p
end Point
-- インスタンス `Add Point` はもう使えない
-- #check fun (p : Point) => p + p + p -- Error
namespace Point
-- インスタンス `Add Point` は再び利用可能になった
#check fun (p : Point) => p + p + p
end Point
open Point -- 名前空間を開き、インスタンス `Add Point` を利用可能にする
#check fun (p : Point) => p + p + p
コマンド open scoped <namespace>
を使うと、名前空間 <namespace>
内の scoped
属性のついた定義が利用可能になるが、名前空間 <namespace>
内のそれ以外の定義に短い別名でアクセスすることはできない。
structure Point where
x : Nat
y : Nat
namespace Point
scoped instance : Add Point where
add a b := { x := a.x + b.x, y := a.y + b.y }
def double (p : Point) :=
p + p
end Point
open scoped Point -- インスタンス `Add Point` を利用可能にする
#check fun (p : Point) => p + p + p
-- #check fun (p : Point) => double p -- Error: unknown identifier 'double'
Decidable Propositions (決定可能命題)
標準ライブラリで定義されている、命題に関する型クラス Decidable
について考えてみよう。大雑把に言えば、Prop
型の項(具体的な命題)は、それが真か偽かを決めることができる場合、決定可能であると言われる。古典論理において全ての命題は決定可能であるため、命題が決定可能かどうかという区別は構成的論理においてのみ有用である。古典論理を使っているかどうかの区別は重要である。古典論理を使って、例えば場合分けによって関数を定義すると、その関数は計算不能になる。
variable (p : Nat → Prop)
-- error : failed to synthesize instance
-- Decidable (p n)
/-
def bad_foo : Nat → Bool :=
fun (n : Nat) =>
if p n then true
else false
-/
open Classical
noncomputable def foo : Nat → Bool :=
fun (n : Nat) =>
if p n then true
else false
#print axioms foo
-- 'foo' depends on axioms: [Classical.choice, Quot.sound, propext]
アルゴリズム的に言えば、Decidable
型クラスは、その命題が真か偽かを効果的に決定する手続きを推論するために使うことができる。結果として、この型クラスはある定義が計算可能なときその定義をサポートすると同時に、古典論理を使った定義や推論の使用へのスムーズな移行を可能にする。
標準ライブラリでは、Decidable
は次のように形式的に定義されている:
namespace Hidden
class inductive Decidable (p : Prop) where
| isFalse (h : ¬p) : Decidable p
| isTrue (h : p) : Decidable p
end Hidden
論理的に言えば、項 t : Decidable p
を持つことは、証明項 t : p ∨ ¬p
を持つことよりも強い。項 t : Decidable p
の存在は、p
の真理値に依存して任意の型の値や関数を定義することを可能にする。例えば、if p then a else b
という式が意味をなすには、p
が決定可能であることを知っている必要がある。ここで if p then a else b
は ite p a b
の糖衣構文である。ite
は次のように定義される:
namespace Hidden
def ite {α : Sort u} (c : Prop) [h : Decidable c] (t e : α) : α :=
Decidable.casesOn (motive := fun _ => α) h (fun _ => e) (fun _ => t)
end Hidden
標準ライブラリは dite
と呼ばれる ite
の変形版を持っている。dite
は依存版if-then-else式である。これは次のように定義される:
namespace Hidden
def dite {α : Sort u} (c : Prop) [h : Decidable c] (t : c → α) (e : Not c → α) : α :=
Decidable.casesOn (motive := fun _ => α) h e t
end Hidden
つまり、dite c t e
において、「then」分岐では hc : c
を、「else」分岐では hnc : ¬ c
を仮定できる。Leanでは、dite
をより使いやすくするために、dite c (λ h : c => t) (λ h : ¬ c => e)
の代わりに if h : c then t else e
と書くことができる。
古典論理がなければ、全ての命題が決定可能であることを証明することはできない。しかし、ある命題が決定可能であることは証明できる。例えば、自然数や整数に関する等式や不等式のような基本関係の決定可能性は古典論理なしで証明できる。さらに、決定可能性は命題結合子の適用前後で保存される:
#check @instDecidableAnd
-- {p q : Prop} → [Decidable p] → [Decidable q] → Decidable (And p q)
#check @instDecidableOr
#check @instDecidableNot
したがって、自然数上の決定可能述語を用いた場合分けによって関数を定義することができる:
def step (a b x : Nat) : Nat :=
if x < a ∨ x > b then 0 else 1
set_option pp.explicit true -- 暗黙の引数を表示する
#print step
暗黙の引数の表示をオンにすると、elaboratorは適切なインスタンス instDecidableOr
と Nat.decLt
を適用しただけで、命題 x < a ∨ x > b
の決定可能性を推論したことがわかる。
古典論理の公理を使うと、全ての命題が決定可能であることが証明できる。Classical
名前空間を開くと、古典論理の公理がインポートされ、全ての命題 p
に対して Decidable p
のインスタンスが利用できるようになる。
open Classical
したがって、古典論理的に推論したい場合、決定可能性を前提とするライブラリ内の定理は、全て自由に利用できる。12章 Axioms and Computation (公理と計算)では、排中律を使って関数を定義すると、その関数が計算に使われなくなることがあることを説明する。したがって、標準ライブラリでは Classical.propDecidable
インスタンスに低い優先度を割り当てている。
namespace Hidden
open Classical
noncomputable scoped
instance (priority := low) propDecidable (a : Prop) : Decidable a :=
choice <| match em a with
| Or.inl h => ⟨isTrue h⟩
| Or.inr h => ⟨isFalse h⟩
end Hidden
これは、Decidable p
の型クラス解決において、Leanが他のインスタンスを優先し、他の試みが全て失敗した後にのみインスタンス propDecidable p
を使うことを保証する。
Decidable
型クラスは、定理証明の小規模な自動化もいくつか提供している。標準ライブラリには、Decidable
のインスタンスを使って単純なゴールを解くタクティク decide
が含まれている。
example : 10 < 5 ∨ 1 > 0 := by
decide
example : ¬ (True ∧ False) := by
decide
example : 10 * 20 = 200 := by
decide
theorem ex : True ∧ 2 = 1+1 := by
decide
#print ex
-- theorem ex : True ∧ 2 = 1 + 1 :=
-- of_decide_eq_true (Eq.refl true)
#check @of_decide_eq_true
-- ∀ {p : Prop} [Decidable p], decide p = true → p
#check @decide
-- (p : Prop) → [Decidable p] → Bool
これらは次のように動作する。式 decide p
は p
の決定可能性を用いて p
の真偽決定手続きの推論を試み、成功すれば decide p
は true
か false
のどちらかに評価される。特に、p
が正しい閉論理式である場合、decide p
はdefinitionallyにブール値 true
に簡約される。decide p = true
が成立するという前提を受け取ると、of_decide_eq_true
は p
の証明を出力する。ターゲット p
を証明するために以上の過程をまとめたものが decide
タクティクである。前述した内容により、decide
は、推論された c
の真偽決定手続きが、c
が isTrue
の場合であることをdefinitionallyに評価するために十分な情報を持っていれば、いつでも成功する。
Managing Type Class Inference (型クラス推論の管理)
Leanの型クラス推論によって合成できる式 t
を提供する必要があるとき、inferInstance
を使うと t
を推論によって合成するようLeanに依頼することができる:
def foo : Add Nat := inferInstance
def bar : Inhabited (Nat → Nat) := inferInstance
#check @inferInstance
-- {α : Sort u} → [α] → α
Leanの (t : T)
記法を使えば、今探している t
がどの型クラス T
のインスタンスなのかを簡潔に指定することができる:
#check (inferInstance : Add Nat)
型 T
を引数に取る補助定義 inferInstanceAs
を使うこともできる:
#check inferInstanceAs (Add Nat)
#check @inferInstanceAs
-- (α : Sort u) → [α] → α
型クラスが定義の下に埋もれているために、Leanがインスタンスを見つけられないことがある。例えば、単に inferInstance
を使うだけで Inhabited (Set α)
のインスタンスを見つけることはできない。この場合、定義を使って Set α
を α → Prop
に書き下し、それを明示的に与えればよい:
def Set (α : Type u) := α → Prop
-- fails
-- example : Inhabited (Set α) :=
-- inferInstance
instance : Inhabited (Set α) :=
inferInstanceAs (Inhabited (α → Prop))
-- 別解
example : Inhabited (Set α) :=
@inferInstance (Inhabited (α → Prop)) _
時には、型クラス推論が期待されるインスタンスを見つけることに失敗したり、最悪の場合、無限ループに陥ってタイムアウトすることがある。このような状況でのデバッグを手伝ってもらうために、Leanにインスタンス探索の追跡を依頼することができる:
set_option trace.Meta.synthInstance true
VSCodeを使用している場合は、関連する定理や定義にカーソルを合わせるか、Ctrl-Shift-Enter
によりメッセージウィンドウを開くことで、追跡結果を読むことができる。Emacsでは、C-c C-x
によりファイルと独立したLeanプロセスを実行することができる。その後、出力バッファには型クラス解決手順が起きるたびに追跡内容が表示される。
次のオプションを使ってインスタンス探索を制限することもできる:
set_option synthInstance.maxHeartbeats 10000
set_option synthInstance.maxSize 400
synthInstance.maxHeartbeats
オプションは、型クラス解決問題ごとのheartbeatsの最大量を指定する。heartbeatsとは(小さな)メモリ割り当ての数(1000単位)であり、synthInstance.maxHeartbeats 0
は制限がないことを意味する。synthInstance.maxSize
オプションは、型クラスインスタンス合成過程で解を構築するために使われるインスタンスの最大数を指定する。
VSCodeでもEmacsでも、set_option
の中でタブ補完が機能することを覚えてほしい。タブ補完は適切なオプションを探すのに役立つ。
上述したように、与えられたコンテキストでの型クラスインスタンス合成はProlog-likeなプログラムであり、これはバックトラック探索を生じさせる。プログラムの効率も発見される解も、システムがインスタンスを探索する順番に依存して変化する。探索においては、最後に宣言されたインスタンスから順番に探索される。さらに、インスタンスが他のモジュール(ファイル)で宣言されている場合、インスタンスが探索される順番は名前空間を開いた順番に依存する。後に開いた名前空間で宣言されたインスタンスほど先に探索される。
型クラスのインスタンスに「優先度」を割り当てることで、探索される順番を変更することができる。普通にインスタンスが宣言されると、そのインスタンスにはデフォルトの優先度が割り当てられる。インスタンスを定義するとき、デフォルト以外の優先度を割り当てることができる。次の例はその方法を示している:
class Foo where
a : Nat
b : Nat
instance (priority := default+1) i1 : Foo where
a := 1
b := 1
instance i2 : Foo where
a := 2
b := 2
example : Foo.a = 1 :=
rfl
instance (priority := default+2) i3 : Foo where
a := 3
b := 3
example : Foo.a = 3 :=
rfl
Coercions using Type Classes (型クラスを用いた強制)
最も基本的なタイプの(型)強制は、ある型の項を別の型の項にマッピングする。例えば、Nat
型から Int
型への強制は、任意の項 n : Nat
を Int
の項とみなすことを可能にする。しかし、いくつかの強制はより複雑で、パラメータに依存する。例えば、任意の型 α
に対して、任意の項 as : List α
を Set α
の項、つまりリストに出現する α
の項全体からなる集合とみなすことが可能である。これに対応する強制は、α
によってパラメータ化された型の「族」List α
上で定義される。
Leanでは3種類の強制を宣言することができる:
- ある型の族から他の型の族への強制
- ある型の族からSortのクラスへの強制
- ある型の族から関数型のクラスへの強制
1種類目の強制は、強制元の族に属する型の任意の「項」を、強制先の族に属する対応する型の「項」として見ることを可能にする。2種類目の強制は、強制元の族に属する型の任意の「項」を「型」として見ることを可能にする。3種類目の強制は、強制元の族に属する型の任意の「項」を「関数」として見ることを可能にする。それぞれを順番に考えてみよう:
instance : Coe Bool Prop where
coe b := b = true
この強制により、if-then-else式の条件の中でブール型の項を使うことができる:
#eval if true then 5 else 3
#eval if false then 5 else 3
List α
から Set α
への強制は次のように定義される:
def Set (α : Type u) := α → Prop
def Set.empty {α : Type u} : Set α := fun _ => False
def Set.mem (a : α) (s : Set α) : Prop := s a
def Set.singleton (a : α) : Set α := fun x => x = a
def Set.union (a b : Set α) : Set α := fun x => a x ∨ b x
notation "{ " a " }" => Set.singleton a
infix:55 " ∪ " => Set.union
def List.toSet : List α → Set α
| [] => Set.empty
| a::as => {a} ∪ as.toSet
instance : Coe (List α) (Set α) where
coe a := a.toSet
def s : Set Nat := {1}
#check s ∪ [2, 3]
-- s ∪ List.toSet [2, 3] : Set Nat
特定の場所に明示的に強制を導入するために、↑
という記法を使うことができる。この記法は書き手の意図を明確にすることや、強制解決システムの制限を回避することにも役立つ。
def Set (α : Type u) := α → Prop
def Set.empty {α : Type u} : Set α := fun _ => False
def Set.mem (a : α) (s : Set α) : Prop := s a
def Set.singleton (a : α) : Set α := fun x => x = a
def Set.union (a b : Set α) : Set α := fun x => a x ∨ b x
notation "{ " a " }" => Set.singleton a
infix:55 " ∪ " => Set.union
def List.toSet : List α → Set α
| [] => Set.empty
| a::as => {a} ∪ as.toSet
instance : Coe (List α) (Set α) where
coe a := a.toSet
def s : Set Nat := {1}
#check let x := ↑[2, 3]; s ∪ x
-- let x := List.toSet [2, 3]; s ∪ x : Set Nat
#check let x := [2, 3]; s ∪ x
-- let x := [2, 3]; s ∪ List.toSet x : Set Nat
Leanは CoeDep
型クラスを使った依存強制もサポートしている。例えば、Prop
型の任意の項 p
をBool型の項に強制することはできないが、Decidable p
型クラスがインスタンスを持つような p
だけはBool型の項に強制できる。
instance (p : Prop) [Decidable p] : CoeDep Prop p Bool where
coe := decide p
Leanは必要に応じて(非依存)強制を連鎖させる。実際、型クラス CoeT
は Coe
の推移的閉包である。
次に、2種類目の強制について考えよう。「Sortのクラス」とは、宇宙 Type u
の集まりを意味する。2種類目の強制は次のような形をとる:
c : (x1 : A1) → ... → (xn : An) → F x1 ... xn → Type u
ここで、F
は型の族であり、F x1 ... xn
は型の族に属する特定の型である。この種類の強制により、t
が型 F a1 ... an
の項であるときは、いつでも s : t
と書くことができる。言い換えれば、この強制は F a1 ... an
の項を型として見ることを可能にする。これは、構造体の1つの要素、つまり構造の台(集合) carrier
が型であるような代数的構造を定義するときに非常に便利である。例えば、semigroup(半群)は次のように定義できる:
structure Semigroup where
carrier : Type u
mul : carrier → carrier → carrier
mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
mul a b := S.mul a b
つまり、半群は型 carrier
、乗法 mul
、「乗法は結合的である」という性質の3要素からなる。上記の instance
コマンドは、a b : S.carrier
があるとき、Semigroup.mul S a b
を a * b
と略記することを可能にする。Leanは a
と b
の型からインスタンスの引数 S
を推測できることに注意してほしい。関数 Semigroup.carrier
はクラス Semigroup
の項(半群)を Type u
の項(型)にマッピングする:
structure Semigroup where
carrier : Type u
mul : carrier → carrier → carrier
mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
mul a b := S.mul a b
#check Semigroup.carrier
この関数は強制であると宣言すれば、半群 S : Semigroup
があるときはいつでも、a : S.carrier
を a : S
と略記することができる:
structure Semigroup where
carrier : Type u
mul : carrier → carrier → carrier
mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
mul a b := S.mul a b
instance : CoeSort Semigroup (Type u) where
coe s := s.carrier
example (S : Semigroup) (a b c : S) : (a * b) * c = a * (b * c) :=
Semigroup.mul_assoc _ a b c
上記の CoeSort Semigroup (Type u)
のインスタンスは、(a b c : S)
と書くことを可能にする強制である。ここで、2種類目の強制では Coe Semigroup (Type u)
ではなく CoeSort Semigroup (Type u)
のインスタンスを定義したことに注意してほしい。
最後に、3種類目の強制について考えよう。「関数型のクラス」とは、依存関数型(パイ型) (z : B) → C
の集まりを意味する。3種類目の強制は次のような形をとる:
c : (x1 : A1) → ... → (xn : An) → (y : F x1 ... xn) → (z : B) → C
ここで、F
は再び型の族であり、F x1 ... xn
は型の族に属する特定の型である。B
と C
は x1, ..., xn, y
に依存することができる。この種類の強制により、t
が F a1 ... an
の項であるときは、いつでも t s
と書くことができる。言い換えれば、この強制は F a1 ... an
の項を関数として見ることを可能にする。上記の例の続きとして、半群 S1
と S2
の間のmorphism(射)あるいはhomomorphism(準同型)という概念を定義できる。射とは、S1
の台から S2
の台への、乗法を保存する(mor (a * b) = (mor a) * (mor b)
)関数である。次のコードでは、S1
、S2
、*
に関する暗黙の強制に注意してほしい。射影 Morphism.mor
は、構造体 Morphism S1 S2
の項を受け取り、射の台関数を返す:
structure Semigroup where
carrier : Type u
mul : carrier → carrier → carrier
mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
mul a b := S.mul a b
instance : CoeSort Semigroup (Type u) where
coe s := s.carrier
structure Morphism (S1 S2 : Semigroup) where
mor : S1 → S2
resp_mul : ∀ a b : S1, mor (a * b) = (mor a) * (mor b)
#check @Morphism.mor
以下のコードにより、半群間の射は3種類目の強制 CoeFun
の代表例となった。
structure Semigroup where
carrier : Type u
mul : carrier → carrier → carrier
mul_assoc (a b c : carrier) : mul (mul a b) c = mul a (mul b c)
instance (S : Semigroup) : Mul S.carrier where
mul a b := S.mul a b
instance : CoeSort Semigroup (Type u) where
coe s := s.carrier
structure Morphism (S1 S2 : Semigroup) where
mor : S1 → S2
resp_mul : ∀ a b : S1, mor (a * b) = (mor a) * (mor b)
instance (S1 S2 : Semigroup) : CoeFun (Morphism S1 S2) (fun _ => S1 → S2) where
coe m := m.mor
theorem resp_mul {S1 S2 : Semigroup} (f : Morphism S1 S2) (a b : S1)
: f (a * b) = f a * f b :=
f.resp_mul a b
example (S1 S2 : Semigroup) (f : Morphism S1 S2) (a : S1) :
f (a * a * a) = f a * f a * f a :=
calc f (a * a * a)
_ = f (a * a) * f a := by rw [resp_mul f]
_ = f a * f a * f a := by rw [resp_mul f]
この強制を使えば、f.mor (a * a * a)
を f (a * a * a)
と略記することができる。関数型が期待される場所で Morphism
や f
が使われると、Leanは強制を挿入する。フィールド F
は強制先の関数型を指定するために使われる。F
の型は強制元の型に依存しうる。
まとめると、1種類目の強制のために型クラス Coe
があり、2種類目の強制のために型クラス CoeSort
があり、3種類目の強制のために型クラス CoeFun
がある。
The Conversion Tactic Mode (変換タクティクモード)
タクティクブロックの内部では、conv
キーワードを使うとconversion mode(変換モード)に入ることができる。このモードでは、仮説やターゲットの内部、さらには関数抽象や依存関数型の内部を移動して、その部分項に書き換えや単純化のステップを適用することができる。
用語に関する注意
この節は翻訳に際して追加した節である。
変換モードにおいて、「ターゲット」とは項構築の目標となる型のことではなく、書き換え・単純化の対象のことである。また、「ゴール」は単に1つのターゲットを保持するためにある。
通常のタクティクモードでは、ターゲットは ⊢
の後に書かれる。一方で、変換タクティクモードでは、ターゲットは |
の後に書かれる。
Basic navigation and rewriting (基本的なナビゲーションと書き換え)
最初の例として、example (a b c : Nat) : a * (b * c) = a * (c * b)
を証明してみよう(このページで挙げる例は、他のタクティクを使えばすぐに解ける可能性があるため、多少作為的である)。素朴な最初の一手は、タクティクモードに入って rw [Nat.mul_comm]
を試すことである。しかし、そうするとターゲット内の一番最初に登場する乗法について可換性が適用され、ターゲットが b * c * a = a * (c * b)
に変換されてしまう。この問題を解決する方法はいくつかあるが、より正確なツールである変換モードを使用することは解決法の1つである。次のコードブロックでは、タクティクブロック内の各行の後に現在のターゲットを示している。
example (a b c : Nat) : a * (b * c) = a * (c * b) := by
conv =>
-- ⊢ a * (b * c) = a * (c * b)
lhs
-- ⊢ a * (b * c)
congr
-- 2 goals: ⊢ a, ⊢ b * c
rfl
-- ⊢ b * c
rw [Nat.mul_comm]
上記のスニペット(小さなコード)では次の3つのナビゲーションコマンドを使っている:
lhs
は関係(ここでは等号)の左辺にターゲットを絞る。関係の右辺にターゲットを絞るrhs
コマンドもある。- 現在のターゲット内の先頭の関数(ここでは乗法)が(非依存的かつ明示的な)引数をとるなら、
congr
は先頭の関数を分解し、各引数をターゲットとするゴールを引数の数だけ生成する。 rfl
は現在のターゲットを反射性を使って閉じ、次のゴールに移る。
目的のターゲットに到着したら、通常のタクティクモードと同様に rw
を使うことができる。
変換モードを使う2つ目の主な理由は、束縛のスコープ内で部分項を書き換えることができるからである。例えば、(fun x : Nat => 0 + x) = (fun x => x)
を証明したいとしよう。素朴な最初の一手は、タクティクモードに入って rw [Nat.zero_add]
を試すことである。しかし、これは失敗し、次のようなエラーメッセージを見ていらいらするだろう。
error: tactic 'rewrite' failed, did not find instance of the pattern
in the target expression
0 + ?n
⊢ (fun x => 0 + x) = fun x => x
正しい解法(の一例)はこうである:
example : (fun x : Nat => 0 + x) = (fun x => x) := by
conv =>
lhs
intro x
rw [Nat.zero_add]
ここで、intro x
は fun
の束縛スコープ内に入るナビゲーションコマンドである。この例は多少作為的であることを断っておく。この例は次のように解くこともできる:
example : (fun x : Nat => 0 + x) = (fun x => x) := by
funext x; rw [Nat.zero_add]
あるいは、simp
を使うだけでよい。
example : (fun x : Nat => 0 + x) = (fun x => x) := by
simp
conv at h
を使うと、現在のコンテキスト内の仮説 h
を書き換えることもできる。
Pattern matching (パターンマッチング)
上記のコマンドを使ったナビゲーションは面倒だと思うかもしれない。パターンマッチングを使えば、次のようにショートカットできる:
example (a b c : Nat) : a * (b * c) = a * (c * b) := by
conv in b * c => rw [Nat.mul_comm]
これは次の糖衣構文である:
example (a b c : Nat) : a * (b * c) = a * (c * b) := by
conv =>
pattern b * c
rw [Nat.mul_comm]
もちろん、ワイルドカードも使える:
example (a b c : Nat) : a * (b * c) = a * (c * b) := by
conv in _ * c => rw [Nat.mul_comm]
Structuring conversion tactics (変換タクティク証明の構造化)
conv
モード中も、タクティク証明を構造化するために波括弧と .
を使うことができる。
example (a b c : Nat) : (0 + a) * (b * c) = a * (c * b) := by
conv =>
lhs
congr
. rw [Nat.zero_add]
. rw [Nat.mul_comm]
Other tactics inside conversion mode (変換モードにおける他のタクティク)
arg i
は現在のターゲットのi
番目の非依存的明示的引数にターゲットを絞る。
example (a b c : Nat) : a * (b * c) = a * (c * b) := by
conv =>
-- ⊢ a * (b * c) = a * (c * b)
lhs
-- ⊢ a * (b * c)
arg 2
-- ⊢ b * c
rw [Nat.mul_comm]
-
args
はcongr
の別名である。 -
simp
は現在のターゲットに単純化子を適用する。simp
は通常のタクティクモードと同様のオプションをサポートする。
def f (x : Nat) :=
if x > 0 then x + 1 else x + 2
example (g : Nat → Nat) (h₁ : g x = x + 1) (h₂ : x > 0) : g x = f x := by
conv =>
rhs
simp [f, h₂]
exact h₁
enter [1, x, 2, y]
は与えられた引数を使ってarg
とintro
を繰り返す。これは単なるマクロである:
syntax enterArg := ident <|> group("@"? num)
syntax "enter " "[" (colGt enterArg),+ "]": conv
macro_rules
| `(conv| enter [$i:num]) => `(conv| arg $i)
| `(conv| enter [@$i:num]) => `(conv| arg @$i)
| `(conv| enter [$id:ident]) => `(conv| ext $id)
| `(conv| enter [$arg:enterArg, $args,*]) => `(conv| (enter [$arg]; enter [$args,*]))
-
done
は、もし未解決のゴールがあるなら失敗する。 -
trace_state
は現在のゴールの状態を表示する。 -
whnf
は現在のターゲットをWeak Head Normal Form(WHNF / 弱頭部正規形)に変換する。 -
tactic => <tactic sequence>
を使うと通常のタクティクモードに戻る。これは、conv
モードでサポートされていないタクティクでゴールを閉じるときや、従来の合同性や外延性の補題を適用するときに便利である。
example (g : Nat → Nat → Nat)
(h₁ : ∀ x, x ≠ 0 → g x x = 1)
(h₂ : x ≠ 0)
: g x x + x = 1 + x := by
conv =>
lhs
-- ⊢ g x x + x
arg 1
-- ⊢ g x x
rw [h₁]
-- 2 goals: ⊢ 1, ⊢ x ≠ 0
. skip
. tactic => exact h₂
apply <term>
はtactic => apply <term>
の糖衣構文である。
example (g : Nat → Nat → Nat)
(h₁ : ∀ x, x ≠ 0 → g x x = 1)
(h₂ : x ≠ 0)
: g x x + x = 1 + x := by
conv =>
lhs
arg 1
rw [h₁]
. skip
. apply h₂
Axioms and Computation (公理と計算)
Leanに実装されたCalculus of Constructionsには、依存関数型、帰納型、そして最下層にあるimpredicative(非可述的)でproof-irrelevant(証明無関係)な Prop
型から始まる宇宙の階層が含まれていることを見てきた。本章では、Leanに実装されているCalculus of Inductive Constructions(CIC)に公理と規則を追加して、CICを拡張する方法を考える。このような方法で基礎体系を拡張することは、多くの場合便利である。基礎体系を拡張することで、より多くの定理を証明することが可能になるか、そうでなければ以前は簡単に証明できなかった定理を簡単に証明できるようになる。しかし、公理を追加することで、その正しさ(無矛盾性)に関する懸念の増大以上の否定的な結果が生じることもある。特に、追加した公理の使用は、以下で紹介するように定義と定理の計算内容に影響する。
Leanは計算的推論と古典論理的推論の両方をサポートするように設計されている。望むなら、ユーザーはcomputationally pure(計算上純粋)なフラグメントだけを使うことができ、そうすればシステム内の全ての閉じた式がcanonical normal form(正規標準形)に評価されることが保証される。特に、例えば Nat
型の計算上純粋な閉じた式は、全て数字に簡約される。
Leanの標準ライブラリでは、propositional extensionality(命題外延性)という追加の公理と、function extensionality(関数外延性)の原理を含意するquotient(商)の構築が定義されている。これらの公理の拡張は、例えば集合や有限集合の理論を開発するために利用される。これらの公理やそれらに依存する定理を使うと、Leanのカーネルにおける項評価がブロックされ、Nat
型の閉項が数字に評価されなくなることがあることを以下で見る。しかし、これらの公理は新しい命題(の証明項)を(無条件に)追加するだけであり、Leanは仮想マシン評価器用のバイトコード(中間コード)に定義をコンパイルする際に型と命題の情報を消去するため、これらの公理は計算的解釈と両立する。計算に傾倒したユーザーであっても、計算における推論を行うために古典的な排中律を使いたいと思うかもしれない。排中律もカーネルでの項評価をブロックするが、排中律は定義のバイトコードへのコンパイルと両立する。
また、標準ライブラリは、計算的解釈とは全く相反するchoice principle(選択原理)も定義している。選択原理は「データ(証明以外の項)」の存在を主張する命題から魔法のようにデータを生成することができ、便利だからである。いくつかの古典的な構文を使うには選択原理が必須であり、ユーザーは必要なときに選択原理をインポートすることができる。しかし、古典的な構文を使ってデータを生成する式は、計算可能な内容を持っていない。Leanでは、その事実を示すために、このような定義を noncomputable
とマークする必要がある。
(Diaconescu's theoremとして知られる)巧妙なトリックを使うと、命題外延性、関数外延性、選択原理から排中律を導くことができる。しかし、上述の通り、データを作るために使用されない限り、排中律や他の古典的な原理の使用はバイトコードコンパイルやコード抽出と両立する。(訳者注: 上記の公理たちに関して言えば、データを作るために古典的な原理を使用したときに限り計算不可能になる。)
要約すると、宇宙、依存関数型、帰納型という基本的なフレームワークの上に、標準ライブラリはさらに3つの公理を追加している:
- 命題外延性の公理
propext
- 商型
Quot
の構築 : 関数外延性funext
を含意する - 選択原理
Classical.choice
: 存在命題Nonempty α
からデータa : α
を生成する
ここで、最初の2つはLeanにおける項の正規化をブロックするが、バイトコード評価とは両立する。一方で、3つ目は計算的に解釈することができない。以下でこれらの詳細について述べる。
Historical and Philosophical Context (歴史的文脈と哲学的文脈)
数学の歴史の大半において、数学は本質的に計算可能なものであった: 幾何学は幾何学的なオブジェクトの作図を扱い、代数学は連立方程式の計算可能な解法と関係があり、解析学は時間発展する物理系の将来の振る舞いを計算する手段を提供した。「任意の x
について、...を満たす y
が存在する(∀ x, ∃ y, ...
)」という定理の証明から、x
が与えられたときにそのような y
を計算するアルゴリズムを抽出するのは、一般的に簡単なことだった。
しかし、19世紀になり数学的議論の複雑さが増すと、数学者たちはアルゴリズム的情報を必須としない、数学的対象の具体的な表現方法の詳細を抽象化した数学的対象の記述を使う新たな推論様式を開発した。その目的は計算の細部に拘泥することなく強力な「概念的」理解を得ることだったが、結果として直観的で計算可能な体系では単に偽である数学的定理を認めることになった。
今日においても、計算が数学にとって重要であることはほとんど一律に合意されている。しかし、計算にまつわる問題にどのように対処するのが最善かについては、様々な見解がある。constructive(構成的)な観点からすれば、数学をその計算的ルーツから切り離すのは間違いである: 全ての意味のある数学の定理は、直観的で計算的な解釈を持つべきである。classical(古典的)な観点からすると、問題の分離を維持した方が有益である: 私たちは、プログラムについて推論するために非構成的な理論やメソッドを使う自由を維持しながら、コンピュータプログラムを書くためにある言語と付属するメソッドを使うことができる。Leanは構成的アプローチと古典的アプローチの両方をサポートするように設計されている。ライブラリのコア部分は構成的に開発されている(そのため古典的な原理を使わない選択ができる)が、システムは古典的な数学的推論を行うためのサポートも提供している。
依存型理論の最も計算上純粋な部分は Prop
型の使用を完全に避けている。帰納型と依存関数型はデータ型とみなすことができ、これらの型の項は、これ以上簡約規則を適用できなくなるまで簡約規則を適用することで「評価」することができる。原理的には、Nat
型の任意の閉項(自由変数を持たない項)は succ (succ (... (succ zero)...))
という数字に評価されるはずである。
証明無関係な Prop
型を導入し、定理に irreducible
とマークすることは、問題分離への第一歩である。このマークの意図は、型 p : Prop
の項は計算において何の役割も果たすべきでないということであり、その意味で項 t : p
の具体的な構成は計算に「無関係」である。Prop
型の項を組み込んだ計算可能オブジェクトを定義することはできる。ポイントは、Prop
型の項は「計算結果を推論」する役に立つが、項から「コードを抽出」するときには無視できるということである。しかし、Prop
型の項はまったく無害というわけではない。Prop
型の項には任意の型 α
とその項 s : α
、t : α
に対する等式 s = t : Prop
が含まれる。このような等式は項を型チェックするためにキャストとして使用される。以下では、このようなキャストがどのようにシステム内の計算をブロックしうるかの例を見ていく。しかし、命題の内容を消去し、中間の型付け制約を無視し、正規形に達するまで項を簡約する評価枠組みの中では、計算は依然として可能である。これはまさにLeanの仮想マシンが行っていることである。
証明無関係な Prop
を採用した場合、任意の命題 p
に対する排中律 p ∨ ¬p
を使うことは正当だと考えるかもしれない。もちろん、排中律がCICの規則に従って計算をブロックする可能性はあるが、上述のようにバイトコードの評価をブロックすることはない。Leanの基礎的理論において、計算に無関係な証明と計算に関係するデータの区別を完全に消し去り、データを計算不可能にするのは、節Choice (選択原理)で説明する選択原理だけである。
Propositional Extensionality (命題外延性)
命題外延性は次のような公理である:
namespace Hidden
axiom propext {a b : Prop} : (a ↔ b) → a = b
end Hidden
propext
は、2つの命題が互いを含意するとき、それらは実際に等しいと主張する公理である。これは命題の集合論的な解釈と一致する。命題の集合論的な解釈において、a : Prop
は空であるか、互いに区別されたある元 *
のみを含むシングルトン {*}
である。この公理は、どのようなコンテキストでも命題をそれと同値な命題に置き換えることができるという効果を持つ:
theorem thm₁ (a b c d e : Prop) (h : a ↔ b) : (c ∧ a ∧ d → e) ↔ (c ∧ b ∧ d → e) :=
propext h ▸ Iff.refl _
theorem thm₂ (a b : Prop) (p : Prop → Prop) (h : a ↔ b) (h₁ : p a) : p b :=
propext h ▸ h₁
#print axioms thm₁ -- 'thm₁' depends on axioms: [propext]
#print axioms thm₂ -- 'thm₂' depends on axioms: [propext]
Function Extensionality (関数外延性)
命題外延性と同様に、関数外延性は、全ての入力に対して出力が一致する (x : α) → β x
型の2つの関数は等しいことを主張する。
universe u v
#check (@funext :
{α : Type u}
→ {β : α → Type u}
→ {f g : (x : α) → β x}
→ (∀ (x : α), f x = g x)
→ f = g)
#print funext
古典的な集合論の観点からは、2つの関数が等しいというのはまさにこのことを意味する(eqfnfv - Metamath Proof Explorer)。これは関数の「外延的な」見方として知られている。しかし、構成的な観点からは、関数をアルゴリズム、あるいは何らかの明示的な方法で提示されるコンピュータプログラムと考える方が自然な場合もある。2つのコンピュータプログラムが、構文的には全く異なっているにも関わらず、全ての入力に対して同じ答えを計算できるという例は確かにある。同様に、同じ入出力動作をする2つの関数を同一視することを強制しないような関数の見方を維持したいと思うかもしれない。これは関数の「内包的な」見方として知られている。
関数外延性は商の存在から導かれる。この事実については次の節で説明する。実際、Leanの標準ライブラリでは、funext
は商の構築から証明されている。
α : Type
に対して、α
の部分集合の型を表す Set α := α → Prop
を定義したとする。つまり、部分集合と述語を本質的に同一視するとする。funext
と propext
を組み合わせることで、このような集合の「外延性の定理」setext
が得られる:
def Set (α : Type u) := α → Prop
namespace Set
def mem (x : α) (a : Set α) : Prop := a x
infix:50 (priority := high) "∈" => mem
theorem setext {a b : Set α} (h : ∀ (x : α), x ∈ a ↔ x ∈ b) : a = b :=
funext (fun x => propext (h x))
theorem setext' {a b : Set α} (h : ∀ (x : α), a x ↔ b x) : a = b :=
funext (fun x => propext (h x))
end Set
それから、例えば空集合や集合の共通部分を定義し、集合に関する恒等式を証明することができる:
def Set (α : Type u) := α → Prop
namespace Set
def mem (x : α) (a : Set α) := a x
infix:50 (priority := high) "∈" => mem
theorem setext {a b : Set α} (h : ∀ x, x ∈ a ↔ x ∈ b) : a = b :=
funext (fun x => propext (h x))
def empty : Set α := fun x => False
notation (priority := high) "∅" => empty
def inter (a b : Set α) : Set α :=
fun x => x ∈ a ∧ x ∈ b
infix:70 " ∩ " => inter
theorem inter_self (a : Set α) : a ∩ a = a :=
setext fun x => Iff.intro
(fun ⟨h, _⟩ => h)
(fun h => ⟨h, h⟩)
theorem inter_empty (a : Set α) : a ∩ ∅ = ∅ :=
setext fun x => Iff.intro
(fun ⟨_, h⟩ => h)
(fun h => False.elim h)
theorem empty_inter (a : Set α) : ∅ ∩ a = ∅ :=
setext fun x => Iff.intro
(fun ⟨h, _⟩ => h)
(fun h => False.elim h)
theorem inter.comm (a b : Set α) : a ∩ b = b ∩ a :=
setext fun x => Iff.intro
(fun ⟨h₁, h₂⟩ => ⟨h₂, h₁⟩)
(fun ⟨h₁, h₂⟩ => ⟨h₂, h₁⟩)
end Set
以下は、Leanのカーネル内部で関数外延性がどのように計算をブロックするかの一例である。
def f (x : Nat) := x
def g (x : Nat) := 0 + x
theorem f_eq_g : f = g :=
funext fun x => (Nat.zero_add x).symm
def val : Nat :=
Eq.recOn (motive := fun _ _ => Nat) f_eq_g 0
-- does not reduce to 0
#reduce val
-- evaluates to 0
#eval val
まず、関数外延性を用いて2つの関数 f
と g
が等しいことを示す。次に 0
の型 Nat
の中に登場する f
を g
に置き換えて 0
をキャストする。もちろん Nat
は f
に依存しないので、このキャストは実質的に何もしない。しかし、計算をブロックするにはこれで十分である: このシステムの計算規則の下で、数字に簡約されない Nat
の閉項 val
を手に入れた。今回の場合、val
を 0
に簡約してほしいと思うかもしれない。しかし、自明でない例では、このようなキャストを除去すると項の型が変わり、周囲の式の型が不正確になる可能性がある。しかしながら、仮想マシンは何の問題もなく val
を 0
に評価できる。次は propext
がどのように計算をブロックするかを示す、上と似た作為的な例である。
theorem tteq : (True ∧ True) = True :=
propext (Iff.intro (fun ⟨h, _⟩ => h) (fun h => ⟨h, h⟩))
def val : Nat :=
Eq.recOn (motive := fun _ _ => Nat) tteq 0
-- does not reduce to 0
#reduce val
-- evaluates to 0
#eval val
observational type theoryやcubical type theoryを含む現在の型理論の研究プログラムは、関数外延性や商などを含む型キャストに対する簡約を許可する方法で型理論を拡張することを目指している。しかし、解決策はそれほど明確ではなく、Leanの基礎となるcalculusの規則はそのようなキャストの簡約を認めていない。
しかしながらある意味では、キャストは式の意味を変えるものではない。むしろ、キャストは式の型を推論するためのメカニズムだと言える。適切な意味論が与えられれば、簡約前後で型付けの正しさを保存するために必要な中間的な記録を無視して、項の意味を保持するやり方で項を簡約することは理にかなっている。
簡約可能性について、Prop
に新しい公理を追加することは問題にならない。証明無関係により、Prop
の項は何の情報も持たない。したがって、簡約手続きにおいて Prop
の項は安全に無視できる。
Quotients (商)
α
を任意の型とし、r
を α
上の同値関係とする。数学において、quotient(商) α / r
、つまり「α
の項の r
による同値類」全体からなる型を作ることは一般的である。集合論的には、α / r
を α
の項の r
による同値類全体からなる集合とみなすことができる。このとき、∀ a b, r a b → f a = f b
を満たすという意味で同値関係を尊重する任意の関数 f : α → β
を、各同値類 ⟦x⟧
に対して f' ⟦x⟧ = f x
で定義される関数 f' : α / r → β
に「持ち上げる」ことができる。Leanの標準ライブラリは、まさにこのような構築を実行する定数(公理)をいくつか追加することで、Calculus of Constructionsを拡張している。そして、これらの最後の公理 Quot.lift
をdefinitionalな除去則として導入している。
最も基本的な形では、商の構築 Quot.mk
は r
が同値関係であることさえ要求しない。Leanには以下の定数(公理)がビルトインに(ライブラリの最初のファイル Init.Prelude
より先に)定義されている:
namespace Hidden
universe u v
axiom Quot : {α : Sort u} → (α → α → Prop) → Sort u
axiom Quot.mk : {α : Sort u} → (r : α → α → Prop) → α → Quot r
axiom Quot.ind :
∀ {α : Sort u} {r : α → α → Prop} {β : Quot r → Prop},
(∀ a, β (Quot.mk r a)) → (q : Quot r) → β q
axiom Quot.lift :
{α : Sort u} → {r : α → α → Prop} → {β : Sort u} → (f : α → β)
→ (∀ a b, r a b → f a = f b) → Quot r → β
end Hidden
最初の公理 Quot
は、型 α
と α
上の二項関係 r
が与えられたときに型 Quot r
を形成する。2つ目の公理 Quot.mk
は、α
の項を Quot r
の項に写すもので、r : α → α → Prop
と a : α
があれば、Quot.mk r a
は Quot r
の項である。3つ目の公理 Quot.ind
は、全ての Quot r
の項が Quot.mk r a
の形をとることを示す(Quot r → Prop
を Set (Quot r)
とみなすと分かりやすい)。4つ目の公理 Quot.lift
は、関数 f : α → β
が与えられたとき、h
が「f
は関係 r
を尊重する」ことの証明であれば、Quot.lift f h
は f
に対応する Quot r
上の関数であることを主張する。この考え方は、h
が「f
はwell-definedである」ことを示す証明なら、関数 Quot.lift f h
は α
の各項 a
について、Quot.mk r a
(a
を含む r
-(同値)類)を f a
に写す、というものである。以下の証明で明らかなように、計算原理 Quot.Lift
は除去則として宣言されている。
def mod7Rel (x y : Nat) : Prop :=
x % 7 = y % 7
-- 商型 `Quot mod7Rel`
#check (Quot mod7Rel : Type)
-- `4` を含む `mod7Rel`-(同値)類
#check (Quot.mk mod7Rel 4 : Quot mod7Rel)
def f (x : Nat) : Bool :=
x % 7 = 0
theorem f_respects (a b : Nat) (h : mod7Rel a b) : f a = f b := by
simp [mod7Rel, f] at *
rw [h]
def f' (x : Quot mod7Rel) : Bool :=
Quot.lift f f_respects x
#check (f' : Quot mod7Rel → Bool)
-- 計算原理
example (a : Nat) : f' (Quot.mk mod7Rel a) = f a :=
rfl
4つの定数(公理) Quot
、Quot.mk
、Quot.ind
、Quot.lift
自体はあまり強くない。Quot r
を単に α
とみなし、Quot.lift
を(h
を無視して) α → β
上の恒等関数とみなせば、Quot.ind
が満たされることが確認できる。そのため、これら4つの公理は追加の公理とはみなさない。
variable (α β : Type)
variable (r : α → α → Prop)
variable (a : α)
variable (f : α → β)
variable (h : ∀ a₁ a₂, r a₁ a₂ → f a₁ = f a₂)
theorem thm : Quot.lift f h (Quot.mk r a) = f a := rfl
#print axioms thm -- 'thm' does not depend on any axioms
これら4つの公理は、帰納型や帰納型に関連するコンストラクタと再帰子と同様に、logical framework(論理フレームワーク)の一部とみなされる。
Quot
を正真正銘の商にするのは、次の追加公理 Quot.sound
である:
namespace Hidden
universe u v
axiom Quot.sound :
∀ {α : Type u} {r : α → α → Prop} {a b : α},
r a b → Quot.mk r a = Quot.mk r b
end Hidden
これは「α
の任意の2つの項は、r
によって関係しているなら、商の中で同一視される」と主張する公理である。定義や定理 foo
が Quot.sound
を使っている場合、コマンド #print axioms foo
は Quot.sound
を表示する。
もちろん、商の構築は r
が同値関係である場合に最もよく使われる。上記のように r
が与えられたとき、r' a b
と Quot.mk r a = Quot.mk r b
が同値になるように r'
を定義すれば、r'
が同値関係であることは明らかである。実際、r'
は関数 a ↦ quot.mk r a
のkernel(核)である。公理 Quot.sound
は、r a b
が r' a b
を含意すると主張している。Quot.lift
と Quot.ind
を使えば、「r
を含む任意の同値関係 r''
に対して、r' a b
は r'' a b
を含意する」という意味で、r'
が r
を含む最小の同値関係であることを証明できる。特に、r
がそもそも同値関係であったならば、任意の a
と b
に対して、r a b
と r' a b
が同値であることが証明できる。
同値関係や商の一般的なユースケースをサポートするために、標準ライブラリはsetoidという概念を定義している。これは単に同値関係を持つ型である:
namespace Hidden
class Setoid (α : Sort u) where
r : α → α → Prop
iseqv : Equivalence r
instance {α : Sort u} [Setoid α] : HasEquiv α :=
⟨Setoid.r⟩
namespace Setoid
variable {α : Sort u} [Setoid α]
theorem refl (a : α) : a ≈ a :=
iseqv.refl a
theorem symm {a b : α} (hab : a ≈ b) : b ≈ a :=
iseqv.symm hab
theorem trans {a b c : α} (hab : a ≈ b) (hbc : b ≈ c) : a ≈ c :=
iseqv.trans hab hbc
end Setoid
end Hidden
型 α
、α
上の二項関係 r
、r
が同値関係であることの証明 p
が与えられたとき、Setoid.mk r p
により Setoid
クラスのインスタンスを定義することができる。
namespace Hidden
def Quotient {α : Sort u} (s : Setoid α) :=
@Quot α Setoid.r
end Hidden
定数(公理) Quotient.mk
、Quotient.ind
、Quotient.lift
、Quotient.sound
は Quot
の対応する要素の特殊化に他ならない。型クラス推論が型 Setoid α
のインスタンスを見つけることができるという事実は、多くの利点をもたらす。まず、Setoid.r a b
を a ≈ b
(\approx
と打つと入力できる)と略記することができる。ここで、Setoid.r
という表記について、Setoid
のインスタンスが暗黙の引数となっていることに注意してほしい。また、Setoid.refl
、Setoid.symm
、Setoid.trans
という一般的な定理を使って同値関係に関する推論を行うことができる。商においては特に Quot.mk Setoid.r a
の一般的な略記 ⟦a⟧
を使うことができる。ここでも Setoid.r
表記に関して Setoid
のインスタンスが暗黙の引数となっている。Quotient.exact
という定理もある:
universe u
#check (@Quotient.exact :
∀ {α : Sort u} {s : Setoid α} {a b : α},
Quotient.mk s a = Quotient.mk s b → a ≈ b)
Quotient.exact
と Quotient.sound
を組み合わせると、Quotient s
の各項が α
の項の各同値類と正確に対応することが導かれる。
標準ライブラリでは、型 α × β
は型 α
と β
の直積を表すことを思い出してほしい。商の使い方を説明するために、型 α
の項からなる非順序対の型を、型 α × α
の商として定義してみよう。まず、関連する同値関係を定義する:
private def eqv (p₁ p₂ : α × α) : Prop :=
(p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
次のステップは、eqv
が実際に同値関係であること、つまり反射的、対称的、推移的であることを証明することである。依存パターンマッチングを使って場合分けし、仮説を分解し、それを組み立てて結論を出すことで、便利で読みやすい方法でこれら3つの事実を証明することができる。
private def eqv (p₁ p₂ : α × α) : Prop :=
(p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
private theorem eqv.refl (p : α × α) : p ~ p :=
Or.inl ⟨rfl, rfl⟩
private theorem eqv.symm : ∀ {p₁ p₂ : α × α}, p₁ ~ p₂ → p₂ ~ p₁
| (a₁, a₂), (b₁, b₂), (Or.inl ⟨a₁b₁, a₂b₂⟩) =>
Or.inl (by simp_all)
| (a₁, a₂), (b₁, b₂), (Or.inr ⟨a₁b₂, a₂b₁⟩) =>
Or.inr (by simp_all)
private theorem eqv.trans : ∀ {p₁ p₂ p₃ : α × α}, p₁ ~ p₂ → p₂ ~ p₃ → p₁ ~ p₃
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
Or.inl (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
Or.inr (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
Or.inr (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
Or.inl (by simp_all)
private theorem is_equivalence : Equivalence (@eqv α) :=
{ refl := eqv.refl, symm := eqv.symm, trans := eqv.trans }
eqv
が同値関係であることが証明されたので、Setoid (α × α)
のインスタンスを構築することができ、Setoid (α × α)
を使って非順序対の型 UProd α
を定義することができる。
private def eqv (p₁ p₂ : α × α) : Prop :=
(p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
private theorem eqv.refl (p : α × α) : p ~ p :=
Or.inl ⟨rfl, rfl⟩
private theorem eqv.symm : ∀ {p₁ p₂ : α × α}, p₁ ~ p₂ → p₂ ~ p₁
| (a₁, a₂), (b₁, b₂), (Or.inl ⟨a₁b₁, a₂b₂⟩) =>
Or.inl (by simp_all)
| (a₁, a₂), (b₁, b₂), (Or.inr ⟨a₁b₂, a₂b₁⟩) =>
Or.inr (by simp_all)
private theorem eqv.trans : ∀ {p₁ p₂ p₃ : α × α}, p₁ ~ p₂ → p₂ ~ p₃ → p₁ ~ p₃
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
Or.inl (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
Or.inr (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
Or.inr (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
Or.inl (by simp_all)
private theorem is_equivalence : Equivalence (@eqv α) :=
{ refl := eqv.refl, symm := eqv.symm, trans := eqv.trans }
instance uprodSetoid (α : Type u) : Setoid (α × α) where
r := eqv
iseqv := is_equivalence
def UProd (α : Type u) : Type u :=
Quotient (uprodSetoid α)
namespace UProd
def mk {α : Type} (a₁ a₂ : α) : UProd α :=
Quotient.mk' (a₁, a₂)
notation "{ " a₁ ", " a₂ " }" => mk a₁ a₂
end UProd
非順序対 Quotient.mk' (a₁, a₂)
に対する略記 {a₁, a₂}
をローカルに定義していることに注意してほしい。この略記は説明を目的とするなら便利であるが、レコードや集合のような対象を表すのに波括弧を使いにくくなるので、一般的には良いアイデアではない。
既に (a₁, a₂) ~ (a₂, a₁)
を証明してあるので、Quot.sound
を使うことで {a₁, a₂} = {a₂, a₁}
を簡単に証明することができる。
private def eqv (p₁ p₂ : α × α) : Prop :=
(p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
private theorem eqv.refl (p : α × α) : p ~ p :=
Or.inl ⟨rfl, rfl⟩
private theorem eqv.symm : ∀ {p₁ p₂ : α × α}, p₁ ~ p₂ → p₂ ~ p₁
| (a₁, a₂), (b₁, b₂), (Or.inl ⟨a₁b₁, a₂b₂⟩) =>
Or.inl (by simp_all)
| (a₁, a₂), (b₁, b₂), (Or.inr ⟨a₁b₂, a₂b₁⟩) =>
Or.inr (by simp_all)
private theorem eqv.trans : ∀ {p₁ p₂ p₃ : α × α}, p₁ ~ p₂ → p₂ ~ p₃ → p₁ ~ p₃
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
Or.inl (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
Or.inr (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
Or.inr (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
Or.inl (by simp_all)
private theorem is_equivalence : Equivalence (@eqv α) :=
{ refl := eqv.refl, symm := eqv.symm, trans := eqv.trans }
instance uprodSetoid (α : Type u) : Setoid (α × α) where
r := eqv
iseqv := is_equivalence
def UProd (α : Type u) : Type u :=
Quotient (uprodSetoid α)
namespace UProd
def mk {α : Type} (a₁ a₂ : α) : UProd α :=
Quotient.mk' (a₁, a₂)
notation "{ " a₁ ", " a₂ " }" => mk a₁ a₂
theorem mk_eq_mk (a₁ a₂ : α) : {a₁, a₂} = {a₂, a₁} :=
Quot.sound (Or.inr ⟨rfl, rfl⟩)
end UProd
この例を完成させるため、a : α
と u : UProd α
に対して、a
が非順序対 u
の要素の1つである場合に成立する命題 a ∈ u
を定義する。まず、順序対に対して同じような命題 mem_fn a u
を定義する。次に mem_fn
が同値関係 eqv
を尊重することを補題 mem_respects
で示す。これはLeanの標準ライブラリで広く使われているイディオムである。
private def eqv (p₁ p₂ : α × α) : Prop :=
(p₁.1 = p₂.1 ∧ p₁.2 = p₂.2) ∨ (p₁.1 = p₂.2 ∧ p₁.2 = p₂.1)
infix:50 " ~ " => eqv
private theorem eqv.refl (p : α × α) : p ~ p :=
Or.inl ⟨rfl, rfl⟩
private theorem eqv.symm : ∀ {p₁ p₂ : α × α}, p₁ ~ p₂ → p₂ ~ p₁
| (a₁, a₂), (b₁, b₂), (Or.inl ⟨a₁b₁, a₂b₂⟩) =>
Or.inl (by simp_all)
| (a₁, a₂), (b₁, b₂), (Or.inr ⟨a₁b₂, a₂b₁⟩) =>
Or.inr (by simp_all)
private theorem eqv.trans : ∀ {p₁ p₂ p₃ : α × α}, p₁ ~ p₂ → p₂ ~ p₃ → p₁ ~ p₃
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
Or.inl (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inl ⟨a₁b₁, a₂b₂⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
Or.inr (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inl ⟨b₁c₁, b₂c₂⟩ =>
Or.inr (by simp_all)
| (a₁, a₂), (b₁, b₂), (c₁, c₂), Or.inr ⟨a₁b₂, a₂b₁⟩, Or.inr ⟨b₁c₂, b₂c₁⟩ =>
Or.inl (by simp_all)
private theorem is_equivalence : Equivalence (@eqv α) :=
{ refl := eqv.refl, symm := eqv.symm, trans := eqv.trans }
instance uprodSetoid (α : Type u) : Setoid (α × α) where
r := eqv
iseqv := is_equivalence
def UProd (α : Type u) : Type u :=
Quotient (uprodSetoid α)
namespace UProd
def mk {α : Type} (a₁ a₂ : α) : UProd α :=
Quotient.mk' (a₁, a₂)
notation "{ " a₁ ", " a₂ " }" => mk a₁ a₂
theorem mk_eq_mk (a₁ a₂ : α) : {a₁, a₂} = {a₂, a₁} :=
Quot.sound (Or.inr ⟨rfl, rfl⟩)
private def mem_fn (a : α) : α × α → Prop
| (a₁, a₂) => a = a₁ ∨ a = a₂
-- auxiliary lemma for proving mem_respects
private theorem mem_swap {a : α} :
∀ {p : α × α}, mem_fn a p = mem_fn a (⟨p.2, p.1⟩)
| (a₁, a₂) => by
apply propext
apply Iff.intro
. intro
| Or.inl h => exact Or.inr h
| Or.inr h => exact Or.inl h
. intro
| Or.inl h => exact Or.inr h
| Or.inr h => exact Or.inl h
private theorem mem_respects
: {p₁ p₂ : α × α} → (a : α) → p₁ ~ p₂ → mem_fn a p₁ = mem_fn a p₂
| (a₁, a₂), (b₁, b₂), a, Or.inl ⟨a₁b₁, a₂b₂⟩ => by simp_all
| (a₁, a₂), (b₁, b₂), a, Or.inr ⟨a₁b₂, a₂b₁⟩ => by simp_all; apply mem_swap
def mem (a : α) (u : UProd α) : Prop :=
Quot.liftOn u (fun p => mem_fn a p) (fun p₁ p₂ e => mem_respects a e)
infix:50 (priority := high) " ∈ " => mem
theorem mem_mk_left (a b : α) : a ∈ {a, b} :=
Or.inl rfl
theorem mem_mk_right (a b : α) : b ∈ {a, b} :=
Or.inr rfl
theorem mem_or_mem_of_mem_mk {a b c : α} : c ∈ {a, b} → c = a ∨ c = b :=
fun h => h
end UProd
利便性のため、標準ライブラリは2変数関数を「持ち上げる」ための Quotient.lift₂
と、2変数帰納法のための Quotient.ind₂
も定義している。
最後に、なぜ商の構築が関数外延性を含意するのかについて、いくつかのヒントを示してこの節を締めくくる。型 (x : α) → β x
を持つ関数の外延性等式が同値関係であることを示すのは難しくない。したがって、「同値関係を足した」依存関数型 extfun α β
を考えることができる。もちろん、関数適用は f₁
と f₂
が同値関係にあるなら、f₁ a
は f₂ a
と等しいという意味で、同値関係を尊重する。したがって、関数適用は関数 extfun_app : extfun α β → (x : α) → β x
に持ち上げられる。しかし、任意の f
について、extfun_app ⟦f⟧
は fun x => f x
とdefinitionally equalであり、結果として f
とdefinitionally equalである。したがって、f₁
と f₂
が外延的に等しいとき、次のような等号の連鎖が成り立つ:
f₁ = extfun_app ⟦f₁⟧ = extfun_app ⟦f₂⟧ = f₂
結果として、f₁
と f₂
は等しい。
Choice (選択原理)
標準ライブラリで定義されている最後の公理(選択原理)を述べるには、次のように定義される Nonempty
型が必要である:
namespace Hidden
class inductive Nonempty (α : Sort u) : Prop where
| intro (val : α) : Nonempty α
end Hidden
Nonempty α
型は Prop
型を持ち、そのコンストラクタはデータを含むので、Inductively Defined Propositions (帰納的に定義された命題)の節で見た通り、Nonempty α
型を除去しても命題を作ることしかできない。実際、Nonempty α
は ∃ x : α, True
と同値である:
example (α : Type u) : Nonempty α ↔ ∃ x : α, True :=
Iff.intro (fun ⟨a⟩ => ⟨a, trivial⟩) (fun ⟨a, h⟩ => ⟨a⟩)
Lean版の選択公理は次のようにシンプルに表現される:
namespace Hidden
universe u
axiom choice {α : Sort u} : Nonempty α → α
end Hidden
「α
は空でない」ことの証明 h
さえあれば、choice h
は魔法のように α
の項を生成する。もちろん、choice
の使用は意味のある計算をブロックする: 証明無関係の考え方の下では、h
はそのような項を見つける方法に関する情報を全く含んでいない。
choice
は Classical
という名前空間の中にあるため、この公理のフルネームは Classical.choice
である。選択原理はindefinite description(不定的記述)の原理と同値である。不定的記述の原理はsubtypes(部分型)を使って次のように表すことができる:
namespace Hidden
universe u
axiom choice {α : Sort u} : Nonempty α → α
noncomputable def indefiniteDescription {α : Sort u} (p : α → Prop)
(h : ∃ x, p x) : {x // p x} :=
choice <| let ⟨x, px⟩ := h; ⟨⟨x, px⟩⟩
end Hidden
この定義は choice
に依存するため、Leanは indefiniteDescription
のバイトコードを生成できない。したがって、この定義を noncomputable
とマークする必要がある。また、Classical
名前空間では、関数 choose
とプロパティ choose_spec
は indefiniteDescription
の(2つの要素からなる)出力を分解し、各要素を抽出する:
open Classical
namespace Hidden
noncomputable def choose {α : Sort u} {p : α → Prop} (h : ∃ x, p x) : α :=
(indefiniteDescription p h).val
theorem choose_spec {α : Sort u} {p : α → Prop} (h : ∃ x, p x) : p (choose h) :=
(indefiniteDescription p h).property
end Hidden
また、選択原理 Choice
は「空でない」という性質 Nonempty
と「有項である」というより構成的な性質 Inhabited
の区別をなくしてしまう:
open Classical
theorem inhabited_of_nonempty : Nonempty α → Inhabited α :=
fun h => choice (let ⟨a⟩ := h; ⟨⟨a⟩⟩)
次節では propext
、funext
、choice
の3つを合わせると、排中律と任意の命題の決定可能性が導かれることを説明する。これらを用いると、不定的記述の原理を次のように強化することができる:
open Classical
universe u
#check (@strongIndefiniteDescription :
{α : Sort u} → (p : α → Prop)
→ Nonempty α → {x // (∃ (y : α), p y) → p x})
前提となる型 α
が空でないとすると、p
を満たす項が存在するなら、strongIndefiniteDescription p
は p
を満たす項 x
を生成する(p
を満たす項が存在しないなら、strongIndefiniteDescription p
は choice
により生成された型 α
の任意の項を返す)。この関数 strongIndefiniteDescription
の出力から値要素を抽出する関数はHilbert's epsilon function(ヒルベルトのε関数)として知られている:
open Classical
universe u
#check (@epsilon :
{α : Sort u} → [Nonempty α]
→ (α → Prop) → α)
#check (@epsilon_spec :
∀ {α : Sort u} {p : α → Prop} (hex : ∃ (y : α), p y),
p (@epsilon _ (nonempty_of_exists hex) p))
関数 epsilon_spec
は、p
を満たす項が存在するという証明を受け取ると、p (epsilon p)
の証明を返す。
The Law of the Excluded Middle (排中律)
排中律は次のように表現される:
open Classical
#check (@em : ∀ (p : Prop), p ∨ ¬p)
Diaconescuの定理は、選択公理から排中律が導かれることを述べている。より正確には、Diaconescuの定理は、Classical.choice
、propext
、funext
から排中律が導かれることを示している。以下に標準ライブラリにあるDiaconescuの定理の証明を記す。
まず、必要な公理をインポートして、2つの述語 U
と V
を定義する:
namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
let U (x : Prop) : Prop := x = True ∨ p
let V (x : Prop) : Prop := x = False ∨ p
have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
sorry
end Hidden
もし p
が真なら、Prop
型の任意の項は U
と V
の両方に属する。もし p
が偽なら、U
はシングルトン True
であり、V
はシングルトン False
である。
次に、choose
を使って U
の元と V
の元を1つ選ぶ:
namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
let U (x : Prop) : Prop := x = True ∨ p
let V (x : Prop) : Prop := x = False ∨ p
have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
let u : Prop := choose exU
let v : Prop := choose exV
have u_def : U u := choose_spec exU
have v_def : V v := choose_spec exV
sorry
end Hidden
U
と V
はそれぞれ選言命題なので、u_def
と v_def
の組は計4つのケースを表している。これらのケースのうち1つでは u = True
かつ v = False
であり、他の全てのケースでは p
が真である。したがって、次のようになる:
namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
let U (x : Prop) : Prop := x = True ∨ p
let V (x : Prop) : Prop := x = False ∨ p
have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
let u : Prop := choose exU
let v : Prop := choose exV
have u_def : U u := choose_spec exU
have v_def : V v := choose_spec exV
have not_uv_or_p : u ≠ v ∨ p :=
match u_def, v_def with
| Or.inr h, _ => Or.inr h
| _, Or.inr h => Or.inr h
| Or.inl hut, Or.inl hvf =>
have hne : u ≠ v := by simp [hvf, hut, true_ne_false]
Or.inl hne
sorry
end Hidden
一方、p
が真であれば、関数外延性と命題外延性によって U
と V
は等しい。u
と v
の定義により、u
と v
も等しいことがわかる。
namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
let U (x : Prop) : Prop := x = True ∨ p
let V (x : Prop) : Prop := x = False ∨ p
have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
let u : Prop := choose exU
let v : Prop := choose exV
have u_def : U u := choose_spec exU
have v_def : V v := choose_spec exV
have not_uv_or_p : u ≠ v ∨ p :=
match u_def, v_def with
| Or.inr h, _ => Or.inr h
| _, Or.inr h => Or.inr h
| Or.inl hut, Or.inl hvf =>
have hne : u ≠ v := by simp [hvf, hut, true_ne_false]
Or.inl hne
have p_implies_uv : p → u = v :=
fun hp =>
have hpred : U = V :=
funext fun x =>
have hl : (x = True ∨ p) → (x = False ∨ p) :=
fun _ => Or.inr hp
have hr : (x = False ∨ p) → (x = True ∨ p) :=
fun _ => Or.inr hp
show (x = True ∨ p) = (x = False ∨ p) from
propext (Iff.intro hl hr)
have h₀ : ∀ exU exV, @choose _ U exU = @choose _ V exV := by
rw [hpred]; intros; rfl
show u = v from h₀ _ _
sorry
end Hidden
not_uv_or_p
と p_implies_uv
をまとめると、所望の結論が得られる:
namespace Hidden
open Classical
theorem em (p : Prop) : p ∨ ¬p :=
let U (x : Prop) : Prop := x = True ∨ p
let V (x : Prop) : Prop := x = False ∨ p
have exU : ∃ x, U x := ⟨True, Or.inl rfl⟩
have exV : ∃ x, V x := ⟨False, Or.inl rfl⟩
let u : Prop := choose exU
let v : Prop := choose exV
have u_def : U u := choose_spec exU
have v_def : V v := choose_spec exV
have not_uv_or_p : u ≠ v ∨ p :=
match u_def, v_def with
| Or.inr h, _ => Or.inr h
| _, Or.inr h => Or.inr h
| Or.inl hut, Or.inl hvf =>
have hne : u ≠ v := by simp [hvf, hut, true_ne_false]
Or.inl hne
have p_implies_uv : p → u = v :=
fun hp =>
have hpred : U = V :=
funext fun x =>
have hl : (x = True ∨ p) → (x = False ∨ p) :=
fun _ => Or.inr hp
have hr : (x = False ∨ p) → (x = True ∨ p) :=
fun _ => Or.inr hp
show (x = True ∨ p) = (x = False ∨ p) from
propext (Iff.intro hl hr)
have h₀ : ∀ exU exV, @choose _ U exU = @choose _ V exV := by
rw [hpred]; intros; rfl
show u = v from h₀ _ _
match not_uv_or_p with
| Or.inl hne => Or.inr (mt p_implies_uv hne)
| Or.inr h => Or.inl h
end Hidden
排中律の系としては、二重否定除去、場合分けによる証明、矛盾による証明などがあり、これらは全て節Classical Logic (古典論理)で説明されている。排中律と命題外延性は命題完全性を含意する:
namespace Hidden
open Classical
theorem propComplete (a : Prop) : a = True ∨ a = False :=
match em a with
| Or.inl ha => Or.inl (propext (Iff.intro (fun _ => ⟨⟩) (fun _ => ha)))
| Or.inr hn => Or.inr (propext (Iff.intro (fun h => hn h) (fun h => False.elim h)))
end Hidden
選択原理と合わせると、「全ての命題は決定可能である」というより強い原理も得られる。決定可能命題のクラス Decidable
は次のように定義されることを思い出してほしい:
namespace Hidden
class inductive Decidable (p : Prop) where
| isFalse (h : ¬p) : Decidable p
| isTrue (h : p) : Decidable p
end Hidden
除去により Prop
型の項しか作れない p ∨ ¬ p
とは対照的に、型 Decidable p
は直和型 Sum p (¬ p)
と等価であり、除去により任意の型の項を作ることができる。型 Decidable p
のデータはif-then-else式を書くのに必要である。
古典的推論の例と同様に、「f : α → β
が単射で α
が有項なら、f
は左逆写像を持つ」という定理を証明するためにも choose
を使う。左逆写像 linv
を定義するために、依存if-then-else式を用いる。if h : c then t else e
は dite c (fun h : c => t) (fun h : ¬ c => e)
の略記であることを思い出してほしい。linv
の定義の中で、選択原理は2回使われている: 選択原理は、まず (∃ a : A, f a = b)
が「決定可能」であることを示すために、そして f a = b
を満たす a
を選ぶために使われている。propDecidable
はスコープ付きインスタンスであり、open Classical
コマンドによって利用可能になることに注意してほしい。このインスタンスにより、このif-then-else式の使用が正当化される(節Decidable Propositions (決定可能命題)の説明も参照のこと)。
open Classical
noncomputable def linv [Inhabited α] (f : α → β) : β → α :=
fun b : β => if ex : (∃ a : α, f a = b) then choose ex else default
theorem linv_comp_self {f : α → β} [Inhabited α]
(inj : ∀ {a b}, f a = f b → a = b)
: linv f ∘ f = id :=
funext fun a =>
have ex : ∃ a₁ : α, f a₁ = f a := ⟨a, rfl⟩
have feq : f (choose ex) = f a := choose_spec ex
calc linv f (f a)
_ = choose ex := dif_pos ex
_ = a := inj feq
古典的な観点からすると、linv
は関数である。構成的な観点からすると、linv
の定義は受け入れがたい: 一般にこのような関数を実装する方法はないため、この構築は何の情報も持たない。