Functional Programming in Lean
by David Thrane Christiansen
Copyright Microsoft Corporation 2023
This is a free book on using Lean 4 as a programming language. All code samples are tested with Lean 4 release 4.1.0
.
Release history
October, 2023
In this first maintenance release, a number of smaller issues were fixed and the text was brought up to date with the latest release of Lean.
May, 2023
The book is now complete! Compared to the April pre-release, many small details have been improved and minor mistakes have been fixed.
April, 2023
This release adds an interlude on writing proofs with tactics as well as a final chapter that combines discussion of performance and cost models with proofs of termination and program equivalence. This is the last release prior to the final release.
March, 2023
This release adds a chapter on programming with dependent types and indexed families.
January, 2023
This release adds a chapter on monad transformers that includes a description of the imperative features that are available in do
-notation.
December, 2022
This release adds a chapter on applicative functors that additionally describes structures and type classes in more detail. This is accompanied with improvements to the description of monads. The December 2022 release was delayed until January 2023 due to winter holidays.
November, 2022
This release adds a chapter on programming with monads. Additionally, the example of using JSON in the coercions section has been updated to include the complete code.
October, 2022
This release completes the chapter on type classes. In addition, a short interlude introducing propositions, proofs, and tactics has been added just before the chapter on type classes, because a small amount of familiarity with the concepts helps to understand some of the standard library type classes.
September, 2022
This release adds the first half of a chapter on type classes, which are Lean's mechanism for overloading operators and an important means of organizing code and structuring libraries. Additionally, the second chapter has been updated to account for changes in Lean's stream API.
August, 2022
This third public release adds a second chapter, which describes compiling and running programs along with Lean's model for side effects.
July, 2022
The second public release completes the first chapter.
June, 2022
This was the first public release, consisting of an introduction and part of the first chapter.
About the Author
David Thrane Christiansen has been using functional languages for twenty years, and dependent types for ten. Together with Daniel P. Friedman, he wrote The Little Typer, an introduction to the key ideas of dependent type theory. He has a Ph.D. from the IT University of Copenhagen. During his studies, he was a major contributor to the first version of the Idris language. Since leaving academia, he has worked as a software developer at Galois in Portland, Oregon and Deon Digital in Copenhagen, Denmark, and he was the Executive Director of the Haskell Foundation. At the time of writing, he is employed at the Lean Focused Research Organization working full-time on Lean.
License
This work is licensed under a Creative Commons Attribution 4.0 International License.
Lean はマイクロソフトリサーチで開発された、依存型理論(dependent type theory)に基づく対話型証明支援系です。依存型理論はプログラムと証明の世界を結びつけます:したがって、Lean はプログラミング言語でもあります。Lean はその両面を重視していて、汎用プログラミング言語として使用できるように設計されています。ーー Lean は Lean 自身で実装されています。本書は、Lean でプログラムを書くことをテーマにしています。
プログラミング言語として見た場合、Lean は依存型を持つ正格(strict)な純粋関数型言語です。Lean でのプログラミングを学ぶには、これらの特徴がそれぞれプログラムの書き方に与える影響と、関数型プログラマのように考える方法を学ぶ必要があります。正格性(strictness)は、Lean における関数呼び出しが、ほとんどの言語と同様に機能することを意味します:つまり、関数本体の実行が始まる前に、引数が完全に計算されます。純粋性(purity)は、副作用を起こしうることがプログラムの型に明記されていない限り、Lean のプログラムがメモリ内で場所を変更したり、電子メールを送信したり、ファイルを削除したりといった副作用を起こしえないことを意味します。Lean は関数型言語であり、これは他の値と同様に関数が第一級の値であることと、プログラムの実行モデルが数式の評価から着想を得ていることを意味します。依存型(dependent types)は Lean の最も珍しい特徴であり、型すらも言語の第一級の部分にすることで、型がプログラムを含むことと、プログラムが型を計算することを可能にします。
本書は、Lean を学びたいが、必ずしも関数型言語を使ったことがないプログラマを対象としています。Haskell・OCaml・F# などの関数型言語に精通している必要はありません。一方、本書はループ・関数・データ構造など、ほとんどのプログラミング言語に共通する概念の知識を前提としています。本書は関数型プログラミングを学ぶ最初の一冊としては好適ですが、プログラミング全般を学ぶ最初の一冊としては不適切です。
Lean を証明支援系として使っている数学者は、いずれ自前の証明自動化ツールが必要になるでしょう。この本はそのような人たちのためのものでもあります。これらのツールは洗練されるにつれ関数型言語のプログラムに似てきますが、現役の数学者のほとんどは Python や Mathematica のような言語で訓練を受けています。本書は、より多くの数学者が保守可能で理解しやすい証明自動化ツールを書けるように、このような言語と関数型言語のギャップを埋める助けとなるでしょう。
本書は最初から最後まで直線的に、つまり飛ばさず読むことを意図しています。概念は1つずつ導入され、後の節は前の節を理解していることを前提としています。時には、先の章では簡単にしか触れなかったトピックについて、後の章で深く掘り下げることもあります。本書のいくつかの節には演習問題が含まれています。演習問題は、その節の理解を深めるために取り組む価値があります。また、本を読みながら Lean を実際に使い、学んだことを使う創造的な新しい方法を見つけることも有効です。
Lean のインストール
Lean でプログラムを書いて実行する前に、自分のコンピュータに Lean をセットアップする必要があります。Lean のツール構成は下記の通りです:
elan
は、rustup
やghcup
と同様に、Lean のツールチェーンを管理します。
lake
は、cargo
・make
・Gradle と同様に、Lean パッケージとその依存関係をビルドします。
lean
(シェルコマンド)は、個々の Lean ファイルを型検査し、コンパイルするだけでなく、編集中のファイルに関する情報をプログラマツールに提供します。通常、lean
はユーザが直接呼び出すのではなく、他のツールによって呼び出されます。
- Visual Studio Code や Emacs などのエディタ用のプラグイン(拡張機能)は、
lean
と情報交換し、その情報を便利な形で表示します。
Lean の最新のインストール方法については、Lean Manualを参照してください。
本書の書式
Lean に入力(input)として提供されるコード例は、このような書式とします:
def add1 (n : Nat) : Nat := n + 1
#eval add1 7
上の最後の行(#eval
で始まる行)は、Lean に答えを計算するように指示するコマンドです。Lean の返事はこのような書式とします:
8
Lean が返すエラーメッセージはこのような書式とします:
application type mismatch
add1 "seven"
argument
"seven"
has type
String : Type
but is expected to have type
Nat : Type
警告はこのような書式とします:
declaration uses 'sorry'
Unicode
慣用的な Lean のコードには、ASCII には含まれないさまざまな Unicode 文字が使用されます。例えば、ギリシャ文字の α
や β
、矢印の →
が、いずれも本書の第1章に登場します。Unicode 文字により、Lean のコードは通常の数学的表記により近くなります。
デフォルトの Lean の設定では、Visual Studio Code でも Emacs でも、バックスラッシュ(\
)の後に名前を続けることで Unicode 文字を入力することができます。例えば、α
と入力するには \alpha
とタイプします。Visual Studio Code で文字の入力方法を調べるには、マウスでその文字をポイントしてツールチップを見ればよいです。Emacs の場合、問題の文字をポイントしてから C-c C-k
を使えばよいです。
Acknowledgments
This free online book was made possible by the generous support of Microsoft Research, who paid for it to be written and given away. During the process of writing, they made the expertise of the Lean development team available to both answer my questions and make Lean easier to use. In particular, Leonardo de Moura initiated the project and helped me get started, Chris Lovett set up the CI and deployment automation and provided great feedback as a test reader, Gabriel Ebner provided technical reviews, Sarah Smith kept the administrative side working well, and Vanessa Rodriguez helped me diagnose a tricky interaction between the source-code highlighting library and certain versions of Safari on iOS.
Writing this book has taken up many hours outside of normal working hours. My wife Ellie Thrane Christiansen has taken on a larger than usual share of running the family, and this book could not exist if she had not done so. An extra day of work each week has not been easy for my family—thank you for your patience and support while I was writing.
The online community surrounding Lean provided enthusiastic support for the project, both technical and emotional. In particular, Sebastian Ullrich provided key help when I was learning Lean's metaprogramming system in order to write the supporting code that allowed the text of error messages to be both checked in CI and easily included in the book itself. Within hours of posting a new revision, excited readers would be finding mistakes, providing suggestions, and showering me with kindness. In particular, I'd like to thank Arien Malec, Asta Halkjær From, Bulhwi Cha, Craig Stuntz, Daniel Fabian, Evgenia Karunus, eyelash, Floris van Doorn, František Silváši, Henrik Böving, Ian Young, Jeremy Salwen, Jireh Loreaux, Kevin Buzzard, Lars Ericson, Liu Yuxi, Mac Malone, Malcolm Langfield, Mario Carneiro, Newell Jensen, Patrick Massot, Paul Chisholm, Pietro Monticone, Tomas Puverle, Yaël Dillies, Zhiyuan Bao, and Zyad Hassan for their many suggestions, both stylistic and technical.
伝統によれば、プログラミング言語は、コンソールに "Hello, world!"
と表示するプログラムをコンパイルし、実行することで導入されるべきです。この単純なプログラムは、言語ツールが正しくインストールされ、プログラマがコンパイルされたコードを実行できることを保証します。
しかし、1970年代以降、プログラミングは変わりました。今日、コンパイラは一般的にテキストエディタに統合されており、プログラミング環境はプログラムを書いている最中にフィードバックを提供してくれます。Lean も例外ではありません:Lean は言語サーバープロトコル(LSP:Language Server Protocol)の拡張版を実装していて、テキストエディタと情報交換し、ユーザが入力するたびにフィードバックを提供することができます。
Python・Haskell・JavaScript などのさまざまな言語が、REPL(read-eval-print-loop)を提供しています。REPL は対話的なトップレベル、あるいはブラウザコンソールとしても知られています。REPL 環境内では、ユーザが式や文を入力することができます。そして、プログラミング言語はユーザの入力に基づき計算を実行し、結果を表示します。一方 Lean は、これらの機能をエディタとの対話機能に統合し、プログラムテキスト自体に統合されたフィードバックをテキストエディタ上に表示させるコマンドを提供します。この章では、エディタ内で Lean を操作する方法を簡単に紹介します。次章 Hello, World! ではコマンドラインからバッチモードで Lean を使用する伝統的な方法を説明します。
この本は、Lean をエディタで開きながら、コード例に沿って、そして実際にコード例を入力しながら読むのがベストです。コード例で遊びましょう!そして、何が起こるか見てみましょう!
式の評価
Lean を学ぶプログラマが理解すべき最も重要なことは、評価の仕組みです。評価とは、算数で行うような式の値を求めるプロセスのことです。例えば、15-6 の値は 9、2×(3+1) の値は 8 です。後者の式の値を求めるとき、まず 3+1 が 4 に置き換えられて 2×4 となります。これは簡約して 8 にできます。数式に変数が含まれることがあります:x+1 の値は、x の値がわかるまで計算できません。Lean では、プログラムはまず第一に式であり、計算を考える第一の方法は、式を評価して値を求めることです。
ほとんどのプログラミング言語は命令型であり、プログラムは、プログラムの結果を求めるために実行されるべき一連の文で構成されています。プログラムは可変なメモリにアクセスできるので、変数が参照する値は時間とともに変化する可能性があります。可変な状態に加えて、プログラムには、ファイルの削除・ネットワーク接続の発信・例外のスローまたはキャッチ・データベースからのデータの読み込みなど、他の副作用があるかもしれません。「副作用」とは、本質的に、数学的な式を評価するモデルに従わないプログラム内で起こりうることの総称です。
しかし Lean では、プログラムは数式と同じように機能します。一度値が与えられた変数は、再代入できません。式を評価しても副作用はありません。2つの式が同じ値を持つ場合、一方を他方に置き換えても、プログラムが異なる結果を計算することはありません。これは、Lean を使ってコンソールに Hello, world!
と書くことができないという意味ではありません。しかし I/O の実行は Lean を扱う経験の核心部分ではないとは言えるでしょう。そこで、この章では Lean を使って対話的に式を評価する方法に焦点を当て、次の章で Hello, world!
プログラムの書き方、コンパイル方法、実行方法について説明します。
Lean に式の評価をしてもらいたいとき、エディタで式の前に #eval
と書けば結果を報告してくれます。通常、カーソルやマウスポインタを #eval
の上に置くと結果が表示されます。例えば、
#eval 1 + 2
と書くと 3
と表示されます。
Lean は通常の算術演算子の優先順位と結合法則に従います。つまり、
#eval 1 + 2 * 5
は 15
ではなく 11
を返します。
通常の数学的表記法でも、大半のプログラミング言語でも、関数をその引数に適用する際には括弧を使います(例:f(x)
)が、Lean は単に関数をその引数の横に書きます (例:f x
)。関数の使用は最も一般的な操作のひとつであるため、簡潔であることが重要なのです。"Hello, Lean!"
を計算するには、
#eval String.append("Hello, ", "Lean!")
と書く代わりに、関数の2つの引数を単に空白区切りで隣に書いて
#eval String.append "Hello, " "Lean!"
とします。
算術の演算順序規則で、(1 + 2) * 5
という式に括弧が必要なように、関数の引数を別の関数呼び出しで計算する場合にも括弧が必要です。例えば次の式では括弧が必要です。
#eval String.append "great " (String.append "oak " "tree")
そうしないと、2番目の String.append
は、"oak "
と "tree "
を引数として渡された関数としてではなく、最初の String.append
の引数として解釈されてしまうからです。最初に String.append
の内部呼び出しの値が評価され、その値を "great "
に追加することで、最終的な値 "great oak tree "
を得ることができます。
命令形言語には、しばしば2種類の条件分岐があります:Bool 値に基づいてどの命令を実行するかを決定する条件文と、Bool 値に基づいて2つの式のうちどちらを評価するかを決定する条件式です。たとえば C や C++ では、条件文は if
と else
を使って書かれ、条件式は三項演算子 ?
:
を使って書かれます。Python では、条件文は if
で始まりますが、条件式は if
を真ん中に置きます。Lean はというと式指向の関数型言語ですから、条件文はありません。条件式のみです。条件式は if
、then
、else
を使って書かれます。例えば、
String.append "it is " (if 1 > 2 then "yes" else "no")
は次のように評価されます。
String.append "it is " (if false then "yes" else "no")
これはさらに次のように評価され、
String.append "it is " "no"
最終的に "it is no"
と評価されます。
簡潔にするために、このような一連の評価ステップを矢印で区切って書くことがあります:
String.append "it is " (if 1 > 2 then "yes" else "no")
===>
String.append "it is " (if false then "yes" else "no")
===>
String.append "it is " "no"
===>
"it is no"
よくあるエラー
Lean に引数のない関数適用の評価を依頼すると、エラーメッセージが表示されます。たとえば、特に
#eval String.append "it is "
とすると、かなり長いエラーメッセージを出力します:
expression
String.append "it is "
has type
String → String
but instance
Lean.MetaEval (String → String)
failed to be synthesized, this instance instructs Lean on how to display the resulting value, recall that any type implementing the `Repr` class also implements the `Lean.MetaEval` class
このメッセージは、一部の引数のみ与えられた Lean 関数が、残りの引数を待つ新しい関数を返すために発生します。Lean はユーザに関数を表示することができないため、表示するように要求されるとエラーを返すのです。
演習問題
次の式の値はなんでしょうか?答えを予想してから、Lean に入力してチェックしてみましょう。
42 + 19
String.append "A" (String.append "B" "C")
String.append (String.append "A" "B") "C"
if 3 == 3 then 5 else 7
if 3 == 4 then "equal" else "not equal"
型
型は計算できる値に基づいてプログラムを分類します。型がプログラムの中で果たす役割は多岐に渡ります:
- 型により、コンパイラが値のメモリ内表現について判断できるようになります。
- 型は、プログラマが自分の意図を他者に伝えるのに役立ちます。また型は、関数の入力と出力に対する軽量な仕様として機能します。コンパイラはプログラムが実際にその仕様を満たすことを検証できます。
- 型は文字列に数字を足してしまうようなミスを未然に防ぎ、プログラムに必要なテストの数を減らすことができます。
- 型は Lean のコンパイラが補助コードを自動生成するのを助け、ボイラープレートを減らすことができます。
Lean の型システムは非常に表現力に富んでいます。型によって「このソート関数は入力の並べ替えを返す」というような強力な仕様や、「この関数は引数の値によって戻り値の型が異なる」というような柔軟な仕様をエンコードすることができます。型システムは、数学の定理を証明するための本格的な論理として使うこともできます。しかし、この最先端の表現力は、より単純な型の必要性を排除するものではありません。単純な型を理解することは、より高度な機能を使うための前提条件です。
Lean のすべてのプログラムは型を持たなければなりません。特に、すべての式は評価される前に型を持っていなければいけません。これまでの例では、Lean は自分で型を推測することができましたが、時には型を提供する必要があります。型の指定はコロン演算子を使って行います:
#eval (1 + 2 : Nat)
ここで Nat
は自然数の型で、任意精度の符号なし整数です。Lean では、Nat
は非負整数リテラルのデフォルト型です。このデフォルトの型は必ずしも最良の選択ではありません。C 言語では、符号なし整数は、引き算の結果が0未満になる場合、表現可能な最大の数までアンダーフローします。しかし Nat
型は任意の大きさの符号なし整数を表現できますから、アンダーフローすべき最大の数が存在しません。したがって、Nat
の引き算は、そうでなければ答えが負になるはずのときに0を返します。例えば
#eval 1 - 2
は -1
ではなく 0
を返します。負の整数を表現できる型を使うには、型を直接指定します:
#eval (1 - 2 : Int)
この型では、結果は予想通り-1
です。
式を評価せずに型を確かめるには、#eval
の代わりに #check
を使います。例えば
#check (1 - 2 : Int)
は、実際の引き算は実行せず、1 - 2 : Int
を報告します。
プログラムに型が与えられない場合、#check
も #eval
もエラーを返します。例えば:
#check String.append "hello" [" ", "world"]
というコードは下記のエラーを出力します。
application type mismatch
String.append "hello" [" ", "world"]
argument
[" ", "world"]
has type
List String : Type
but is expected to have type
String : Type
String.append
の第2引数は文字列であることが期待されていますが、代わりに文字列のリストが代入されているからです。
関数と定義
Lean では、定義は def
というキーワードを使って導入されます。例えば、文字列 "Hello"
を指す名前として hello
を定義するには、こう書きます:
def hello := "Hello"
Lean では、新しい名前は =
ではなくコロンと等号を組み合わせた記号 :=
を使って定義されます。これは、=
が既存の式同士の等式を表すのに使われるためで、異なる演算子を使うことで混乱を防ぐことができます。
hello
の定義の場合、"Hello"
という式は単純なので、Lean は定義の型を自動的に判断することができます。しかし、ほとんどの定義はそれほど単純ではないので、通常は型を注釈する必要があります。これは、定義する名前の後にコロンを使って行います。
def lean : String := "Lean"
名前が定義されたら、それを使うことができます。
#eval String.append hello (String.append " " lean)
上のコードは次を出力します。
"Hello Lean"
Lean では、定義されるまである名前を使うことはできません。
多くの言語では、関数の定義には他の値の定義とは異なる構文を使用します。例えば、Python の関数定義は def
キーワードで始まりますが、他の定義は等号で定義されます。Lean では、関数も他の値と同じ def
キーワードを使って定義されます。それにもかかわらず、hello
のような定義は、呼び出されるたびに同等の結果を返すゼロ引数関数ではなく、その値を直接参照する名前を導入しています。
関数の定義
Lean で関数を定義するには様々な方法があります。最もシンプルな方法は、関数の引数を定義型の前にスペースで区切って置くことです。例えば、引数に1を加える関数はこう書けます:
def add1 (n : Nat) : Nat := n + 1
この関数を #eval
でテストすると、予想通り 8
が得られます:
#eval add1 7
関数が各引数の間にスペースを書くことで複数の引数に適用されるように、複数の引数を受け付ける関数は、引数の名前と型の間にスペースを入れることで定義されます。関数 maximum
は、2つの引数の最大値を返すもので、2つの Nat
型引数 n
と k
を取り、Nat
を返します。
def maximum (n : Nat) (k : Nat) : Nat :=
if n < k then
k
else n
maximum
のような定義された関数が引数とともに提供されると、結果は、まず引数名を与えられた値に置き換え、次に本体を評価するというように決定されます。例えば:
maximum (5 + 8) (2 * 7)
===>
maximum 13 14
===>
if 13 < 14 then 14 else 13
===>
14
自然数、整数、文字列を表す式は、それぞれ Nat
、Int
、String
型を持ちます。Nat
を受け取って Bool
を返す関数は Nat → Bool
型を持ち、2つの Nat
を受け取って Nat
を返す関数は Nat → Nat → Nat
型を持ちます。
特別な場合として、Lean は関数名が #check
で直接使われた場合、その関数のシグネチャを返します。#check add1
と入力すると、add1 (n : Nat) : Nat
が得られます。しかし、関数名を括弧で囲むことで関数の型を示すように Lean を「騙す」ことができます。したがって #check (add1)
は add1 : Nat → Nat
という出力を返し、#check (maximum)
は maximum : Nat → Nat → Nat
という出力を返します。この矢印は ASCII の代替矢印 ->
で書くこともできるので、先の関数型はそれぞれ Nat -> Nat
、Nat -> Nat -> Nat
と書くことができます。
舞台裏では、すべての関数は実際にはちょうど1つの引数を受け付けます。複数の引数を取るように見える max
関数などは、実際には1つの引数を取って新しい関数を返す関数です。この新しい関数は次の引数を取り、その処理は引数がなくなるまで続きます。これは、複数の引数を持つ関数に1つの引数を与えることでわかります:#check maximum 3
は maximum 3 : Nat → Nat
を返し、#check String.append "Hello "
は String.append "Hello " : String → String
を返します。複数引数の関数を実装するために関数を返す関数を使うことを、数学者のハスケル・カリー(Haskell Curry)にちなんでカリー化(currying)と呼びます。矢印は右結合です。つまり、Nat → Nat → Nat
に括弧を付けるなら Nat → (Nat → Nat)
となります。
演習
- 関数
joinStringsWith
をString -> String -> String -> String
型の関数であって、その第一引数を第二引数と第三引数の間に配置して新しい文字列を作成するようなものとして定義してください。joinStringsWith ", " "one" "and another"
は"one, and another"
に等しくなるはずです。
joinStringsWith ": "
の型は何でしょうか? Lean で答えを確認してください。
- 与えられた高さ、幅、奥行きを持つ直方体の体積を計算する関数
volume
をNat → Nat → Nat → Nat
型の関数として定義してください。
型の定義
ほとんどの型付きプログラミング言語には、C 言語の typedef
のように、型のエイリアスを定義する手段があります。しかし Lean では、型は言語の第一級の部分であり、他の式と同じように扱われます。これは、定義が他の値を参照するのと同様に、型を参照できることを意味します。
例えば、String
が入力するには長すぎる場合、より短い省略形 Str
を定義することができます:
def Str : Type := String
そうすれば、String
の代わりに Str
を定義の型として使うことができます:
def aStr : Str := "This is a string."
これが機能するのは、型が Lean の他の部分と同じルールに従うからです。型は式であり、式の中では、定義された名前はその定義に置き換えることができます。Str
は String
に等しいと定義されているので、aStr
の定義は意味をなしています。
よくあるエラー
型を定義するとき、Lean がオーバーロードされた整数リテラルをサポートする方法との兼ね合いで、より複雑な挙動をすることがあります。Nat
が短すぎる場合は、NaturalNumber
という長い名前を定義することができます:
def NaturalNumber : Type := Nat
しかし、Nat
の代わりに NaturalNumber
を定義の型として使っても、期待した効果は得られません。例えば、以下のように定義したとします:
def thirtyEight : NaturalNumber := 38
これは次のようなエラーになります:
failed to synthesize instance
OfNat NaturalNumber 38
このエラーは、Lean が数値リテラルのオーバーロード(overload)を許可しているために発生します。自然数リテラルは、あたかもその型がシステムに組み込まれているかのように、新しい型に使用することができます。これは、数学の表現を便利にするという Lean の使命の一部です。数学でも分野によって、数字をまったく異なる概念を表すのに使っています。このオーバーロードを可能にするための機能は、定義された名前をすべてその定義に置き換える前に、オーバーロードを探します。それが上のエラーメッセージを引き起こします。
このエラーを回避する1つの方法は、定義の右側に Nat
型を指定し、Nat
のオーバーロード・ルールを 38
に使用させることです:
def thirtyEight : NaturalNumber := (38 : Nat)
NaturalNumber
は定義から Nat
と同じ型なので、この定義は正しく型付けされています。
もう一つの解決策は、Nat
と同等に機能する NaturalNumber
のオーバーロードを定義することです。しかし、これには Lean のより高度な機能が必要です。
最後の解決策は、def
の代わりに abbrev
を使って Nat
の新しい名前を定義することです。これで定義された名前をその定義に置き換えるオーバーロード解決が可能になります。abbrev
を使って書かれた定義は常に展開されます。例えば
abbrev N : Type := Nat
としたとき
def thirtyNine : N := 39
はエラーになりません。
舞台裏では、オーバーロードの解決時に、展開可能(unfoldable)であると内部でマークされる定義もあれば、そうでない定義もあります。展開される定義は reducible (簡約可能)と呼ばれます。Lean をスケールさせるためには、定義の展開可能性のコントロールが不可欠です:すべての定義を完全に展開すると、型が非常に大きくなり、機械が処理するのに時間がかかりますし、ユーザにとっても理解しづらいものになります。abbrev
で生成された定義は reducible であるとマークされます。
構造体
プログラムを書く最初のステップは、通常、問題領域の概念を確認し、それをコードで適切に表現することです。ドメイン概念は、他のもっと単純な概念の集まりであることがあります。そういったとき、単純な構成要素をひとつの「パッケージ」にまとめ、意味のある名前をつけると便利でしょう。Lean では、それは構造体(structure)によって実現できます。これは Rust や C の struct
、C# でいう record
に対応するものです。
構造体を定義すると、Lean は他のどの型にも簡約できない、全く新しい型を導入します。構造体は、同じデータを含んでいても異なる概念を表している可能性があるため、他の型に簡約できない必要があります。例えば、点はデカルト座標か極座標のどちらかを使って表現しても、それぞれ浮動小数点数の組であることは同じです。別々の構造体を定義することで、APIの使用者が混同しにくくなります。
Lean の浮動小数点数型は Float
と呼ばれます。浮動小数点数は一般的な記法で記述されます。
#check 1.2
1.2 : Float
#check -454.2123215
-454.2123215 : Float
#check 0.0
0.0 : Float
浮動小数点数が小数点付きで記述されている場合、Lean は Float
型だと推論します。小数点なしで書かれた場合は、型注釈が必要になることがあります。
#check 0
0 : Nat
#check (0 : Float)
0 : Float
デカルト点(Cartesian point)は、x
と y
という2つの Float
型のフィールドを持つ構造体です。これは structure
キーワードを使って宣言されます。
structure Point where
x : Float
y : Float
deriving Repr
この宣言の後、Point
は新しい構造体型になります。最後の行には deriving Repr
と書かれていますが、これは Lean に Point
型の値を表示するコードを生成するよう指示しています。このコードは #eval
で使用され、プログラマに評価結果を表示します。 Python での repr
関数と同様のものです。コンパイラが生成した表示コードを上書きすることも可能です。
構造体型の値を作る典型的な方法は、中括弧の中にすべてのフィールドの値を指定することです。デカルト平面(Cartesian plane)の原点とは、x
と y
がともにゼロとなる場所です:
def origin : Point := { x := 0.0, y := 0.0 }
Point
の定義で deriving Repr
の行を省略すると、#eval origin
を実行しようとしたとき、関数の引数を省略したときと同様のエラーが発生します:
expression
origin
has type
Point
but instance
Lean.MetaEval Point
failed to be synthesized, this instance instructs Lean on how to display the resulting value, recall that any type implementing the `Repr` class also implements the `Lean.MetaEval` class
このメッセージは、評価マシンが評価結果をユーザに伝える方法を知らないという意味です。
喜ばしいことに deriving Repr
を付け加えれば評価することができ、origin
の定義とよく似た結果が #eval origin
から出力されます。
{ x := 0.000000, y := 0.000000 }
構造体はデータの集まりを「束ね」、名前を付けて一つの単位として扱うために存在するため、構造体の個々のフィールドを抽出できることも重要です。C 言語や Python, Rust のようにドット記法を用いてこれを行うことができます。
#eval origin.x
0.000000
#eval origin.y
0.000000
ドット記法は、構造体を引数に取る関数を定義するために使用できます。例えば、各座標の値を加算することによって、点の加算を行うことを考えます。#eval addPoints { x := 1.5, y := 32 } { x := -8, y := 0.2 }
の評価結果は次のようになるべきです:
{ x := -6.500000, y := 32.200000 }
この addPoints
関数は、p1
と p2
と呼ばれる2つの Point
型の項を引数に取ります。そして返される点は、p1
と p2
の両方の x
、y
成分に依存しています:
def addPoints (p1 : Point) (p2 : Point) : Point :=
{ x := p1.x + p2.x, y := p1.y + p2.y }
同様に、2点間の距離は、x
成分と y
成分の差の二乗和の平方根であり、次のように書くことができます:
def distance (p1 : Point) (p2 : Point) : Float :=
Float.sqrt (((p2.x - p1.x) ^ 2.0) + ((p2.y - p1.y) ^ 2.0))
たとえば、(1, 2) と (5, -1) の距離は 5 です。
#eval distance { x := 1.0, y := 2.0 } { x := 5.0, y := -1.0 }
5.000000
複数の構造体に同じ名前のフィールドが存在することもありえます。例えば、3次元の点のデータ型を考えます。フィールド x
と y
は2次元のものと同じ名前にできます。更に同じフィールド名でインスタンス化することができます:
structure Point3D where
x : Float
y : Float
z : Float
deriving Repr
def origin3D : Point3D := { x := 0.0, y := 0.0, z := 0.0 }
つまり、中括弧構文を使うためには、構造体に期待される型がわかっていなければなりません。型がわからない場合、Lean は構造体をインスタンス化することができません。たとえば
#check { x := 0.0, y := 0.0 }
は次のようなエラーになります:
invalid {...} notation, expected type is not known
いつものように、型注釈をつけることでこの状況を改善することができます。
#check ({ x := 0.0, y := 0.0 } : Point)
{ x := 0.0, y := 0.0 } : Point
より簡潔な書き方として、Lean では中括弧の中に構造型の注釈を入れることもできます。
#check { x := 0.0, y := 0.0 : Point}
{ x := 0.0, y := 0.0 } : Point
構造体の更新
Point
のフィールド x
を 0.0
に置き換える関数 zeroX
を考えてみてください。だいたいのプログラミング言語では、この操作は x
が指すメモリロケーションを新しい値で上書きすることを意味します。しかし、Lean は可変(mutable)な状態を持たないのでした。関数型プログラミングの界隈では、ほとんどの場合この種の文が意味するのは、「x
フィールドが新しい値を指し、他のすべてのフィールドが元の値を指す、新しい Point
を割り当てる」ということです。zeroX
の実装法のひとつは、上記の記述に忠実に、x
に新しい値を入れて、y
を手動で移すことです:
def zeroX (p : Point) : Point :=
{ x := 0, y := p.y }
しかし、この実装の仕方には欠点があります。まず、構造体に新しいフィールドを追加するとき、フィールドを更新しているすべての個所を修正しなければならず、保守が困難になります。第2に、構造体に同じ型のフィールドが複数含まれている場合、コピー&ペーストのコーディングによってフィールドの内容を重複させてしまったり、入れ替えてしまったりする現実的なリスクがあります。最後に、定型文を書かなければならないのでプログラムが長くなってしまいます。
Lean には、構造体の一部のフィールドだけを置換し、他はそのままにするための便利な構文があります。構造体を初期化する際に with
キーワードを付けることです。with
の後に現れるフィールドだけが更新されます。例えば zeroX
を更新対象の x
の値だけで書くことができます:
def zeroX (p : Point) : Point :=
{ p with x := 0 }
この with
による構造体を更新する構文は、既存の値は変更しておらず、既存の値とフィールドの一部を共有する新しい値を生成しているということに注意してください。たとえば、fourAndThree
という点を考えます:
def fourAndThree : Point :=
{ x := 4.3, y := 3.4 }
fourAndThree
を評価し、zeroX
を使って更新された値を評価し、再び fourAndThree
を評価してみると、元の値が得られます:
#eval fourAndThree
{ x := 4.300000, y := 3.400000 }
#eval zeroX fourAndThree
{ x := 0.000000, y := 3.400000 }
#eval fourAndThree
{ x := 4.300000, y := 3.400000 }
構造体を更新しても元の値は変更されないので、新しい値を計算することによりコードの理解が難しくなることはありません。古い構造体への参照はすべて、変わらず同じフィールド値を参照し続けます。
舞台裏
すべての構造体には コンストラクタ(constructor) があります。ここで、「コンストラクタ」という用語は混乱を生むかもしれません。Java や Python におけるコンストラクタとは異なり、Lean のコンストラクタは「データ型の初期化時に実行される任意のコード」ではありません。Lean におけるコンストラクタは、新しく割り当てられたデータ構造にデータを集めて格納するだけです。データを前処理したり、無効な引数を拒否したりするカスタムコンストラクタを作ることはできません。このように「コンストラクタ」という言葉の意味は2つの文脈で異なるのですが、しかし関連はあります。
デフォルトでは S
という名前の構造体のコンストラクタは S.mk
という名前になります。ここで、S
は名前空間修飾子、mk
はコンストラクタそのものの名前です。中括弧による初期化構文を使う代わりに、コンストラクタを直接使うことができます。
#check Point.mk 1.5 2.8
しかし、一般的にこれは Lean のスタイルとして良いものと考えられていません。Lean は標準的な(中括弧を使った)構文でフィードバックを返します。
{ x := 1.5, y := 2.8 } : Point
コンストラクタは関数型を持っているため、関数が期待される場所であればどこでも使用できます。例えば、Point.mk
は2つの Float
の項(それぞれ x
と y
)を受け取り、新しい Point
を返す関数です。
#check (Point.mk)
Point.mk : Float → Float → Point
構造体のコンストラクタ名を上書きするには、先頭にコロン2つを付けて書きます。例えば、Point.mk
の代わりに Point.point
を使うには、次のように書きます:
structure Point where
point ::
x : Float
y : Float
deriving Repr
コンストラクタに加えて、構造体の各フィールドに対してアクセサ関数が定義されます。アクセサ関数は構造体の名前空間でフィールドと同じ名前を持っています。Point
については、アクセサ関数 Point.x
と Point.y
が生成されます。
#check (Point.x)
Point.x : Point → Float
#check (Point.y)
Point.y : Point → Float
実際、構造体を作る中括弧による構文が舞台裏で構造体のコンストラクタを呼ぶように、 戦術の addPoints
の定義における p1.x
は変換されてアクセサ Point.x
を呼び出します。つまり、#eval origin.x
と #eval Point.x origin
の結果は等しく、次が得られます。
0.000000
アクセサドット記法は、構造体のフィールド以外にも使えます。 任意の数の引数を取る関数にも使用できます。 より一般的に、アクセサ記法は TARGET.f ARG1 ARG2 ...
という形をしています。このとき TARGET
の型が T
であれば、T.f
という関数が呼び出されます。TARGET
は関数 T.f
の型 T
を持つ最初の引数になります。 多くの場合これは最初の引数ですが、常にではありません。ARG1 ARG2 ...
は順に残りの引数として与えられます。例えば String.append
は、String
が append
フィールドを持つ構造体ではないにもかかわらず、アクセサ記法を使って文字列から呼び出すことができます。
#eval "one string".append " and another"
"one string and another"
上記の例では TARGET
が "one string"
に当たり、ARG1
が " and another"
に当たります。
関数 Point.modifyBoth
(つまり、Point
名前空間で定義された modifyBoth
) は、Point
の全てのフィールドに関数を適用します:
def Point.modifyBoth (f : Float → Float) (p : Point) : Point :=
{ x := f p.x, y := f p.y }
Point
型の引数は関数型の引数の後に来ていますが、ドット記法でも問題なく使用できます:
#eval fourAndThree.modifyBoth Float.floor
{ x := 4.000000, y := 3.000000 }
この例では TARGET
は fourAndThree
に当たり、ARG1
は Float.floor
に当たります。これは、アクセサ記法のターゲットが、型が一致する最初の引数として使われ、必ずしも最初の引数として使われるとは限らないことによるものです。
演習問題
RectangularPrism
(角柱) という名前で、height
,width
,depth
の3つのFloat
型のフィールドを持つ構造体を定義してください。
- 角柱の体積を計算する関数
volume : RectangularPrism → Float
を定義してください。
- 端点をフィールドとして、線分を表す構造体
Segment
を定義してください。そして線分の長さを計算する関数length : Segment → Float
を定義してください。なおSegment
のフィールドは2つ以下としてください。
RectangularPrism
の宣言により、どういう名前が導入されましたか?
- 以下の
Hamster
とBook
の宣言により、どういう名前が導入されましたか? またその型はどうなっていますか。
structure Hamster where
name : String
fluffy : Bool
structure Book where
makeBook ::
title : String
author : String
price : Float
データ型とパターン
構造体を使用すれば、複数の独立したデータをひとまとまりにしてまったく新しい型をつくることができます。値の集まりをグループ化する構造体のような型は直積型(product types)と呼ばれます。ただし、多くのドメイン概念は構造体として自然に表現できません。例えば、アプリケーションによっては、ドキュメントの所有者であるユーザー、ドキュメントを編集できるユーザー、ドキュメントの閲覧しかできないユーザーなどユーザーのアクセス権限を追う必要があるかもしれません。電卓であれば、加算、減算、乗算のような二項演算子があります。構造体で複数の選択肢を表現する簡単な方法はありません。
同様に、構造体は決まったフィールドの集まりを追跡する優れた方法ですが、多くのアプリケーションでは任意個の要素を含むことができるデータが必要です。ツリーやリストなどの古典的なデータ構造のほとんどは再帰的な構造を持っています。リストの先頭を除いた部分自体がリストになっていますし、二分木の左右の枝自体が二分木です。前述の電卓の例でいえば、式の構造自体が再帰的です。例えば、加算式内の足される式自体が乗算式であることがあります。
選択することができるデータ型は直和型(sum types)と呼ばれ、それ自体のインスタンスを含めることができるデータ型は再帰データ型(recursive datatypes)と呼ばれます。再帰直和型は、それらに関する文を証明するために数学的帰納法を使用できるため、帰納的データ型(inductive datatypes)と呼ばれます。プログラミングの際、帰納的データ型はパターンマッチングと再帰関数を通じて使用されます。
標準ライブラリでは、組み込み型の多くは実際には帰納的データ型です。例えば、Bool
は帰納的データ型です。
inductive Bool where
| false : Bool
| true : Bool
この定義には2つの主要な部分があります。初めの行は新しい型の名前(Bool
)を提供し、残りの行はそれぞれコンストラクタを記述します。構造体のコンストラクタと同様、帰納的データ型のコンストラクタは、任意の初期化コードやバリデーションコードを挿入する場所ではなく、単なる他のデータのレシーバおよびコンテナにすぎません。構造体とは異なり、帰納的データ型は複数のコンストラクタを持つ可能性があります。今の例では、true
と false
という2つのコンストラクタがあり、どちらも引数を取りません。構造体宣言がそのフィールドの名前を、宣言された型と同名の名前空間に配置するのと同様に、帰納的データ型はそのコンストラクタの名前を名前空間に配置します。Lean 標準ライブラリでは、true
と false
はこの名前空間から再エクスポートされるため、それぞれ Bool.true
と Bool.false
としてではなく、単独で記述できます。
データモデリングの観点から見ると、帰納的データ型が使用される場面の多くは、他の言語における抽象クラスが使用される場面と同じです。C# や Java のような言語では、同様の Bool
の定義を書くことができます。
abstract class Bool {}
class True : Bool {}
class False : Bool {}
ただし、これらの表現の詳細はかなり異なります。特に、各非抽象クラスは、新しい型と新しいデータ割り当て方法の両方を作成します。オブジェクト指向の例では、True
と False
は両方とも Bool
よりも具体的な型ですが、Leanでの定義では新しい型 Bool
のみが導入されます。
非負整数の型 Nat
は帰納的データ型です。
inductive Nat where
| zero : Nat
| succ (n : Nat) : Nat
ここで、zero
は0を表し、succ
は他の数値の後続値を表します。succ
の宣言で言及されている Nat
は、まさにこれから定義しようとしている Nat
型です。後続値(Successor)は「1つ大きい」を意味するため、5の後続値は6、32,185の後続値は32,186です。この定義を使用すると、4
はNat.succ (Nat.succ (Nat.succ (Nat.succ Nat.zero)))
として表されます。この定義は、名前が異なるだけで Bool
の定義に似ています。唯一の本当の違いは、succ
の後に (n : Nat)
が続くことです。これは、コンストラクタ succ
が n
という名前の Nat
型の引数を取ることを指定します。名前 zero
と succ
は、その型と同名の名前空間内にあるため、それぞれ Nat.zero
と Nat.succ
として参照する必要があります。
n
などの引数名は、Lean のエラーメッセージや数学で証明を行うときに提供されるフィードバックに出現することがあります。Lean には、引数を名前で指定するためのオプションの構文もあります。ただし、引数名が API に占める部分はあまり大きくないため、一般に引数名の選択は構造体のフィールド名の選択ほど重要ではありません。
C# または Java では、Nat
は次のように定義できます:
abstract class Nat {}
class Zero : Nat {}
class Succ : Nat {
public Nat n;
public Succ(Nat pred) {
n = pred;
}
}
以前の Bool
の例と同様に、これは対応する Lean での定義よりも多くの型を定義します。さらにこの例は、Lean のデータ型コンストラクタが、C# や Java のコンストラクタというよりも抽象クラスのサブクラスに近いことを示唆しています。これは、ここで示されているコンストラクタには、初期化時に実行されるコードが含まれているためです。
直和型は、TypeScript で文字列タグを使用して判別共用体(discriminated unions)を実装することにも似ています。TypeScript では、Nat
は次のように定義できます。
interface Zero {
tag: "zero";
}
interface Succ {
tag: "succ";
predecessor: Nat;
}
type Nat = Zero | Succ;
C# や Java と同様に、Zero
と Succ
はそれぞれ独立した型であるため、この実装では Lean よりも多くの型が含まれることになります。また、Lean のコンストラクタが中身を識別するタグを含む JavaScript または TypeScript のオブジェクトに対応することも示しています。
パターンマッチング
多くの言語では、この種のデータは、最初に instance-of 演算子を使用してどのサブクラスを受け取ったかを確認し、次に与えられたサブクラスで使用可能なフィールドの値を読み取るというように使用されます。instance-of によるチェックは実行するコードを決定し、フィールド自体がデータを提供しながら、このコードで必要なデータが利用可能であることを確認します。Lean では、これらの目的は両方ともパターンマッチングによって同時に達成されます。
パターンマッチングを使用する関数の例として次の isZero
を見ましょう。これは、引数が Nat.zero
の場合に true
を返し、それ以外の場合は false
を返す関数です。
def isZero (n : Nat) : Bool :=
match n with
| Nat.zero => true
| Nat.succ k => false
match
式には、デストラクトのために関数の引数 n
が与えられます。n
がコンストラクタ Nat.zero
からくる場合、マッチパターンの最初の分岐が選択され、結果はtrue
になります。n
がコンストラクタ Nat.succ
からくる場合、2番目の分岐が選択され、結果は false
になります。
isZero Nat.zero
の評価は、段階的には次のように進められます。
isZero Nat.zero
===>
match Nat.zero with
| Nat.zero => true
| Nat.succ k => false
===>
true
isZero 5
の評価も同様に行われます。
isZero 5
===>
isZero (Nat.succ (Nat.succ (Nat.succ (Nat.succ (Nat.succ Nat.zero)))))
===>
match Nat.succ (Nat.succ (Nat.succ (Nat.succ (Nat.succ Nat.zero)))) with
| Nat.zero => true
| Nat.succ k => false
===>
false
isZero
のパターンの2番目の分岐の k
は飾りではありません。これにより、succ
の引数である Nat
が与えられた名前で表示されます。そのより小さい数値を使用して、式の最終結果を計算できます。
ある自然数 \(n\) の後続の数が \(n\) より1大きい(つまり、\(n+1\) )のと同じように、ある数の前の数はその数より 1 小さな数です。pred
をある Nat
の前の数を見つける関数としたとき、次の例の結果は期待通りでしょう。
#eval pred 5
4
#eval pred 839
838
Nat
は負の数を表すことができないため、0
は少し難問です。通常、Nat
を使用する場合、普通なら負の数を生成する演算子は 0
を生成するように再定義されます。
#eval pred 0
0
Nat
の前者関数を作る最初のステップは、与えられた数を作るためにどのコンストラクタが使用されたかを確認することです。それが Nat.zero
だった場合、結果は Nat.zero
になります。それが Nat.succ
だった場合、その下の Nat
を参照するために名前 k
が使用されます。そして、この Nat
が求めたかった前者であるため、Nat.succ
分岐の結果は k
になります。
def pred (n : Nat) : Nat :=
match n with
| Nat.zero => Nat.zero
| Nat.succ k => k
この関数を 5
に適用すると、次の手順が出てきます。
pred 5
===>
pred (Nat.succ 4)
===>
match Nat.succ 4 with
| Nat.zero => Nat.zero
| Nat.succ k => k
===>
4
パターンマッチングは、直和型だけでなく構造体でも使用できます。たとえば、Point3D
から \(z\) 座標を取り出す関数は次のように記述できます。
def depth (p : Point3D) : Float :=
match p with
| { x:= h, y := w, z := d } => d
この場合、単に z
アクセサを使用する方がはるかにシンプルですが、構造体パターンが関数を記述する最もシンプルな方法になる場合もあります。
再帰関数
定義しようとしている名前を参照する定義は、再帰的定義(recursive definitions)と呼ばれます。帰納的データ型は再帰的に書くことができます。実際、succ
は別のNat
を要求するため、Nat
はそのようなデータ型の例です。再帰データ型は、いくらでも大きなデータを表すことができます。制限は使用可能なメモリなどの技術的要因だけです。データ型の定義時に自然数ごとに1つのコンストラクターを書き下すのが不可能であるのと同じく、可能なすべてのパターンマッチのケースを書き出すことも不可能です。
再帰データ型は、再帰関数によって適切に補完されます。Nat
に対する単純な再帰関数の例として、引数が偶数かどうかをチェックする次のような関数を考えましょう。このとき、zero
は偶数です。このように、コードの非再帰分岐を基底ケース(base cases)と呼びます。奇数の後続は偶数であり、偶数の後続は奇数です。これは、succ
で作られた数は、その引数が偶数でない場合にのみ偶数であることを意味します。
def even (n : Nat) : Bool :=
match n with
| Nat.zero => true
| Nat.succ k => not (even k)
この思考パターンは、Nat
上の再帰関数を記述する場合に典型的なものです。まず、zero
に対して何をすべきかを特定します。次に、任意の Nat
の結果をその後続に対する結果に変換する方法を決定し、この変換を再帰呼び出しの結果に適用します。このパターンは構造的再帰(structural recursion)と呼ばれます。
多くの言語とは異なり、Lean はデフォルトで、すべての再帰関数が最終的に基本ケースに到達することを保証します。プログラミングの観点から見ると、これにより偶発的な無限ループが排除されます。ただし、この機能は定理証明で特に重要です。定理証明では、無限ループが大きな問題を引き起こすからです。たとえば、Lean は同じ数に対して再帰的に自身を呼び出そうとするバージョンの even
は受け入れません。
def evenLoops (n : Nat) : Bool :=
match n with
| Nat.zero => true
| Nat.succ k => not (evenLoops n)
エラーメッセージには、再帰関数が常に基本ケースに到達するかどうかを(実際到達しないが故に) Lean が判断できなかったと書かれています。
fail to show termination for
evenLoops
with errors
structural recursion cannot be used
well-founded recursion cannot be used, 'evenLoops' does not take any (non-fixed) arguments
加算には2つの引数が必要ですが、パターンマッチする必要があるのはそのうちの1つだけです。数 \(n\) に0を足した和は、\(n\) そのものです。\(k\) の後続数を \(n\) に加えた和は、\(k\) を \(n\) に加えた結果の後続数です。
def plus (n : Nat) (k : Nat) : Nat :=
match k with
| Nat.zero => n
| Nat.succ k' => Nat.succ (plus n k')
plus
の定義では、引数 k
と関連があるが同一ではないことを示すために k'
という名前を選びました。たとえば、plus 3 2
の評価を実行すると、次の手順で行われます。
plus 3 2
===>
plus 3 (Nat.succ (Nat.succ Nat.zero))
===>
match Nat.succ (Nat.succ Nat.zero) with
| Nat.zero => 3
| Nat.succ k' => Nat.succ (plus 3 k')
===>
Nat.succ (plus 3 (Nat.succ Nat.zero))
===>
Nat.succ (match Nat.succ Nat.zero with
| Nat.zero => 3
| Nat.succ k' => Nat.succ (plus 3 k'))
===>
Nat.succ (Nat.succ (plus 3 Nat.zero))
===>
Nat.succ (Nat.succ (match Nat.zero with
| Nat.zero => 3
| Nat.succ k' => Nat.succ (plus 3 k')))
===>
Nat.succ (Nat.succ 3)
===>
5
加算 \(n + k\) は、Nat.succ
を \(n\) に \(k\) 回適用したものだと見なせます。同様に、乗算 \(n × k\) は \(n\) を \(n\) 自身に \(k\) 回加算したものだと見なせますし、減算 \(n - k\) は \(n\) の前者を \(k\) 回取ったものだと見なせます。
def times (n : Nat) (k : Nat) : Nat :=
match k with
| Nat.zero => Nat.zero
| Nat.succ k' => plus n (times n k')
def minus (n : Nat) (k : Nat) : Nat :=
match k with
| Nat.zero => n
| Nat.succ k' => pred (minus n k')
すべての関数が構造的再帰を使用して簡単に記述できるわけではありません。加算は Nat.succ
の反復、乗算は加算の反復、減算は前者関数の反復として理解できるので、除算は減算の反復として実装可能であることが示唆されます。この場合、分子が除数より小さい場合、結果はゼロになります。それ以外の場合、結果は「分子から除数を引いた値を除数で除算したもの」の後者になります。
def div (n : Nat) (k : Nat) : Nat :=
if n < k then
0
else Nat.succ (div (n - k) k)
2番目の引数が 0
でない限り、このプログラムは常に基本ケースに向かって進むため、終了します。しかし、「ゼロに対する返り値を記述し、より小さい Nat
での結果をその後続の結果に変換するパターン」に従っていないため、構造的再帰ではありません。特に、関数の再帰呼び出しは、入力コンストラクタの引数ではなく、別の関数呼び出し(今回であれば引き算)の結果に適用されます。したがって、Lean は次のメッセージを表示してこの関数を拒否します。
fail to show termination for
div
with errors
argument #1 was not used for structural recursion
failed to eliminate recursive application
div (n - k) k
argument #2 was not used for structural recursion
failed to eliminate recursive application
div (n - k) k
structural recursion cannot be used
failed to prove termination, use `termination_by` to specify a well-founded relation
このメッセージは、div
の再帰が停止することを手動で証明する必要があると言っています。このトピックについては、最終章で説明します。
多相性
ほとんどの言語と同じように、Leanの型も引数を取ることができます。例えば、List Nat
型は自然数のリストを意味し、List String
型は文字列のリストを、List (List Point)
型は点のリストのリストを意味します。これはC#やJavaのような言語における型の書き方である List<Nat>
、List<String>
、List<List<Point>>
に非常に似ています。Leanが関数に引数を渡すときにスペースを使うように、型に引数を渡すときにもスペースを使います。
関数型プログラミングでは、多相性 (polymorphism)という用語は通常、引数に型をとるデータ型や定義を指します。これはスーパークラスのふるまいをオーバーライドするサブクラスのことを多相性とするオブジェクト指向プログラミングのコミュニティとは異なる点です。本書では、「多相性」は常に最初の意味を指します。これらの型引数はデータ型や定義で使用することができ、引数の名前をほかの型に置き換えることで、同じデータ型や定義を任意の型で使用できるようになります。
Point
構造体は x
と y
フィールドの両方が Float
型である必要があります。しかし、点は各座標の表現に特化している必要はありません。Point
の多相バージョン PPoint
は、型を引数として受け取り、その型を両方のフィールドに使用することができます:
structure PPoint (α : Type) where
x : α
y : α
deriving Repr
関数定義の引数が定義された関数名の直後に書かれるように、構造体の引数も構造体名の直後に書かれます。引数自体から示唆される具体的な名前がない場合には、Leanの型引数名にはギリシャ文字を使うのが通例です。型 Type
はほかの型を記述する型であるため、Nat
や List String
、Point Int
はすべて Type
型を持ちます。
List
型のように、PPoint
も引数に特定の型を指定することで使用できます:
def natOrigin : PPoint Nat :=
{ x := Nat.zero, y := Nat.zero }
この例では、両方のフィールドが Nat
であることが期待されます。関数が引数の変数を引数の値に置き換えて呼び出されるのと同じように、PPoint
に Nat
型を引数として与えると、引数名 α
が引数の型 Nat
に置き換えられることでフィールド x
と y
が Nat
型を持つ構造体が生成されます。型はLeanでは普通の式であるため、( PPoint
型のような)多相型に引数を渡すときに特別な構文は必要ありません。
定義は引数として型を取ることもでき、それによって多相なものになります。replaceX
は PPoint
の x
フィールドを新しい値に置き換える関数です。replaceX
が 任意の 多相な点で動作するようにするには、replaceX
自身が多相でなければなりません。これは、最初の引数をポイントのフィールドの型とし、それ以降の引数は最初の引数の名前を参照することで実現されます。
def replaceX (α : Type) (point : PPoint α) (newX : α) : PPoint α :=
{ point with x := newX }
言い換えると、引数 point
と newX
の型が α
を参照している場合、それらは 最初の引数として提供されたいずれかの型 を参照していることになります。これは、関数の引数名が関数内に現れたときに、提供された値を参照する方法と似ています。
この事実は replaceX
の型をチェックし、次に replaceX Nat
の型をチェックすることで確認できます。
#check (replaceX)
replaceX : (α : Type) → PPoint α → α → PPoint α
この関数型には最初の引数の 名前 が含まれ、その後に続く引数ではこの名前を参照します。関数適用の値が関数本体の引数名を提供された引数の値に置き換えることで導出されるのと同じように、関数適用の型は、引数名を関数の戻り値の型で与えられる値に置き換えることで導かれます。最初の引数に Nat
を指定すると、残りの型に含まれるすべての α
が Nat
に置き換えられます:
#check replaceX Nat
replaceX Nat : PPoint Nat → Nat → PPoint Nat
α
以降の残りの引数には明示的に名前が付けられていないため、引数が増えてもそれ以上の置換は起きません:
#check replaceX Nat natOrigin
replaceX Nat natOrigin : Nat → PPoint Nat
#check replaceX Nat natOrigin 5
replaceX Nat natOrigin 5 : PPoint Nat
関数適用の式全体の型が引数に型を渡すことによって決定されるという事実は、その式を評価する機能には関係がありません。
#eval replaceX Nat natOrigin 5
{ x := 5, y := 0 }
多相関数は名前付きの型引数を取り、その後の引数の型が当該の引数の名前を参照することで機能します。しかし、型引数について名前を付けられたことによる特別なことは何もありません。例えば正負の符号を表すデータ型が与えられたとします:
inductive Sign where
| pos
| neg
ここで引数が符号である関数を書くことができます。もし引数が正なら、この関数は Nat
を返し、負なら Int
を返します:
def posOrNegThree (s : Sign) : match s with | Sign.pos => Nat | Sign.neg => Int :=
match s with
| Sign.pos => (3 : Nat)
| Sign.neg => (-3 : Int)
型は第一級であり、Leanの言語として通常のルールで計算することができるため、データ型に対するパターンマッチで計算することができます。Leanがこの関数をチェックするとき、関数本体の match
式がデータ型の match
式に対応することを利用して、pos
の場合は Nat
を、neg
の場合は Int
を期待される型にします。
posOrNegThree
を Sign.pos
に適用すると、関数本体と戻り値の型の両方の引数名 s
が Sign.pos
に置き換えられます。評価は式と型の両方で行われます:
(posOrNegThree Sign.pos : match Sign.pos with | Sign.pos => Nat | Sign.neg => Int)
===>
((match Sign.pos with
| Sign.pos => (3 : Nat)
| Sign.neg => (-3 : Int)) :
match Sign.pos with | Sign.pos => Nat | Sign.neg => Int)
===>
((3 : Nat) : Nat)
===>
3
連結リスト
Leanの標準ライブラリには、List
と呼ばれる標準的な連結リストのデータ型と、それをより便利に使うための特別な構文が含まれています。リストを記述するには角括弧を使います。例えば10未満の素数が格納されたリストは次のように書くことができます:
def primesUnder10 : List Nat := [2, 3, 5, 7]
この裏側では、List
は帰納的データ型として次のような感じで定義されています:
inductive List (α : Type) where
| nil : List α
| cons : α → List α → List α
標準ライブラリでの実際の定義は、まだ発表されていない機能を使用しているため若干異なりますが、実質的には似たものになっています。この定義によると、List
は PPoint
と同様に引数として型を1つ取ります。この型はリストに格納される要素の型です。コンストラクタによれば、List α
は nil
または cons
のどちらかで構築することができます。コンストラクタ nil
は空のリストを、コンストラクタ cons
は空でないリストを表します。cons
の第1引数はリストの先頭で、第2引数はその後ろに連なるリストです。 \( n \) 個の要素を含むリストには \( n \) 個の cons
コンストラクタが含まれ、最後の cons
コンストラクタの後ろには nil
が続きます。
primesUnder10
の例は、List
のコンストラクタを直接使うことでより明示的に書くことができます。
def explicitPrimesUnder10 : List Nat :=
List.cons 2 (List.cons 3 (List.cons 5 (List.cons 7 List.nil)))
これら2つの定義は完全に等価ですが、explicitPrimesUnder10
よりも primesUnder10
の方がはるかに読みやすいでしょう。
List
を受け付けるような関数は Nat
を取る関数と同じように定義することができます。実際、連結リストは、各 succ
コンストラクタに余分なデータフィールドがぶら下がったような Nat
と考えることもできます。この観点からすると、リストの長さを計算することは、各 cons
を succ
に置き換え、最後の nil
を zero
に置き換える処理です。replaceX
が点のフィールドの型を引数に取ったように、length
はリストの要素の型を引数に取ります。例えば、リストに文字列が含まれている場合、最初の引数は String
:length String ["Sourdough", "bread"]
です。これは次のように計算されます:
length String ["Sourdough", "bread"]
===>
length String (List.cons "Sourdough" (List.cons "bread" List.nil))
===>
Nat.succ (length String (List.cons "bread" List.nil))
===>
Nat.succ (Nat.succ (length String List.nil))
===>
Nat.succ (Nat.succ Nat.zero)
===>
2
length
の定義は多相的であり(リストの要素の型を引数にとるため)、また再帰的です(自分自身を参照するため)。一般的に、関数はデータの形に従います:再帰的なデータ型は再帰的な関数を導き、多相的なデータ型は多相的な関数を導きます。
def length (α : Type) (xs : List α) : Nat :=
match xs with
| List.nil => Nat.zero
| List.cons y ys => Nat.succ (length α ys)
xs
や ys
といった名前は、未知の値のリストを表すために慣例的に使用されます。名前の中の s
は複数形であることを示すため、「x s(エックスス)」や「y s(ワイス)」ではなく、「エクセズ(exes)」や「ワイズ(whys)」と発音されます。
リスト上の関数を読みやすくするために、[]
という括弧記法を使って nil
に対してパターンマッチを行うことができ、cons
の代わりに ::
という中置記法を使うことができます。
def length (α : Type) (xs : List α) : Nat :=
match xs with
| [] => 0
| y :: ys => Nat.succ (length α ys)
暗黙の引数
replaceX
と length
はどちらも使うにはややお役所的です、というのも型引数は一般的にその後の引数の値で一意に決まるからです。実際、たいていの言語では、コンパイラが型引数を自分で決定する完璧な能力を備えており、ユーザの助けが必要になることはまれです。これはLeanでも同様です。関数を定義するときに、丸括弧の代わりに波括弧で囲むことで引数を 暗黙的に (implicit)宣言することができます。例えば、暗黙の型引数を持つバージョンの replaceX
は次のようになります:
def replaceX {α : Type} (point : PPoint α) (newX : α) : PPoint α :=
{ point with x := newX }
この関数は natOrigin
に用いる際に Nat
を明示的に渡すことなく使えます、なぜならLeanは後の引数から α
の値を 推測 することができるからです:
#eval replaceX natOrigin 5
{ x := 5, y := 0 }
同様に、length
も暗黙的に要素の型を取るように再定義できます:
def length {α : Type} (xs : List α) : Nat :=
match xs with
| [] => 0
| y :: ys => Nat.succ (length ys)
この length
関数は primesUnder10
に直接適用できます:
#eval length primesUnder10
4
標準ライブラリにて、この関数は List.length
と呼ばれています。つまり、構造体フィールドへのアクセスに使われるドット構文がリストの長さを求めるのにも使えるということです:
#eval primesUnder10.length
4
C#やJavaなどにおいて時折、型引数を明示的に提供することを要求するように、Leanが暗黙の引数がなんであるか常にわかるとは限りません。このような場合、引数をその名前を使って指定することができます。例えば、List.length
を整数のリストに対してのみ動作するようにするには、α
に Int
を設定します:
#check List.length (α := Int)
List.length : List Int → Nat
その他の組み込みのデータ型
リストに加えて、Leanの標準ライブラリには、様々なコンテキストで使用できる構造や帰納的データ型が数多く含まれています。
Option
すべてのリストに先頭の要素があるとは限りません。空のリストも存在します。データのコレクションに対する操作においては探しているものが見つけられないことが多々あります。例えば、リストの先頭の要素を見つける関数は、該当する要素を見つけられないかもしれません。そのため、先頭の要素がないことを知らせる方法が必要です。
多くの言語には、値がないことを表す null
という値があります。既存の型に特別な null
値を持たせる代わりに、Leanでは Option
と呼ばれる何かしらの型に値がないことを示すものを合わせた型が提供されています。例えば、nullを許容する Int
は Option Int
で、nullを許容する文字列のリストは Option (List String)
型で表現されます。nullの許容を表すために新しい型を導入するということは、型システムが null
のチェックを忘れえないことを意味します。なぜなら Option Int
は Int
が期待されるコンテキストで使うことができないからです。
Option
には some
と none
という2つのコンストラクタがあり、それぞれベースとなる型の非null版とnull版を表します。非null版のコンストラクタ some
にはベースとなる値が格納され、none
には引数は渡されません:
inductive Option (α : Type) : Type where
| none : Option α
| some (val : α) : Option α
Option
型はC#やKotlinなどの言語のnullable型に非常に似ていますが、同じではありません。これらの言語では、ある型(例えば Boolean
)が常にその型の実際の値( true
と false
)を参照する場合、Boolean?
型または Nullable<Boolean>
型は null
値を元の型に追加する形で許容します。それらの型システムにおいてこの型をなぞるのは非常に有用です: 型チェッカやそのほかのツールはプログラマがnullのチェックを忘れないようにするのに役立ちますし、型シグネチャで明示的にnull許容性を記述しているAPIはそうでないAPIよりも有益です。しかし、これらのnull許容な型はLeanの Option
とは非常に重要な点で異なります。Option (Option Int)
は none
か some none
、some (some 360)
で構築できます。一方でC#はnull許容ではない型に対して ?
を1つだけつけることを許容しており、複数層のnull許容型を禁じています。またKotlinでは T??
は T?
と同じものとして取り扱われます。この微妙な違いは実際にはほとんど関係ありませんが、時折問題になることがあります。
リストの先頭の要素を探すには、もし存在するなら List.head?
を使用します。はてなマークは名前の一部であり、C#やKotlinでnull可能な型を示すためにはてなマークを使用することとは無関係です。List.head?
の定義では、リストの後続を表すためにアンダースコアが使われています。この場合、アンダースコアはあらゆるものにマッチしますが、マッチしたデータを参照する変数を導入することはできません。名前の代わりにアンダースコアを使うことで、読者に入力の一部が無視されることを明確に伝えることができます。
def List.head? {α : Type} (xs : List α) : Option α :=
match xs with
| [] => none
| y :: _ => some y
Leanの命名規則として失敗する可能性のある操作について、Option
を返すものには ?
を、無効な入力が渡されたらクラッシュするものには !
を、失敗するときにデフォルト値を返すものには D
を接尾辞としてそれぞれ定義します。例えば、head
は呼び出し元に対して、渡されたリストが空でないという数学的な根拠の提示を要求します。head?
は Option
を返し、head!
は空のリストを渡されたときにプログラムをクラッシュさせ、headD
はリストが空の場合に返すデフォルト値を取ります。はてなマークとビックリマークは名前の一部であり、特別な構文ではありません。このようにLeanの命名規則は多くの言語に比べて自由です。
head?
は List
名前空間で定義されているため、アクセサ記法を使うことができます:
#eval primesUnder10.head?
some 2
しかし、これを空リストで試そうとすると、以下の2つのエラーを出します:
#eval [].head?
don't know how to synthesize implicit argument
@List.nil ?m.20264
context:
⊢ Type ?u.20261
don't know how to synthesize implicit argument
@_root_.List.head? ?m.20264 []
context:
⊢ Type ?u.20261
これはLeanが式の型を完全に決定できなかったためです。特に、List.head?
の暗黙の型引数だけでなく、List.nil
の暗黙の型引数も見つけることができていません。Leanの出力での ?m.XYZ
は推論できなかったプログラムの一部を表しています。これらの未知の部分は メタ変数 (metavariables)と呼ばれ、エラーメッセージに時折現れます。式を評価するために、Leanはその型を見つけられる必要がありますが、空リストは1つも要素を持たないことから型が見つからないため、上記の式の型を得ることができません。型を明示的に指定することで、Leanは処理を進めることができます:
#eval [].head? (α := Int)
none
この型は型注釈で与えることも可能です:
#eval ([] : List Int).head?
none
エラーメッセージは有用な手がかりを与えてくれます。どちらのメッセージも、足りない暗黙の引数を記述するために、 同じ メタ変数を使用しています。これはLeanが解の実際の値を決定できなかったにもかかわらず、2つの足りない部分が解を共有するだろうと判断したことを意味します。
Prod
Prod
構造体は「Product」の略で、2つの値を結合する一般的な方法です。例えば、Prod Nat String
は Nat
と String
を含みます。つまり、PPoint Nat
は Prod Nat Nat
に置き換えることができます。Prod
はC#のタプル、Kotlinの Pair
型と Triple
型、C++の tuple
によく似ています。実用に際しては、Point
のような単純な場合であってももっぱら独自の構造体を定義することが最善です。というのもこのような固有の用語によってコードが読みやすくなるからです。さらに、構造体型を定義することで、異なるドメイン概念に異なる型を割り当て、それらが混在することを防ぐことでより多くのエラーを検出できます。
一方、新しい型を定義するオーバーヘッドに見合わないケースもあります。加えて、いくつかのライブラリは「ペア」以上の具体的な概念がないほど十分汎用的です。そして最後に、標準ライブラリには様々な便利関数が含まれており、組み込みのペア型をより簡単に扱うことができます。
標準的なペアの構造体は Prod
と呼ばれます。
structure Prod (α : Type) (β : Type) : Type where
fst : α
snd : β
リストは頻繁に使われるため、読みやすくなる特別な構文があります。同じ理由で、積の型とコンストラクタも特別な構文を持っています。Prod α β
型は通常 α × β
と表記されます。これは集合のデカルト積の通常の表記を反映したものです。同様に、ペアを表す通常の数学的記法が Prod
でも利用できます。つまり、以下のように書く代わりに:
def fives : String × Int := { fst := "five", snd := 5 }
次のように書けます:
def fives : String × Int := ("five", 5)
どちらの記法も右結合です。つまり、以下の定義は等価です:
def sevens : String × Int × Nat := ("VII", 7, 4 + 3)
def sevens : String × (Int × Nat) := ("VII", (7, 4 + 3))
言い換えれば、2つ以上の型を持つすべての積とそれに対応するコンストラクタは、実際には裏でネストされた積とネストされたペアなのです。
Sum
Sum
データ型は、2つの異なる型の値を選択できるようにする汎用的な方法です。例えば、Sum String Int
は String
か Int
のどちらかです。Prod
と同様に、Sum
は非常に汎用的なコードを書く時や、ドメイン固有の型が無いような非常に小さなコードで使用するか、標準ライブラリに便利な関数があるときに使用します。たいていの場合、カスタムの帰納型を使用する方が読みやすく、保守性も高くなります。
Sum α β
型の値は、コンストラクタ inl
を α
型の値に適用したものか、コンストラクタ inr
を β
型の値に適用したかのどちらかです:
inductive Sum (α : Type) (β : Type) : Type where
| inl : α → Sum α β
| inr : β → Sum α β
これらの名前はそれぞれ「左埋め込み(left injection)」と「右埋め込み(right injection)」の略です。Prod
にデカルト積の記法が使われるように、Sum
には「丸で囲んだプラス」記法が使われ、Sum α β
は α ⊕ β
とも書き表されます。Sum.inl
と Sum.inr
には特別な構文はありません。
例えば、ペットの名前に犬の名前と猫の名前のどちらもあり得る場合、それらを表す型を文字列の和として導入することができます。
def PetName : Type := String ⊕ String
実際のプログラムでは通常、このような目的のためには情報量の多いコンストラクタ名でカスタムの帰納的データ型を定義する方がよいでしょう。ここでは犬の名前には Sum.inl
を、猫の名前には Sum.inr
を使用します。これらのコンストラクタを使用して、動物の名前のリストを書くことができます:
def animals : List PetName :=
[Sum.inl "Spot", Sum.inr "Tiger", Sum.inl "Fifi", Sum.inl "Rex", Sum.inr "Floof"]
2つのコンストラクタを区別するために、パターンマッチを使うことができます。例えば、動物の名前のリストに含まれる犬の数(つまり、Sum.inl
コンストラクタの数)を数える関数は次のようになります:
def howManyDogs (pets : List PetName) : Nat :=
match pets with
| [] => 0
| Sum.inl _ :: morePets => howManyDogs morePets + 1
| Sum.inr _ :: morePets => howManyDogs morePets
関数呼び出しは中置演算子より前に評価されるため、howManyDogs morePets + 1
は (howManyDogs morePets) + 1
と同じです。予想通り、#eval howManyDogs animals
は 3
を返します。
Unit
Unit
は unit
と呼ばれる引数のないコンストラクタを1つだけもつ型です。つまり、引数のないコンストラクタを適用した単一の値のみを記述します。Unit
は以下のように定義されます:
inductive Unit : Type where
| unit : Unit
単体では Unit
は特に役に立ちません。しかし、多相なプログラムでは、足りないデータのプレースホルダとして使用することができます。例えば、以下の帰納的データ型は算術式を表します:
inductive ArithExpr (ann : Type) : Type where
| int : ann → Int → ArithExpr ann
| plus : ann → ArithExpr ann → ArithExpr ann → ArithExpr ann
| minus : ann → ArithExpr ann → ArithExpr ann → ArithExpr ann
| times : ann → ArithExpr ann → ArithExpr ann → ArithExpr ann
型引数の ann
は注釈を表し、各コンストラクタは注釈されています。パースされた結果の式はソース中の位置を注釈しているかもしれないため、ArithExpr SourcePos
の戻り値の型はパーサがそれぞれの部分式に SourcePos
を置くことを保証します。しかし、パーサから来ない式はソース中の位置を持たないため、その型は ArithExpr Unit
となります。
さらに、すべてのLeanでの関数は引数を持つため、ほかの言語での引数が無い関数は、Leanでは Unit
引数を取る関数として表すことができます。戻り値の観点では、Unit
型はC言語から派生した言語での void
型に似ています。C言語ファミリーでは、void
を返す関数は呼び出し元に制御を返すが、興味深い値を返すことはありません。Unit
は意図的に興味のない値であることで、型システムの中に特別な目的の void
機能を必要とすることなく、これを表現することができます。Unit
のコンストラクタは空の括弧で書くことができます:() : Unit
Empty
Empty
データ型はコンストラクタを一切持ちません。よってこれは到達不可能なコードを意味します。なぜなら、どんな関数呼び出しの列も Empty
型の値で終了することは無いからです。
Empty
は Unit
ほど頻繁には使われません。しかし、特殊なコンテキストでは役に立ちます。多くの多相データ型では、すべてのコンストラクタですべての型引数を使用するわけではありません。たとえば、Sum.inl
と Sum.inr
はそれぞれ Sum
の型引数を1つしか使用しません。Empty
を Sum
の型引数の1つとして使用することで、プログラムの特定の時点でコンストラクタの1つを除外することができます。これにより、追加の制限があるコンテキストでジェネリックなコードを使用することができます。
名前について: 和(Sum)、積(Product)、単位(Unit)
一般的に、複数のコンストラクタを持つ型は 直和型 (sum type)と呼ばれ、単一のコンストラクタが複数の引数を取る型は 直積型 (product type)と呼ばれます。これらの用語は、通常の算術で使われる和と積に関連しています。この関係は、関係する型が有限個の値を含む場合に最もわかりやすいでしょう。α
と β
がそれぞれ \( n \) 個と \( k \) 個の異なる値を含む型だとすると、α ⊕ β
は \( n + k \) 個の異なる値を含み、α × β
は \( n \times k \) 個の異なる値を含みます。たとえば Bool
は true
と false
の2つの値を持ち、Unit
には Unit.unit
という1つの値があります。積 Bool × Unit
は2つの値 (true, Unit.unit)
と (false, Unit.unit)
を持ち、和 Bool ⊕ Unit
は3つの値 Sum.inl true
と Sum.inl false
、Sum.inr unit
を持ちます。これと同様に、 \( 2 \times 1 = 2 \) と \( 2 + 1 = 3 \) となります。
見るかもしれないメッセージ
すべての定義可能な構造体や帰納型が Type
型を持つわけではありません。特に、コンストラクタが引数として任意の型を取る場合、帰納型は異なる型を持たなければなりません。これらのエラーは通常、「宇宙レベル」について述べています。例えば、この帰納型について:
inductive MyType : Type where
| ctor : (α : Type) → α → MyType
Leanは以下のエラーを出します:
invalid universe level in constructor 'MyType.ctor', parameter 'α' has type
Type
at universe level
2
it must be smaller than or equal to the inductive datatype universe level
1
後の章では、なぜそうなるか、どのように定義を修正すればうまくいくのかを説明します。今の時点では、型をコンストラクタの引数ではなく、帰納型全体の引数にしてみてください。
同様に、コンストラクタの引数が定義されているデータ型を引数とする関数である場合、その定義は却下されます。例えば以下の定義について:
inductive MyType : Type where
| ctor : (MyType → Int) → MyType
このようなメッセージが出力されます:
(kernel) arg #1 of 'MyType.ctor' has a non positive occurrence of the datatypes being declared
技術的な理由から、このようなデータ型を許可すると、Leanの内部論理が損なわれる可能性があり、定理証明として使用するのに適さなくなります。
帰納的な型の引数を忘れると、混乱を招くメッセージになることもあります。例えば、ctor
の型の MyType
に引数 α
が渡されていない場合です:
inductive MyType (α : Type) : Type where
| ctor : α → MyType
Leanはこれに対して以下のエラーを返します:
type expected, got
(MyType : Type → Type)
このエラーメッセージは MyType
の型は Type → Type
であり、型そのものを記述していないと言っています。MyType
が実際の正真正銘の型になるためには、引数を必要とします。
定義の型シグネチャなど、ほかのコンテキストで型引数が省略された場合にも、同じメッセージが表示されることがあります:
inductive MyType (α : Type) : Type where
| ctor : α → MyType α
def ofFive : MyType := ctor 5
演習問題
- リストの最後の要素を探す関数を書いてください。これは
Option
を返すべきです。
- リストの中で与えられた述語を満たす最初の要素を探す関数を書いてください。定義は
def List.findFirst? {α : Type} (xs : List α) (predicate : α → Bool) : Option α :=
から始めてください。
- ペアの2つのフィールドを入れ替える関数
Prod.swap
を書いてください。定義はdef Prod.swap {α β : Type} (pair : α × β) : β × α :=
から始めてください。
- カスタムのデータ型を使って
PetName
の例を書き換えて、Sum
のバージョンを比較してください。
- 2つのリストのペアを紐づける関数
zip
を書いてください。出力されるリストは入力のリストのうち短い方と同じ長さにしてください。定義はdef zip {α β : Type} (xs : List α) (ys : List β) : List (α × β) :=
から始めてください。
- リストの先頭から \( n \) 個の要素を返す多相関数
take
を書いてください。ここで \( n \) はNat
です。もしリストの要素がn
個未満の場合、出力のリストは入力のリストにしてください。また#eval take 3 ["bolete", "oyster"]
は["bolete", "oyster"]
を出力し、#eval take 1 ["bolete", "oyster"]
は["bolete"]
を出力させてください。
- 型と算術の間の類似を使って、直積を直和に分配する関数を書いてください。言い換えると、この型は
α × (β ⊕ γ) → (α × β) ⊕ (α × γ)
になります。
- 型と算術の間の類似を使って、2倍が和になる関数を書いてください。言い換えると、この方は
Bool × α → α ⊕ α
になります。
その他の便利な機能
Leanにはプログラムをより簡潔にする便利な機能がたくさんあります。
自動的な暗黙引数
Leanで多相関数を書く場合、基本的にすべての暗黙引数を列挙する必要はありません。その代わりに、単にそれらを参照するだけでよいのです。Leanが引数に現れなかった変数の型を決定できる場合、それらは自動的に暗黙の引数として挿入されます。例えば、先ほどの length
の定義について:
def length {α : Type} (xs : List α) : Nat :=
match xs with
| [] => 0
| y :: ys => Nat.succ (length ys)
{α : Type}
を書かずに定義できます:
def length (xs : List α) : Nat :=
match xs with
| [] => 0
| y :: ys => Nat.succ (length ys)
これにより、多くの暗黙引数をとるような高度に多相的な定義を大幅に簡略化することができます。
パターンマッチによる定義
def
で関数を定義するとき、引数に名前をつけてもすぐにパターンマッチに適用してしまうケースはよくあります。例えば、length
では引数 xs
は match
でのみ使用されます。このような状況では、引数に名前を付けずに match
式のケースを直接書くことができます。
最初のステップは、引数の型をコロンの右側に移動させることです。例えば length
の型は List α → Nat
となります。次に、:=
をパターンマッチの各ケースで置き換えます:
def length : List α → Nat
| [] => 0
| y :: ys => Nat.succ (length ys)
この構文は複数の引数を取る関数を定義するのにも使えます。この場合、パターンはカンマで区切られます。例えば、drop
は整数値 \( n \) とリストを受け取り、先頭から \( n \) 個の要素を取り除いたリストを返します。
def drop : Nat → List α → List α
| Nat.zero, xs => xs
| _, [] => []
| Nat.succ n, x :: xs => drop n xs
名前の付いた引数とパターンを同じ定義で使用することもできます。例えば、デフォルト値とオプション値を受け取り、オプション値が none
の場合はデフォルト値を返す関数を書くことができます:
def fromOption (default : α) : Option α → α
| none => default
| some x => x
この関数は標準ライブラリに Option.getD
という名前で定義されており、ドット記法で呼び出すことができます:
#eval (some "salmonberry").getD ""
"salmonberry"
#eval none.getD ""
""
ローカル定義
計算の途中のステップに名前を付けると便利なことが多いものです。多くの場合、こうした中間値はそれだけで有用な概念を表しており、明示的に名前をつけることでプログラムを読みやすくすることができます。また中間値が複数回使われる場合もあります。ほかの多くの言語と同じように、Leanにて同じコードを2回書くと2回計算されることになる一方で、結果を変数に保存すると計算結果が保存されて再利用されることになります。
例えば、unzip
はペアからなるリストをリストのペアに変換する関数です。ペアのリストが空の場合、unzip
の結果は空のリストのペアになります。ペアのリストの先頭にペアがある場合、そのペアの2つのフィールドが残りのリストを unzip
した結果に追加されます。この unzip
の定義を完全に起こすと以下のようになります:
def unzip : List (α × β) → List α × List β
| [] => ([], [])
| (x, y) :: xys =>
(x :: (unzip xys).fst, y :: (unzip xys).snd)
残念ながら、このコードには問題があります: これは必要以上に遅いのです。ペアのリストの各要素が2回の再帰呼び出しを行うため、この関数には指数関数的な時間がかかります。しかし、どちらの再帰呼び出しも結果は同じであるため、再帰呼び出しを2回行う必要はありません。
Leanでは再帰呼び出しの結果を let
を使って名前を付け、保存することができます。let
によるローカル定義は def
によるトップレベル定義と似ています:この構文ではローカル定義する名前、必要であれば引数、型シグネチャ、そして :=
に続く本体を取ります。ローカル定義の後、この定義が使用可能な式( let
式の 本体 (body)と呼ばれます)は次の行からかつ let
キーワードが定義された列と同じかそれよりも後ろから始まる必要があります。例えば let
は unzip
で次のように使用することができます:
def unzip : List (α × β) → List α × List β
| [] => ([], [])
| (x, y) :: xys =>
let unzipped : List α × List β := unzip xys
(x :: unzipped.fst, y :: unzipped.snd)
let
を1行で使用するには、ローカル定義と本体をセミコロンで区切ります。
let
を使用したローカル定義では、1つのパターンでデータ型のすべてのケースにマッチする場合であればパターンマッチを使うこともできます。unzip
の場合、再帰呼び出しの結果はペアになります。ペアは単一のコンストラクタしか持たないので、unzipped
という名前をペアのパターンに置き換えることができます:
def unzip : List (α × β) → List α × List β
| [] => ([], [])
| (x, y) :: xys =>
let (xs, ys) : List α × List β := unzip xys
(x :: xs, y :: ys)
let
を使ったパターンをうまく使えば、手作業でアクセサの呼び出しを書くよりもコードを読みやすくすることができます。
let
と def
の最大の違いは再帰的な let
定義は let rec
と書いて明示的に示さなければならない点です。例えば、リストを反転させる方法の1つに、以下の定義のような再帰的な補助関数を用いるものがあります:
def reverse (xs : List α) : List α :=
let rec helper : List α → List α → List α
| [], soFar => soFar
| y :: ys, soFar => helper ys (y :: soFar)
helper xs []
補助関数は入力リストを下向きに進み、そのたびに1つの要素を soFar
に移動していきます。入力リストの最後に到達すると、soFar
には入力されたリストの逆順が格納されます。
型推論
多くの場合、Leanは式の型を自動的に決定することができます。このような場合、( def
による)トップレベルの定義と( let
による)ローカル定義のどちらでも明示的な型の省略ができます。例えば、再帰的に呼び出される unzip
には注釈は不要です:
def unzip : List (α × β) → List α × List β
| [] => ([], [])
| (x, y) :: xys =>
let unzipped := unzip xys
(x :: unzipped.fst, y :: unzipped.snd)
経験則として、(文字列や数値のような)リテラル値の型を省略することは通常うまくいきますが、Leanはリテラルの数値に対して意図した型よりも特殊な型を選ぶことがあります。Leanは関数適用の型を決定することができます、というのもすでに引数の型と戻り値の型を知っているからです。関数の定義で戻り値の型を省略することはよくありますが、引数には通常注釈が必要です。先ほどの unzipped
のような関数ではない定義においては、その本体が型注釈を必要とせず、かつ定義の本体が関数適用である場合は型注釈を必要としません。
明示的な match
式を使用する場合、unzip
の戻り値の型を省略することができます:
def unzip (pairs : List (α × β)) :=
match pairs with
| [] => ([], [])
| (x, y) :: xys =>
let unzipped := unzip xys
(x :: unzipped.fst, y :: unzipped.snd)
一般論として、型注釈は少なすぎるよりは多すぎる方が良いです。まず、明示的な型は読み手にコードの前提を伝えます。たとえLeanによって型が決定できるものでも、Leanに型情報をいちいち問い合わせることなくコードを読むことができます。第二に、明示的な型はエラーの特定に役立ちます。プログラムが型について明示的であればあるほど、エラーメッセージはより有益なものになります。これはLeanに限らず非常に表現力豊かな型システムを持つ言語では特に重要です。第三に、型が明示されていることによって、そもそもプログラムを書くのが簡単になります。型は仕様であり、コンパイラのフィードバックは仕様を満たすプログラムを書くのに役立つツールになります。最後に、Leanの型推論はベストエフォートなシステムです。Leanの型システムは非常に表現力が豊かであるため、すべての式に対して「最良」な型や最も一般的な型を見つけることができません。つまり、型が得られたとしても、それが与えられたアプリケーションにとって「正しい」型であるという保証はないということです。例えば、14
は Nat
にも Int
にもなり得ます:
#check 14
14 : Nat
#check (14 : Int)
14 : Int
型注釈が欠落すると、エラーメッセージがわかりづらくなります。unzip
の定義からすべての型を省略すると:
def unzip pairs :=
match pairs with
| [] => ([], [])
| (x, y) :: xys =>
let unzipped := unzip xys
(x :: unzipped.fst, y :: unzipped.snd)
match
式について以下のようなメッセージが出力されます:
invalid match-expression, pattern contains metavariables
[]
これは match
が受け取った検査対象の値の型を知る必要がありますが、その型が利用できなかったためです。「metavariable(メタ変数)」とはプログラムの未知の部分のことで、エラーメッセージでは ?m.XYZ
と書かれます。これについては 「多相性」節 で解説しています。このプログラムでは、引数の型注釈が必要です。
非常に単純なプログラムであっても、型注釈を必要とするものがあります。例えば、恒等関数は渡された引数をそのまま返すだけの関数です。引数と型注釈を使うと次のようになります:
def id (x : α) : α := x
Leanはこの戻り値の型を自力で決定できます:
def id (x : α) := x
ここで引数の型を削除すると、以下のエラーを引き起こします:
def id x := x
failed to infer binder type
一般的に、「failed to infer(推論に失敗)」のようなメッセージやメタ変数に言及したメッセージは型注釈が足りていないというサインであることが多いです。特にLeanを学習している段階においては、ほとんどの型を明示的に提供することが有用です。
一斉パターンマッチ
パターンマッチング式は、パターンマッチング定義と同様に、一度に複数の値にマッチすることができます。検査する式とマッチするパターンは、定義に使われる構文と同じようにカンマで区切って記述します。以下は一斉パターンマッチを使用するバージョンの drop
です:
def drop (n : Nat) (xs : List α) : List α :=
match n, xs with
| Nat.zero, ys => ys
| _, [] => []
| Nat.succ n , y :: ys => drop n ys
整数のパターンマッチ
「データ型・パターン・再帰」の節において、even
は次のように定義されていました:
def even (n : Nat) : Bool :=
match n with
| Nat.zero => true
| Nat.succ k => not (even k)
リストのパターンマッチを List.cons
や List.nil
を使うよりも読みやすくするための特別な構文があるように、自然数もリテラル数値と +
を使ってマッチさせることができます。例えば、even
は以下のように定義することもできます:
def even : Nat → Bool
| 0 => true
| n + 1 => not (even n)
この記法では、+
パターンの引数は異なる役割を果たします。裏側では、左の引数(上の n
)は何かしらの数値の Nat.succ
パターンの引数になり、右の引数(上の 1
)はパターンにラップする Nat.succ
の数を決定します。さて、次に halve
関数の明示的なパターンでは Nat
を2で割って余りを落とします:
def halve : Nat → Nat
| Nat.zero => 0
| Nat.succ Nat.zero => 0
| Nat.succ (Nat.succ n) => halve n + 1
このパターンマッチは数値リテラルと +
で置き換えることができます:
def halve : Nat → Nat
| 0 => 0
| 1 => 0
| n + 2 => halve n + 1
この裏側では、上記二つの定義はどちらも完全に等価です。ここで覚えておいてほしいこととして、halve n + 1
は (halve n) + 1
と等価であり、halve (n + 1)
とは等価ではありません。
この構文を使う場合、+
の第二引数は常に Nat
のリテラルでなければなりません。また加算は可換であるにもかかわらず、パターン内の引数を反転させると次のようなエラーになることがあります:
def halve : Nat → Nat
| 0 => 0
| 1 => 0
| 2 + n => halve n + 1
invalid patterns, `n` is an explicit pattern variable, but it only occurs in positions that are inaccessible to pattern matching
.(Nat.add 2 n)
この制限により、Leanはパターン内で +
表記を使用する場合はすべて、その基礎となる Nat.succ
表記に変換することができます。
無名関数
Leanの関数はトップレベルで定義する必要はありません。式としての関数は fun
構文で生成されます。関数式はキーワード fun
で始まり、1つ以上の引数が続き、その後に =>
を挟んで返される式が続きます。例えば、ある数値に1を足す関数は次のように書くことができます:
#check fun x => x + 1
fun x => x + 1 : Nat → Nat
型注釈は def
と同じように括弧とコロンを使って記述します:
#check fun (x : Int) => x + 1
fun x => x + 1 : Int → Int
同じように、暗黙引数は波括弧を使って記述します:
#check fun {α : Type} (x : α) => x
fun {α} x => x : {α : Type} → α → α
この無名関数式のスタイルはしばしば ラムダ式 (lambda expression)と呼ばれます、というのもLeanではキーワード fun
に対応するプログラミング言語の数学的な記述で使われる典型的な表記法はギリシャ文字のλ(ラムダ)だからです。Leanでも fun
の代わりに λ
を使うことができますが、fun
と書く方が一般的です。
無名関数は def
で使われる複数パターンのスタイルもサポートしています。例えば、ある自然数に対して、もし存在するならそのひとつ前を返す関数は以下のように書けます:
#check fun
| 0 => none
| n + 1 => some n
fun x =>
match x with
| 0 => none
| Nat.succ n => some n : Nat → Option Nat
この関数に対してLeanから実際に付けられる情報には、名前付き引数と match
式があることに注意してください。Leanの便利な構文の短縮形の多くは、裏ではより単純な構文に拡張されており、抽象化が漏れることもあります。
def
を使った定義で引数を取るものは、関数式に置き換えることができます。例えば、引数を2倍にする関数は以下のように書けます:
def double : Nat → Nat := fun
| 0 => 0
| k + 1 => double k + 2
無名関数が、fun x => x + 1
のように非常に単純な場合、関数を作成する構文はかなり冗長になります。この例では、6つの非空白文字が関数の導入に使用され、本体は3つの非空白文字で構成されています。このような単純なケースのために、Leanは省略記法を用意しています。括弧で囲まれた式の中で、中黒点 ·
が引数を表し、括弧の中の式が関数の本体になります。この関数は (· + 1)
と書くこともできます。
中黒点は常に最も近い括弧から関数を生成します。例えば、(· + 5, 3)
は数字のペアを返す関数で、((· + 5), 3)
は関数と数字のペアです。複数の点が使用された場合、それらは左から右の順に複数の引数になります:
(· , ·) 1 2
===>
(1, ·) 2
===>
(1, 2)
無名関数は def
や let
で定義された関数とまったく同じように適用することができます。コマンド #eval (fun x => x + x) 5
の結果は以下のようになります:
10
一方で #eval (· * 2) 5
の結果は以下のようになります:
10
名前空間
Leanの各名前は名前の集まりである 名前空間 (namespace)の中に存在します。名前は .
を使って名前空間に配置されるので、List.map
は List
名前空間の map
という名前になります。名前空間が異なる名前同士は、たとえ同じ名前であっても衝突することはありません。つまり、List.map
と Array.map
は異なる名前です。名前空間は入れ子にすることができるので、Project.Frontend.User.loginTime
は入れ子の名前空間 Project.Frontend.User
内の loginTime
という名前になります。
名前は名前空間内で直接定義することができます。例えば、double
という名前は Nat
名前空間で定義することができます:
def Nat.double (x : Nat) : Nat := x + x
Nat
は型の名前でもあるので、ドット記法を使えば Nat
型の式で Nat.double
を呼び出すことができる:
#eval (4 : Nat).double
8
名前空間内で直接名前を定義するだけでなく、namespace
コマンドと end
コマンドを使用して一連の宣言を名前空間内に配置することができます。例えば、以下では名前空間 NewNamespace
に triple
と quadruple
を定義しています:
namespace NewNamespace
def triple (x : Nat) : Nat := 3 * x
def quadruple (x : Nat) : Nat := 2 * x + 2 * x
end NewNamespace
これらを参照するには、それらの名前に接頭辞 NewNamespace.
を付けます:
#check NewNamespace.triple
NewNamespace.triple (x : Nat) : Nat
#check NewNamespace.quadruple
NewNamespace.quadruple (x : Nat) : Nat
名前空間は 開く (open)ことができます。これによって名前空間内の名前を明示的に指定することなく使うことができるようになります。式の前に open MyNamespace in
と書くと、その式で MyNamespace
の内容が使用できるようになります。以下の例で、timesTwelve
は NewNamespace
を開いた後に quadruple
と triple
の両方を使用しています:
def timesTwelve (x : Nat) :=
open NewNamespace in
quadruple (triple x)
名前空間はコマンドの前に開くこともできます。これにより単一の式内ではなく、コマンドのすべての部分で名前空間の内容を参照できるようになります。これを行うには、コマンドの前に open ... in
を置きます:
open NewNamespace in
#check quadruple
NewNamespace.quadruple (x : Nat) : Nat
関数のシグネチャは、名前の完全な名前空間を示します。名前空間はさらに、ファイルの残りの部分 すべて のコマンドのために開くこともできます。これを行うには open
のトップレベルの使用法から in
を省略するだけです。
if let
直和型を持つ値を計算する場合、複数のコンストラクタの中のどれか一つだけが注目されることがよくあります。例えば、Markdownのインライン要素のサブセットを表す以下の型を考えます:
inductive Inline : Type where
| lineBreak
| string : String → Inline
| emph : Inline → Inline
| strong : Inline → Inline
ここで文字列要素を認識してその内容を抽出する関数を書くことができます:
def Inline.string? (inline : Inline) : Option String :=
match inline with
| Inline.string s => some s
| _ => none
この関数の本体部分は、if
を let
と一緒に使うことでも記述できます:
def Inline.string? (inline : Inline) : Option String :=
if let Inline.string s := inline then
some s
else none
これはパターンマッチの let
構文によく似ています。違いは、else
の場合にフォールバックするようになるため、直和型で使用できる点です。文脈によっては、match
の代わりに if let
を使うとコードが読みやすくなることがあります。
構造体の位置引数
「構造体」の節 では構造体の構築の仕方について2つの方法を紹介しました:
Point.mk 1 2
のようにコンストラクタを直接呼ぶ。
{ x := 1, y := 2 }
のように括弧記法を使う。
文脈によっては、コンストラクタに直接名前を付けずに、名前ではなく位置で引数を渡すと便利な場合があります。例えば、様々な似たような構造体タイプを定義することで、ドメイン概念を分離しておくことができますが、コードを読む際にはそれらを実質的にタプルとして扱う方が自然です。このような文脈では、引数を角括弧 ⟨
と ⟩
で囲むことができます。Point
は ⟨1, 2⟩
と書くことができます。ここで注意点です!この括弧は小なり記号 <
と大なり記号 >
のように見えますが、これらの括弧は違うものです。それぞれ \<
と \>
で入力できます。
名前付きコンストラクタ引数の波括弧表記と同様に、この位置指定構文は、型注釈やプログラム内の他の型情報から、Leanが構造体の型を決定できるコンテキストでのみ使用できます。例えば、#eval ⟨1, 2⟩
は以下のエラーを返します:
invalid constructor ⟨...⟩, expected type must be an inductive type
?m.34991
エラーのメタ変数は、利用可能な型情報が無いためです。例えば、#eval (⟨1, 2⟩ : Point)
のような注釈を追加することで問題は解決します:
{ x := 1.000000, y := 2.000000 }
文字列への内挿
Leanでは、文字列の先頭に s!
を付けると 内挿 が発動し、文字列内の波括弧に含まれる式がその値に置き換えられます。これはPythonの f
文字列や、C#の $
接頭辞付き文字列に似ています。例えば、
#eval s!"three fives is {NewNamespace.triple 5}"
は以下を出力します
"three fives is 15"
すべての式を文字列に内挿できるわけではありません。例えば、関数を内挿しようとするとエラーになります。
#check s!"three fives is {NewNamespace.triple}"
上記は以下を出力します
failed to synthesize instance
ToString (Nat → Nat)
これは、関数を文字列に変換する標準的な方法がないからです。Leanコンパイラはさまざまな型の値を文字列に変換する方法を記述したテーブルを保持しており、failed to synthesize instance
というメッセージはLeanコンパイラがこのテーブルの中から指定された型の要素を見つけられなかったことを意味します。これは「構造体」の節 で説明した deriving Repr
構文と同じ言語機能を使用しています。
まとめ
式の評価
Leanでは、式が評価されるときに計算も行われます。これは数式の通常のルールに従って行われます:式全体が値になるまで、通常の演算順序に従って部分式が値で置き換えられます。if
や match
を評価する場合、条件の値やマッチの対象が見つかるまでは枝の中の式は評価されません。
一度値が与えられると、変数は決して変化しません。これは数学と似ている一方でほとんどのプログラミング言語と異なった性質です。Leanの変数は単に値のプレースホルダであり、新しい値を書き込むことができるアドレスではありません。変数の値は def
によるグローバル定義、let
によるローカル定義、関数への名前付き引数、パターンマッチによって得られます。
関数
Leanにおいて関数は第一級です。つまり、関数をほかの関数に引数として渡したり、変数に保存したり、ほかの値と同じように使用することができます。Leanのすべての関数は必ず1つの引数を取ります。2つ以上の引数を取る関数を実装する場合には、Leanはカリー化と呼ばれるテクニックを使います。これは複数引数のうち最初の引数を受け取ってそれ以降の引数を受け取るような関数を返すようにするものです。引数を取らない関数を実装する場合には、Leanでは Unit
型という引数として最低限の情報を持つ型を使用します。
関数の実装方法は主に以下の3つがあります:
fun
を用いた無名関数。例として、Point
のフィールドの交換を行う関数はfun (point : Point) => { x := point.y, y := point.x : Point }
と書くことができます。
- 括弧の中で一つ以上の中黒点を置いた非常にシンプルな無名関数。各中黒点は関数の引数になり、括弧は関数の本体の範囲を仕切ります。例として、引数から1を引く関数
fun x => x - 1
の代わりに(· - 1)
とも書けます。
def
かlet
に引数のリストかパターンマッチ記法を続けるようにして定義した関数。
型
Leanはすべての式が型を持っていることをチェックします。Leanでの型とは何かしらの式について最終的に見つかるかもしれない値を記述したもので、例えば Int
や Potin
、{α : Type} → Nat → α → List α
、Option (String ⊕ (Nat × String))
のようなものになります。ほかの言語と同様に、Leanの型はLeanのコンパイラによってチェックされるプログラムのための軽量な仕様を表現することができ、一部の単体テストの必要性を排除します。ほとんどの言語とは異なり、Leanの型は任意の数学も表現でき、プログラミングと定理証明の世界を統合しています。Leanを定理証明に使うことは本書の範囲外ですが、 Theorem Proving in Lean 4 にてこのトピックに関する詳細な情報があります。1
式の中には複数の型を指定できるものもあります。例えば、3
は Int
にも Nat
にもなります。Leanでは、これは同じものに対して2つの異なる型あるというよりも、一つは Nat
型でもう一つは Int
型である2つの別々の式がたまたま同じように書かれたと理解すべきです。
Leanは自動的に型を決定できることもありますが、多くの場合、型はユーザによって提供されなければなりません。これはLeanの型システムが非常に表現力豊かだからです。仮にLeanが型を見つけることが出来たとしても、それが期待した型ではない可能性があります。3
は Int
として使用されることを意図しているかもしれませんが、一切制約がない場合、Leanは Nat
という型を与えます。一般的に、ほとんどの型は明示的に記述し、非常に明白な型はLeanに埋めてもらうようにするとよいでしょう。これはLeanのエラーメッセージを改善し、プログラマの意図をより明確にするのに役立ちます。
関数やデータ型の中には、型を引数に取るものがあります。これは 多相性 (polymorphic)と呼ばれます。多相性によって、リストの要素がどの型を持っているかを気にせずにリストの長さを計算するようなプログラムが可能になります。型はLeanにおいて第一級であるため、多相性は特別な構文を必要とせず、型はほかの引数と同じように渡されます。関数型において型引数に名前を与えることで、後の型がその引数に言及できるようになり、その関数を引数に適用した型は引数の名前を引数の値に置き換えることで求められます。
構造体と帰納的型
Leanでは structure
や inductive
の機能を使って、全く新しいデータ型を導入することができます。これらの新しいデータ型はたとえ定義が同じであったとしても他のデータ型と同じであるとはみなされません。データ型はその値を構築する方法を説明する コンストラクタ (constructor)を持ち、各コンストラクタはいくつかの引数を取ります。Leanのコンストラクタはオブジェクト指向言語のコンストラクタとは異なります:Leanのコンストラクタは、割り当てられたオブジェクトを初期化するアクティブなコードではなく、データを保持する不活性なものです。
一般的に、sturucture
は直積型(つまり、任意の数の引数を取るコンストラクタを1つだけ持つ型)を導入するために使用され、inductive
は直和型(つまり、多数の異なるコンストラクタを持つ型)を導入するために使用されます。sturucture
で定義されたデータ型はコンストラクタの引数ごとにアクセサ関数が提供されます。構造体と帰納的データ型のどちらもパターンマッチにかけることができます。パターンマッチはコンストラクタを呼び出すために使用される構文のサブセットを使用して、コンストラクタの内部に格納されている値を展開します。パターンマッチは、値の作成方法を知るにはその値を消費する方法を知る必要があることを意味します。
再帰
定義されている名前がその定義自体で使われている場合、定義は再帰的です。Leanはプログラミング言語であると同時に対話型定理証明器であるため、再帰的定義には一定の制限があります。Leanの論理的な側面では、循環的な定義は論理的な矛盾につながる可能性があります。
再帰的定義がLeanの論理的側面を損なわないようにするために、Leanは再帰関数がどのような引数で呼び出されたとしても、すべての再帰関数が終了することを証明できなければなりません。実用としては、これは①再帰的な呼び出しがすべて入力の構造的に小さい部分で実行され、常にその構造の基底のケースに向かって進むことを保証するか、②ユーザが関数が常に終了するという他の証拠を提供する必要があること、のどちらかを意味します。同様に、再帰的帰納型はその型を引数として 受け取る 関数を取るコンストラクタを持つことはできません。なぜならこれは終了しない関数の実装を可能にするからです。
日本語訳は https://aconite-ac.github.io/theorem_proving_in_lean4_ja/
Hello, World!
Lean には豊かな対話的環境があり、プログラマはお気に入りのテキストエディタを離れることなく、多くのフィードバックを得ることができます。また、Lean は実用的なプログラムを記述することができる言語でもあります。つまり、バッチモードのコンパイラ、ビルドシステム、パッケージマネージャなど、プログラムを書くために不可欠なツールもすべて備えているということです。
前の章では、Lean における関数型プログラミングの基本を紹介しましたが、この章ではプロジェクトを作成し、コンパイルして、その結果を実行する一連の方法を説明します。環境と相互作用するプログラム(例えば、標準入力を読み取ったりファイルを作成したりすること)と、計算を「数学的な式の評価」と解釈する関数型プログラミングは、整合させることが難しいものです。この章では Lean のビルドツールの説明に加え、関数型プログラミングにおいて世界と相互作用するようなプログラムをどのように扱うかについても説明します。
プログラムの実行
Lean プログラムを実行する最も簡単な方法は、Lean の実行ファイルに --run
オプションを使用することです。Hello.lean
という名前のファイルを作成し、以下の内容を入力してみましょう。
def main : IO Unit := IO.println "Hello, world!"
次に、コマンドラインから以下を実行してください。
lean --run Hello.lean
このプログラムは Hello, world!
を表示して終了します。
挨拶の構造
Lean を --run
オプション付きで実行すると、プログラムの main
定義が呼び出されます。コマンドライン引数を取らないプログラムの場合、main
の型は IO Unit
であるべきです。この型には矢印(→
)が含まれていません。これは main
が関数でないことを意味します。つまり、main
は副作用を持つ関数ではなく、実行される作用の説明から成り立っています。
前の章で議論したように、Unit
は最も単純な帰納型です。Unit
のコンストラクタは一つだけで、unit
という引数を取らない関数です。C言語系のプログラミング言語には、何の値も返さない void
関数の概念があります。一方 Lean では、すべての関数が引数を取り、値を返します。引数や返り値がないことは、代わりに Unit
型を使用して示すことができます。Bool
が1ビットの情報を表すなら、Unit
が表すのは0ビットの情報です。
IO α
は、「実行されると型 α
の値を返すか、または例外をスローするようなプログラム」の型です。IO α
型を持つプログラムは、実行中に副作用を発生させる可能性があり、IO
アクションと呼ばれます。Lean は、変数への値の代入と副作用のない部分式の簡約という数学的モデルに厳密に従う「式の評価」と、外部システムに依存して世界と相互作用する「IO
アクションの実行」を区別します。IO.println
は文字列から IO
アクションへの関数で、実行すると指定された文字列を標準出力に書き込みます。この IO
アクションは文字列を出力する過程で環境から情報を読み取らないため、IO.println
の型は String → IO Unit
です。もし何か値を返すなら、返り値の型は IO Unit
ではなくなります。
関数型プログラミングと作用
Lean の計算モデルは数学的な式の評価に基づいています。変数は正確に1つの値を持ち、時間が経っても変化することはありません。式を評価した結果も変わることがなく、同じ式を評価するたびに常に同じ結果が得られます。
一方で、実用上プログラムは世界と相互作用する必要があります。入力や出力を行わないプログラムは、ユーザからデータを要求したり、ディスクにファイルを作成したり、ネットワーク接続を開いたりすることはできません。Lean は Lean 自身で記述されていますが、Lean コンパイラはファイルを読み込んだり、ファイルを作成したり、テキストエディタと対話したりします。ファイルの内容は時間によって変化しうるにも関わらず、「同じ式は常に同じ結果を返す」ような言語で、どうやって「ファイルをディスクから読み込む」というプログラムを実現しているのでしょうか?
この明らかな矛盾は、副作用について少し異なる考え方をすることで解決できます。とあるカフェを想像してみてください。コーヒーとサンドイッチを販売していて、従業員が2人います。このカフェには2人の従業員がいます。注文された料理を作るコックと、客と話して注文を取るカウンターの従業員の2人です。コックは不愛想な人物で、外の世界と接触したくないというのが本音ですが、そのカフェの名物である料理とドリンクを一貫して提供するのはとても得意です。しかし、そのためにはコックには平穏と静けさが必要で、会話で邪魔されてはいけません。カウンターの従業員はフレンドリーな人間ですが、キッチンの仕事は全くできません。客はカウンターの従業員と話し、カウンターの従業員はすべての調理をコックに任せます。コックが客に質問がある場合(アレルギーの確認など)、カウンターの従業員に小さなメモを送り、その従業員が客とやり取りし、返事を書いたメモをコックに返します。
これは例え話で、コックは Lean 言語を表しています。注文が入ると、コックは一貫して注文されたものを忠実に提供します。カウンターの従業員は、世界と相互作用する周囲のランタイムシステムです。支払いを受け取り、食事を運び、お客さんと話をします。2人の従業員は協力して、レストランのすべての機能を提供しますが、彼らの責任は分かれており、それぞれが得意なタスクを実行します。客を遠ざけることでコックが本当においしいコーヒーやサンドイッチを作ることに集中できるのと同じように、Lean は副作用を隔離することで、プログラムを正式な数学的証明の一部として使うことができます。副作用がないことには、プログラムが理解しやすくなるという利点もあります。コンポーネント間の微妙な結合を生み出す隠れた状態変化がないため、プログラムを互いに切り離された部分の集まりとして理解できるからです。コックのメモは、Lean 式を評価して生成される IO
アクションを表し、カウンターの従業員の返事は、実行結果から渡される値です。
この副作用のモデルは、Lean 言語全体、コンパイラ、およびランタイムシステム(RTS)の総合的な動作方法とかなり類似しています。ランタイムシステム内のプリミティブな部分はCで書かれており、すべての基本的な作用を実装しています。プログラムを実行する際、RTS は main
アクションを呼び出します。呼び出された main
アクションは、実行されるべき新しい IO
アクションを RTS に返します。RTS はこれらのアクションを実行し、ユーザの Lean コードに計算を実行させます。Lean という内部の視点からは、プログラムに副作用はなく、IO
アクションは実行されるべきタスクの説明にすぎません。プログラムのユーザという外部の視点からは、副作用のレイヤが存在していて、プログラムのコアロジックへのインターフェースを形成しているように見えます。
現実世界の関数型プログラミング
Lean における副作用について考えるもう一つの有用な方法は、IO
アクションを、世界全体を引数として受け取り、値と新しい世界の組を返す関数と考えることです。この場合、標準入力からテキストを読み取ることは純粋な関数です。なぜなら、異なる世界が引数として都度渡されるからです。標準出力にテキストを書き込むことも純粋な関数です。なぜなら、関数が返す世界は実行開始時のものと異なるからです。プログラムは世界を再利用しないように、新しい世界を返し損ねないように、よくよく注意する必要があります。それは結局、タイムトラベルまたは世界の終了を意味するからです。注意深い抽象化で副作用を隔離することにより、Lean は安全なプログラミングスタイルを実現しています。世界が与えられたら常に新しい世界を返すようなプリミティブ IO
アクションを、その条件を保つツールとだけ組み合わせている限り、問題は発生しないのです。
このモデルは実装できません。結局のところ、宇宙全体を Lean の値に変えてメモリに配置することはできません。ただし、このモデルのバリエーションを、世界を表す抽象トークンを使用して実装することは可能です。プログラムが開始するとき、世界のトークンが渡されます。その後、このトークンは IO プリミティブに渡され、返されたトークンも同様に次のステップに渡されます。プログラムの終了時には、トークンは OS に返されます。
この副作用のモデルは、Lean 内部で RTS によって実行されるタスクの説明としての IO
アクションがどのように表現されているかを良く説明しています。実際の世界を変える関数は抽象性の壁の背後に隠れています。しかし、実際のプログラムは通常、1つだけでなく一連の作用から成り立っています。プログラムが複数の作用を利用できるようにするために、Lean には do
記法と呼ばれるサブ言語があり、プリミティブな IO
アクションを安全に組み合わせて、より大きく有用なプログラムを作ることができます。
IO
アクションの結合
ほとんどの有用なプログラムは、出力を生成するだけでなく、入力を受け付けます。さらに、入力データをもとに計算を行い、決定を下すこともあります。次のプログラム、HelloName.lean
は、ユーザに名前を尋ね、それから挨拶をします。
def main : IO Unit := do
let stdin ← IO.getStdin
let stdout ← IO.getStdout
stdout.putStrLn "How would you like to be addressed?"
let input ← stdin.getLine
let name := input.dropRightWhile Char.isWhitespace
stdout.putStrLn s!"Hello, {name}!"
このプログラムでは、main
アクションは do
ブロックで構成されています。do ブロックには、一連の文(statement)が含まれています。それぞれの文は、let
によるローカル変数の定義だったり、実行されるアクションであったりします。SQL がデータベースと対話するための特別な目的の言語と考えることができるように、do
構文は、Lean 内で命令型プログラムをモデル化するための専用のサブ言語だと考えることができます。do
ブロックで構築された IO
アクションは、文を順番に実行することで実行されます。
このプログラムは、前のプログラムと同じ方法で実行できます。
lean --run HelloName.lean
ユーザが David
と応答する場合、プログラムとの対話セッションが次のように表示されます。
How would you like to be addressed?
David
Hello, David!
型のシグネチャ行は、Hello.lean
のものと同じです。
def main : IO Unit := do
唯一の違いは、キーワード do
で終わることです。do
はコマンドのシーケンスを開始します。キーワード do
に続くインデントされた各行は、同じ一連のコマンドの一部です。
最初の2行は、次のように記載されています。
let stdin ← IO.getStdin
let stdout ← IO.getStdout
これらの行は、ライブラリアクション IO.getStdin
および IO.getStdout
を実行して stdin
と stdout
のハンドルを取得します。do
ブロックの中の let
は、通常の式の中の let
とはやや異なる意味を持ちます。通常、let
で導入されたローカル定義は、すぐにそのローカル定義に続く式でしか使用できません。do
ブロックでは、let
によって導入されたローカル束縛は、次の式だけでなく、do
ブロックの残りのすべての文で使用できます。さらに、通常 let
は :=
を使って定義される名前とその定義を結びつけますが、do
内部の let
束縛においては、代わりに左矢印(←
または <-
)を使うことがあります。矢印を使用するということは、式の値が IO
アクションであり、そのアクションの実行結果をローカル変数に保存するということを意味します。言い換えれば、矢印の右側の式が型 IO α
を持つ場合、その変数は do
ブロックの残りの部分で型 α
を持ちます。IO.getStdin
および IO.getStdout
は stdin
と stdout
をプログラム内でローカルに上書きできるようにするための便利な IO
アクションです。C 言語のように stdin
と stdout
がグローバル変数であったら、(Lean では一度束縛した値は変更できないので)これを上書きすることはできませんが、IO
アクションなら実行ごとに異なる値を返すことができます。
do
ブロックの次の部分は、ユーザに名前を尋ねます。
stdout.putStrLn "How would you like to be addressed?"
let input ← stdin.getLine
let name := input.dropRightWhile Char.isWhitespace
最初の行は質問を stdout
に書き込み、2番目の行は stdin
から入力をリクエストし、3番目の行は入力行から末尾の改行(および末尾の空白)を削除します。name
の定義では、String.dropRightWhile
は IO
アクションではなく、通常の文字列関数であるため、←
ではなく :=
を使用しています。
最後に、プログラムの最終行は以下のようなものです。
stdout.putStrLn s!"Hello, {name}!"
ここでは文字列補間を使って、与えられた名前を挨拶の文字列に挿入し、その結果を stdout
に書き込んでいます。
ステップ・バイ・ステップ
do
ブロックは一行ずつ実行できます。前の節のプログラムから始めましょう:
let stdin ← IO.getStdin
let stdout ← IO.getStdout
stdout.putStrLn "How would you like to be addressed?"
let input ← stdin.getLine
let name := input.dropRightWhile Char.isWhitespace
stdout.putStrLn s!"Hello, {name}!"
標準入出力
最初の行は let stdin ← IO.getStdin
で残りの部分は以下の通りです:
let stdout ← IO.getStdout
stdout.putStrLn "How would you like to be addressed?"
let input ← stdin.getLine
let name := input.dropRightWhile Char.isWhitespace
stdout.putStrLn s!"Hello, {name}!"
←
を使った let
文を実行するには、まず矢印の右側にある式(この場合は IO.getStdIn
)を評価します。この式は単なる変数なので、その値が参照されます。結果として得られる値は組み込みのプリミティブな IO
アクションです。次のステップはこの IO
アクションを実行し、標準入力ストリームを表す値を得ることです。この値は IO.FS.Stream
型です。標準入力はその後、矢印の左側の名前(ここでは stdin
)に関連付けられ、do
ブロックの残りの部分で使用されます。
2行目の let stdout ← IO.getStdout
の実行も同様に進みます。まず、IO.getStdout
という式が評価され、標準出力を返す IO
アクションが得られます。次に、このアクションが実行され、実際に標準出力が返されます。最後に、この値が stdout
という名前に関連付けられ、do
ブロックの残りの部分で使用されます。
質問をする
stdin
と stdout
が定まったので、ブロックの残りの部分は質問と答えから構成されます:
stdout.putStrLn "How would you like to be addressed?"
let input ← stdin.getLine
let name := input.dropRightWhile Char.isWhitespace
stdout.putStrLn s!"Hello, {name}!"
ブロックの最初の文である stdout.putStrLn "How would you like to be addressed?"
は式から成り立っています。式を実行するにはまず評価します。今回の場合、IO.FS.Stream.putStrLn
は IO.FS.Stream → String → IO Unit
型を持ちます。これはストリームと文字列を受け取り、IO
アクションを返す関数であることを意味します。この式は関数呼び出しのためにアクセサ記法を使用しています。呼び出された関数には標準出力ストリームと文字列の2つの引数が適用されます。よってこの式の値は文字列と改行文字を出力ストリームに書き込む IO
アクションです。値が決まった後、次のステップでそれを実行することにより、実際に文字列と改行文字が stdout
に書き込まれます。式だけで構成される文は新しい変数を導入しません。
次の文は let input ← stdin.getLine
です。IO.FS.Stream.getLine
は IO.FS.Stream → IO String
型を持ちます。これはストリームを受け取り、文字列を返す IO
アクションであることを意味します。これもアクセサ記法の一例です。この IO
アクションが実行されると、プログラムはユーザが入力を完了するまで待機します。ユーザが "David"
と入力したとすると、結果として得られる文字列("David\n"
)は input
に関連付けられます(\n
はエスケープシーケンスでここでは改行文字を表します)。
let name := input.dropRightWhile Char.isWhitespace
stdout.putStrLn s!"Hello, {name}!"
次の行 let name := input.dropRightWhile Char.isWhitespace
は let
文です。このプログラムの他の let
文とは異なり、←
ではなく :=
を使用しています。これは、式が評価されるものの、その結果の値は IO
アクションである必要はなく、実行もされないことを意味します。今回の場合、String.dropRightWhile
は文字列と文字に対する述語を受け取り、述語を満たす文字を末尾から全て削除した新しい文字列を返します。例えば、
#eval "Hello!!!".dropRightWhile (· == '!')
は次の文字列を返し、
"Hello"
また、
#eval "Hello... ".dropRightWhile (fun c => not (c.isAlphanum))
は次の文字列を返します。
"Hello"
このように文字列の右側から英数字ではない文字を全て削除します。現在のプログラムの行では、空白文字(空白文字として改行文字も含まれる)が入力文字列の右側から削除され、その結果得られる "David"
がこのブロックの残りの部分で name
に関連付けられます。
ユーザへの挨拶
do
ブロック内で実行される残りの文は以下の1行です:
stdout.putStrLn s!"Hello, {name}!"
putStrLn
への文字列引数は文字列補完によって構築され、"Hello, David!"
という文字列になります。この文は式なので、評価されると、文字列を改行とともに標準出力に表示する IO
アクションが得られます。この式が評価されると、結果として得られる IO
アクションが実行され、挨拶が表示されます。
値としての IO
アクション
上記の説明では、式の評価と IO
アクションの実行の区別がなぜ必要なのかわかりにくいかもしれません。結局のところ、各アクションは生成された直後に実行されます。他の言語のように、評価中に作用を実行しないのはなぜでしょうか?
答えは2つあります。まず、評価と実行を分離することで、プログラムはどの関数が副作用を持つか明示的に示す必要があります。副作用を持たないプログラムの部分は数学的な推論がしやすいため、プログラマが頭の中で考える場合でも Lean の形式証明の機能を使う場合でも、この分離はバグを回避しやすくします。次に、すべての IO
アクションが生成された時点で実行される必要はありません。アクションを実行せずに記述できる機能により、通常の関数を制御構造として使用できるようになります。
例えば、twice
関数は IO
アクションを引数にとり、最初のアクションを2回実行する新しいアクションを返します。
def twice (action : IO Unit) : IO Unit := do
action
action
例えば、以下を実行すると、
twice (IO.println "shy")
結果は、
shy
shy
のように出力されます。これは、基礎となるアクションを任意の回数実行するバージョンに一般化できます:
def nTimes (action : IO Unit) : Nat → IO Unit
| 0 => pure ()
| n + 1 => do
action
nTimes action n
基底ケースである Nat.zero
では、結果は pure()
です。pure
関数は副作用のない IO
アクションを作成し、引数(この場合 Unit
のコンストラクタ)を返します。何もせず何の興味深いものも返さないアクションである pure()
はとても退屈であると同時に非常に便利です。再帰的なステップでは、do
ブロックを使って、最初に action
を実行した後にその再帰呼び出しの結果を実行するアクションを作成します。
Hello
Hello
Hello
制御構造として関数を使うことに加えて、IO
アクションが第一級の値であるということは、後で実行するためにその値をデータ構造として保存できることを意味します。例えば、countdown
関数は Nat
を引数にとり、未実行の Nat
ごとの IO
アクションのリストを返します:
def countdown : Nat → List (IO Unit)
| 0 => [IO.println "Blast off!"]
| n + 1 => IO.println s!"{n + 1}" :: countdown n
この関数は副作用がなく、何も出力しません。 例えば、引数にこの関数を適用して、結果として得られるアクションのリストの長さを確認することができます:
def from5 : List (IO Unit) := countdown 5
このリストは6つの要素を含みます(各数値ごとのアクションに加えて、ゼロのときの "Blast off!"
):
#eval from5.length
6
runActions
関数はアクションのリストを受け取り、それらを順番に実行する一つのアクションを構築します:
def runActions : List (IO Unit) → IO Unit
| [] => pure ()
| act :: actions => do
act
runActions actions
これは nTimes
と同じ構造ですが、Nat.succ
ごとに実行されるアクションが1つずつあるのではなく、List.cons
ごとに実行されるアクションがあります。同様に、runActions
はそれ自体ではアクションを実行しません。アクションを実行する新しいアクションを作成し、そのアクションは main
の一部として実行される位置に置かなければいけません:
def main : IO Unit := runActions from5
このプログラムを実行すると、以下のような出力が得られます:
5
4
3
2
1
Blast off!
プログラムを実行したときに何が起こっているのでしょうか?最初のステップは main
の評価です。以下のように実行されます:
main
===>
runActions from5
===>
runActions (countdown 5)
===>
runActions
[IO.println "5",
IO.println "4",
IO.println "3",
IO.println "2",
IO.println "1",
IO.println "Blast off!"]
===>
do IO.println "5"
IO.println "4"
IO.println "3"
IO.println "2"
IO.println "1"
IO.println "Blast off!"
pure ()
IO
アクションの結果は do
ブロックです。do
ブロックの各ステップが1つずつ実行され、期待する出力が得られます。最後のステップである pure()
は何の作用もなく、runActions
の定義として基底ケースが必要なため存在しています。
演習問題
次のプログラムの実行を、紙の上でステップ実行してください:
def main : IO Unit := do
let englishGreeting := IO.println "Hello!"
IO.println "Bonjour!"
englishGreeting
プログラムをステップ実行しながら、いつ式が評価されて、いつ IO
アクションが実行されるかを確認してください。IO
アクションを実行した結果、副作用が発生したときは、それを書き留めます。紙の上での実行が終わった後、Lean を使ってプログラムを実行し、副作用に関するあなたの予想が正しかったかを再確認してください。
プロジェクトの開始
Lean で書かれたプログラムが本格的になるにつれて,Ahead-Of-Time コンパイラベースのワークフローが魅力的になってきます.ほかの言語と同様に,Lean にも複数ファイルのパッケージのビルドと依存関係の管理のためのツールがあります.Lean の標準的なビルドツールは Lake(Lean Make の略)と呼ばれ,Lean で設定されます.Lean には do
記法のように副作用を持つプログラムを書くための特別な言語があるように,Lake にもビルドを設定するための特別な言語があります.これらの言語は埋め込みドメイン固有言語(embedded domain-specific languages)と呼ばれ EDSL と略されます(またはドメイン固有言語(domain-specific languages)とも呼び DSL と略します).EDSL は,あるサブドメインの概念を用いて特定の目的のために使用されるという意味でドメイン固有(domain-specific)であり,一般的に汎用プログラミングには適していません.また,埋め込み(embedded)とはほかの言語の構文の内部で使用されることに由来します.Lean には EDSL を作成するための豊富な機能がありますが,それは本書の範囲外です.
最初のステップ
Lake を使用するプロジェクトを開始するには,greeting
というファイルやディレクトリがまだ存在しないディレクトリで lake new greeting
コマンドを使用します.
Main.lean
は Lean コンパイラがmain
アクションを探すファイルです.Greeting.lean
とGreeting/Basic.lean
はプログラムのサポートライブラリの足場です.lakefile.lean
はlake
がアプリケーションをビルドするために必要な設定を含みます.lean-toolchain
は プロジェクトに使用される Lean の特定のバージョンの識別子を含みます.
さらに,lake new
はプロジェクトを Git リポジトリとして初期化し,.gitignore
ファイルを設定して中間ビルド生成物を無視します.通常,アプリケーションロジックの大部分はプログラム用のライブラリの集まりに含まれ,一方で Main.lean
にはコマンドラインの解析や中心的なアプリケーションロジックの実行を行う小さなラッパーが含まれます.
すでに存在するディレクトリにプロジェクトを作成するには,lake init
の代わりに lake new
を実行します.
デフォルトでは,ライブラリファイル Greeting/Basic.lean
は1つの定義を含みます:
def hello := "world"
ライブラリファイル Greeting.lean
は Greeting/Basic.lean
をインポートします:
-- This module serves as the root of the `Greeting` library.
-- Import modules here that should be built as part of the library.
import «Greeting».Basic
これは,Greetings/Basic.lean
で定義された全てが Greetings.lean
をインポートするファイルでも利用可能であることを意味します.import
文では,ドットはディスク上のディレクトリとして解釈されます.«Greeting»
のように名前の周りに二重山括弧を置くことで,通常 Lean の名前では許されないスペースや他の文字を含むことができます.また,«if»
や «def»
と書くことで,if
や def
のような予約語を通常の名前として使用できます.これにより,lake new
で作成されたパッケージ名にそれらの文字が含まれていた場合の問題を防ぐことができます.
実行可能なソース Main.lean
には以下の内容が含まれています:
import «Greeting»
def main : IO Unit :=
IO.println s!"Hello, {hello}!"
Main.lean
は Greetings.lean
をインポートし,Greetings.lean
は Greetings/Basic.lean
をインポートするので,hello
の定義は main
で使用可能です.
パッケージをビルドするには,lake build
を実行します.いくつかのビルドコマンドがスクロールした後,結果のバイナリは build/bin
に置かれます.
./build/bin/greeting
の実行結果は Hello, world!
です.
Lakefiles
lakefile.lean
は配布用の Lean コードの首尾一貫したコレクションであるパッケージを記述します.これは npm
や nuget
パッケージ,Rust のクレートと似ています.パッケージはいくつかのライブラリや実行可能ファイルを含むことができます.Lake のドキュメント では lakefile で利用可能なオプションについて説明されていますが,ここではまだ説明されていない多くの Lean の機能を利用しています.
生成された lakefile.lean
には以下が含まれています:
import Lake
open Lake DSL
package «greeting» where
-- add package configuration options here
lean_lib «Greeting» where
-- add library configuration options here
@[default_target]
lean_exe «greeting» where
root := `Main
-- Enables the use of the Lean interpreter by the executable (e.g.,
-- `runFrontend`) at the expense of increased binary size on Linux.
-- Remove this line if you do not need such functionality.
supportInterpreter := true
生成直後の Lakefile は3つの項目を含みます:
greeting
という名前のパッケージ宣言Greeting
という名前のライブラリ宣言greeting
という名前の実行ファイル
これらの名前はそれぞれ二重山括弧で囲まれており,ユーザがパッケージ名を自由に選べるようになっています.
各 Lakefile にはちょうど1つのパッケージが含まれますが,ライブラリや実行ファイルはいくつでも含めることができます.さらに,Lakefile は以下も含めることができます:
- 外部ライブラリ(external libraries)- Lean で書かれていませんが,最終的な実行可能ファイルに制的リンクされるライブラリ
- カスタムターゲット(custom targets)- ライブラリ・実行可能ファイルの分類には自然に収まらないビルドターゲット
- 依存関係(dependencies)- 他の Lean パッケージの宣言(ローカルまたは Git リポジトリから取得されます)
- スクリプト(scripts)- 本質的には
main
のようなIO
アクションで,パッケージの設定に関するメタデータにもアクセスできます
Lakefile の項目により,ソースファイルの場所,モジュール階層,コンパイラフラグなどの設定が可能です.しかし一般的にはデフォルト設定が合理的です.
ライブラリ,実行可能ファイル,カスタムターゲットはすべてターゲット(target)と呼ばれます.デフォルトでは,lake build
は @[default_target]
でアノテーションされたターゲットをビルドします.このアノテーションはアトリビュート(attribute)で,Lean の宣言に関連付けることができるメタデータです.アトリビュートは Java のアノテーションや C# や Rust のアトリビュートと似ています.これらは Lean 全体で広く使用されています.
@[default_target]
でアノテーションされていないターゲットをビルドするには,lake build
の後にターゲットの名前を引数として指定します.
ライブラリとインポート
Lean のライブラリは名前をインポートできるソースファイルの階層的に整理されたコレクションで構成され,これをモジュール(moudle)と呼びます.デフォルトでは,ライブラリにはその名前と一致する1つのルートファイルがあります.今回の場合,ライブラリ Greeting
のルートファイルは Greeting.lean
です.
Main.lean
の最初の行 import Greeting
は Greeting.lean
の内容を Main.lean
で使用可能にします.
追加のモジュールファイルをライブラリに追加するには,``Greetingというディレクトリを作成し,その中にファイルを配置します.これらの名前はディレクトリの区切り文字をドットに置き換えることでインポートできます. 例えば,以下の内容の
Greeting/Smile.lean` を作成するとします:
def expression : String := "a big smile"
これにより,Main.lean
は以下のように定義を使用できます:
import Greeting
import Greeting.Smile
def main : IO Unit :=
IO.println s!"Hello, {hello}, with {expression}!"
モジュール名の階層は名前空間の階層と切り離されています.Lean では,モジュールはコード配布の単位で,名前空間はコードの集まりの単位です.つまり,Greeting.Smile
モジュールで定義された名前は,対応する名前空間 Greeting.Smile
に自動的に含まれるわけではありません.モジュールは任意の名前空間に名前を配置でき,インポートするコードはその名前空間を open
するかどうかを選べます.import
はソースファイルの内容を使用可能にするために使用され,open
は名前空間から名前をプレフィックスなしで現在のコンテキストで使用可能にします.Lakefile では,import Lake
の行は Lake
モジュールの内容を使用可能にし,open Lake DSL
の行は Lake
および Lake.DSL
名前空間の内容をプレフィックスなしで使用可能にします.Lake
をオープンすることで Lake.DSL
が DSL
として使用可能となるため,Lake.DSL
もオープンされます.
Lake
モジュールは名前を Lake
と Lake.DSL
名前空間の両方に配置します.
名前空間は選択的にオープンすることもでき,特定の名前だけをプレフィックスなしで使用可能にすることができます.これは,必要な名前を括弧内に書くことで行います.
例えば,Nat.toFloat
は自然数を Float
に変換します.
これを toFloat
として使用可能にするには open Nat (toFloat)
と書きます.
Worked Example: cat
The standard Unix utility cat
takes a number of command-line options, followed by zero or more input files.
If no files are provided, or if one of them is a dash (-
), then it takes the standard input as the corresponding input instead of reading a file.
The contents of the inputs are written, one after the other, to the standard output.
If a specified input file does not exist, this is noted on standard error, but cat
continues concatenating the remaining inputs.
A non-zero exit code is returned if any of the input files do not exist.
This section describes a simplified version of cat
, called feline
.
Unlike commonly-used versions of cat
, feline
has no command-line options for features such as numbering lines, indicating non-printing characters, or displaying help text.
Furthermore, it cannot read more than once from a standard input that's associated with a terminal device.
To get the most benefit from this section, follow along yourself. It's OK to copy-paste the code examples, but it's even better to type them in by hand. This makes it easier to learn the mechanical process of typing in code, recovering from mistakes, and interpreting feedback from the compiler.
Getting started
The first step in implementing feline
is to create a package and decide how to organize the code.
In this case, because the program is so simple, all the code will be placed in Main.lean
.
The first step is to run lake new feline
.
Edit the Lakefile to remove the library, and delete the generated library code and the reference to it from Main.lean
.
Once this has been done, lakefile.lean
should contain:
import Lake
open Lake DSL
package «feline» {
-- add package configuration options here
}
@[default_target]
lean_exe «feline» {
root := `Main
}
and Main.lean
should contain something like:
def main : IO Unit :=
IO.println s!"Hello, cats!"
Alternatively, running lake new feline exe
instructs lake
to use a template that does not include a library section, making it unnecessary to edit the file.
Ensure that the code can be built by running lake build
.
Concatenating Streams
Now that the basic skeleton of the program has been built, it's time to actually enter the code.
A proper implementation of cat
can be used with infinite IO streams, such as /dev/random
, which means that it can't read its input into memory before outputting it.
Furthermore, it should not work one character at a time, as this leads to frustratingly slow performance.
Instead, it's better to read contiguous blocks of data all at once, directing the data to the standard output one block at a time.
The first step is to decide how big of a block to read.
For the sake of simplicity, this implementation uses a conservative 20 kilobyte block.
USize
is analogous to size_t
in C—it's an unsigned integer type that is big enough to represent all valid array sizes.
def bufsize : USize := 20 * 1024
Streams
The main work of feline
is done by dump
, which reads input one block at a time, dumping the result to standard output, until the end of the input has been reached:
partial def dump (stream : IO.FS.Stream) : IO Unit := do
let buf ← stream.read bufsize
if buf.isEmpty then
pure ()
else
let stdout ← IO.getStdout
stdout.write buf
dump stream
The dump
function is declared partial
, because it calls itself recursively on input that is not immediately smaller than an argument.
When a function is declared to be partial, Lean does not require a proof that it terminates.
On the other hand, partial functions are also much less amenable to proofs of correctness, because allowing infinite loops in Lean's logic would make it unsound.
However, there is no way to prove that dump
terminates, because infinite input (such as from /dev/random
) would mean that it does not, in fact, terminate.
In cases like this, there is no alternative to declaring the function partial
.
The type IO.FS.Stream
represents a POSIX stream.
Behind the scenes, it is represented as a structure that has one field for each POSIX stream operation.
Each operation is represented as an IO action that provides the corresponding operation:
structure Stream where
flush : IO Unit
read : USize → IO ByteArray
write : ByteArray → IO Unit
getLine : IO String
putStr : String → IO Unit
The Lean compiler contains IO
actions (such as IO.getStdout
, which is called in dump
) to get streams that represent standard input, standard output, and standard error.
These are IO
actions rather than ordinary definitions because Lean allows these standard POSIX streams to be replaced in a process, which makes it easier to do things like capturing the output from a program into a string by writing a custom IO.FS.Stream
.
The control flow in dump
is essentially a while
loop.
When dump
is called, if the stream has reached the end of the file, pure ()
terminates the function by returning the constructor for Unit
.
If the stream has not yet reached the end of the file, one block is read, and its contents are written to stdout
, after which dump
calls itself directly.
The recursive calls continue until stream.read
returns an empty byte array, which indicates that the end of the file has been reached.
When an if
expression occurs as a statement in a do
, as in dump
, each branch of the if
is implicitly provided with a do
.
In other words, the sequence of steps following the else
are treated as a sequence of IO
actions to be executed, just as if they had a do
at the beginning.
Names introduced with let
in the branches of the if
are visible only in their own branches, and are not in scope outside of the if
.
There is no danger of running out of stack space while calling dump
because the recursive call happens as the very last step in the function, and its result is returned directly rather than being manipulated or computed with.
This kind of recursion is called tail recursion, and it is described in more detail later in this book.
Because the compiled code does not need to retain any state, the Lean compiler can compile the recursive call to a jump.
If feline
only redirected standard input to standard output, then dump
would be sufficient.
However, it also needs to be able to open files that are provided as command-line arguments and emit their contents.
When its argument is the name of a file that exists, fileStream
returns a stream that reads the file's contents.
When the argument is not a file, fileStream
emits an error and returns none
.
def fileStream (filename : System.FilePath) : IO (Option IO.FS.Stream) := do
let fileExists ← filename.pathExists
if not fileExists then
let stderr ← IO.getStderr
stderr.putStrLn s!"File not found: {filename}"
pure none
else
let handle ← IO.FS.Handle.mk filename IO.FS.Mode.read
pure (some (IO.FS.Stream.ofHandle handle))
Opening a file as a stream takes two steps.
First, a file handle is created by opening the file in read mode.
A Lean file handle tracks an underlying file descriptor.
When there are no references to the file handle value, a finalizer closes the file descriptor.
Second, the file handle is given the same interface as a POSIX stream using IO.FS.Stream.ofHandle
, which fills each field of the Stream
structure with the corresponding IO
action that works on file handles.
Handling Input
The main loop of feline
is another tail-recursive function, called process
.
In order to return a non-zero exit code if any of the inputs could not be read, process
takes an argument exitCode
that represents the current exit code for the whole program.
Additionally, it takes a list of input files to be processed.
def process (exitCode : UInt32) (args : List String) : IO UInt32 := do
match args with
| [] => pure exitCode
| "-" :: args =>
let stdin ← IO.getStdin
dump stdin
process exitCode args
| filename :: args =>
let stream ← fileStream ⟨filename⟩
match stream with
| none =>
process 1 args
| some stream =>
dump stream
process exitCode args
Just as with if
, each branch of a match
that is used as a statement in a do
is implicitly provided with its own do
.
There are three possibilities.
One is that no more files remain to be processed, in which case process
returns the error code unchanged.
Another is that the specified filename is "-"
, in which case process
dumps the contents of the standard input and then processes the remaining filenames.
The final possibility is that an actual filename was specified.
In this case, fileStream
is used to attempt to open the file as a POSIX stream.
Its argument is encased in ⟨ ... ⟩
because a FilePath
is a single-field structure that contains a string.
If the file could not be opened, it is skipped, and the recursive call to process
sets the exit code to 1
.
If it could, then it is dumped, and the recursive call to process
leaves the exit code unchanged.
process
does not need to be marked partial
because it is structurally recursive.
Each recursive call is provided with the tail of the input list, and all Lean lists are finite.
Thus, process
does not introduce any non-termination.
Main
The final step is to write the main
action.
Unlike prior examples, main
in feline
is a function.
In Lean, main
can have one of three types:
main : IO Unit
corresponds to programs that cannot read their command-line arguments and always indicate success with an exit code of0
,main : IO UInt32
corresponds toint main(void)
in C, for programs without arguments that return exit codes, andmain : List String → IO UInt32
corresponds toint main(int argc, char **argv)
in C, for programs that take arguments and signal success or failure.
If no arguments were provided, feline
should read from standard input as if it were called with a single "-"
argument.
Otherwise, the arguments should be processed one after the other.
def main (args : List String) : IO UInt32 :=
match args with
| [] => process 0 ["-"]
| _ => process 0 args
Meow!
To check whether feline
works, the first step is to build it with lake build
.
First off, when called without arguments, it should emit what it receives from standard input.
Check that
echo "It works!" | ./build/bin/feline
emits It works!
.
Secondly, when called with files as arguments, it should print them.
If the file test1.txt
contains
It's time to find a warm spot
and test2.txt
contains
and curl up!
then the command
./build/bin/feline test1.txt test2.txt
should emit
It's time to find a warm spot
and curl up!
Finally, the -
argument should be handled appropriately.
echo "and purr" | ./build/bin/feline test1.txt - test2.txt
should yield
It's time to find a warm spot
and purr
and curl up!
Exercise
Extend feline
with support for usage information.
The extended version should accept a command-line argument --help
that causes documentation about the available command-line options to be written to standard output.
その他の便利機能
ネストされたアクション
feline
の関数の多くは IO
アクションの結果に名前を付けてすぐ一度だけ使うというパターンを繰り返していました。例えば、dump
の場合:
partial def dump (stream : IO.FS.Stream) : IO Unit := do
let buf ← stream.read bufsize
if buf.isEmpty then
pure ()
else
let stdout ← IO.getStdout
stdout.write buf
dump stream
stdout
のところでこのパターンが発生しています:
let stdout ← IO.getStdout
stdout.write buf
同様に、fileStream
も以下のコード片を含みます:
let fileExists ← filename.pathExists
if not fileExists then
Leanが do
ブロックをコンパイルする時、括弧のすぐ下にある左矢印からなる式は、それを包含する最も近い do
に持ち上げられ、その式の結果が一意な名前に束縛されます。この一意名でもともとの式が置き換えられます。つまり、dump
は次のように書くこともできます:
partial def dump (stream : IO.FS.Stream) : IO Unit := do
let buf ← stream.read bufsize
if buf.isEmpty then
pure ()
else
(← IO.getStdout).write buf
dump stream
このバージョンの dump
では、一度しか使わない名前の導入を避けることができるため、プログラムを大幅に簡略にすることができます。ネストされた式のコンテキストからLeanが持ち上げた IO
アクションは ネストされたアクション と呼ばれます。
fileStream
も同じテクニックを使って簡略にできます:
def fileStream (filename : System.FilePath) : IO (Option IO.FS.Stream) := do
if not (← filename.pathExists) then
(← IO.getStderr).putStrLn s!"File not found: {filename}"
pure none
else
let handle ← IO.FS.Handle.mk filename IO.FS.Mode.read
pure (some (IO.FS.Stream.ofHandle handle))
ここでは、handle
というローカル名をネストされたアクションを使って削除することもできますが、その場合は式が長く複雑になってしまいます。ネストされたアクションを使うことが良い場合が多いとはいえ、中間的な結果に名前を付けておくと便利なこともあります。
しかし、ネストされたアクションは do
ブロックの中で起こる IO
アクションの短縮表記に過ぎないことを覚えておくことが重要です。アクションの実行に伴う副作用は同じ順序で発生し、副作用の実行が式の評価と混在することはありません。これが混乱を招く可能性がある例として、実行されたことを世界に通知した後にデータを返す以下の補助的な定義を考えてみましょう:
def getNumA : IO Nat := do
(← IO.getStdout).putStrLn "A"
pure 5
def getNumB : IO Nat := do
(← IO.getStdout).putStrLn "B"
pure 7
これらの定義はユーザ入力を検証したり、データベースを読み込んだり、ファイルを開いたりするようなより複雑な IO
コードの代用を意図しています。
数字Aが5の時は 0
を、それ以外の時は B
の数値を表示するプログラムは次のように書けます:
def test : IO Unit := do
let a : Nat := if (← getNumA) == 5 then 0 else (← getNumB)
(← IO.getStdout).putStrLn s!"The answer is {a}"
しかし、このプログラムはおそらく意図した以上の副作用(ユーザ入力のプロンプトやデータベースの読み取りなど)を持ちます。getNumA
の定義では、常に 5
を返すことが明確になっているので、このプログラムではBの数値を読み込むべきではありません。しかし、プログラムを実行すると次のような出力が得られます:
A
B
The answer is 0
getNumB
was executed because test
is equivalent to this definition:
def test : IO Unit := do
let x ← getNumA
let y ← getNumB
let a : Nat := if x == 5 then 0 else y
(← IO.getStdout).putStrLn s!"The answer is {a}"
これはネストされたアクションは 最も近い do
ブロックに持ち上げられるルールによるものです。なぜならここでの文は a
を定義している let
であって if
は do
ブロックの中の文ではないため、この if
の分岐は暗黙的に do
ブロックで囲まれないのです。実際、条件式の型は IO Nat
ではなく Nat
であるため、このようにラップすることはできません。
do
の柔軟なレイアウト
Leanにおいて、do
式は空白に対して敏感です。do
の各 IO
アクションや局所的な束縛はそれぞれ独立した行で始まり、インデントも同じでなければなりません。ほとんどすべての do
はこのように書くべきです。しかしまれに空白やインデントを手動で制御する必要や、複数の小さなアクションを1行に書くと便利な場合があります。このような場合、改行はセミコロンに、インデントは波括弧に置き換えることができます。
例えば、以下のプログラムはすべての等価です:
-- This version uses only whitespace-sensitive layout
def main : IO Unit := do
let stdin ← IO.getStdin
let stdout ← IO.getStdout
stdout.putStrLn "How would you like to be addressed?"
let name := (← stdin.getLine).trim
stdout.putStrLn s!"Hello, {name}!"
-- This version is as explicit as possible
def main : IO Unit := do {
let stdin ← IO.getStdin;
let stdout ← IO.getStdout;
stdout.putStrLn "How would you like to be addressed?";
let name := (← stdin.getLine).trim;
stdout.putStrLn s!"Hello, {name}!"
}
-- This version uses a semicolon to put two actions on the same line
def main : IO Unit := do
let stdin ← IO.getStdin; let stdout ← IO.getStdout
stdout.putStrLn "How would you like to be addressed?"
let name := (← stdin.getLine).trim
stdout.putStrLn s!"Hello, {name}!"
慣用的なLeanの do
のコードでは波括弧を使うことはほとんどありません。
#eval
による IO
アクションの実行
Leanの #eval
コマンドは単に式の評価だけでなく、IO
アクションを実行するために使用することができます。通常、#eval
コマンドをLeanファイルに追加すると、Leanは指定された式を評価し、結果の値を文字列に変換して、その文字列をツールチップやinfoウィンドウに表示します。IO
アクションは文字列に変換できないからといって失敗させるのではなく、#eval
はそのアクションを実行し、その副作用を執り行います。実行結果が Unit
型の値 ()
の場合、結果の文字列は表示されませんが、文字列に変換できる型であればLeanは結果の値を表示します。
つまり、countdown
と runActions
の定義があらかじめ与えられているとした時:
#eval runActions (countdown 3)
は以下を出力します。
3
2
1
Blast off!
これは IO
アクションを実行することによって生成される出力であり、アクション自体の不透明な表現ではありません。言い換えれば、IO
アクションに対して #eval
は指定された式の 評価 と、結果のアクションの値の 実行 をどちらも行います。
#eval
を使って IO
アクションを素早くテストすることはプログラム全体をコンパイルして実行することよりもはるかに便利です。しかし、いくつかの制限があります。例えば、標準入力からの読み込みは単に空の入力を返すだけになります。さらに、IO
アクションはLeanがユーザに提供する新参情報を更新する必要があるたびに再実行され、これによって実行タイミングが予測できなくなります。例えば、ファイルを読み書きするアクションは、都合の悪い時に実行される可能性があります。
まとめ
評価 vs 実行
副作用とはファイルの読み込み・例外の発生・産業機械の起動など、数式の評価を超えたプログラムの実行についての側面です。ほとんどの言語では評価中に副作用が発生することを許可していますが、Leanでは許可していません。その代わりに、Leanには IO
という型があり、副作用を使用するプログラムの 記述 を表現します。これらの記述は言語のランタイムシステムによって実行されます。このランタイムシステムはLeanの式の評価器を呼び出して特定の計算を実行します。IO α
型の値は IO
アクション と呼ばれます。最も単純なものは pure
で、これは引数をそのまま返し、実際の副作用を持ちません。
IO
アクションは世界全体を引数として受け取り、副作用が発生した新しい世界を返す関数として理解することもできます。その裏では、IO
ライブラリは世界が決して複製されたり、新しく作られたり、破壊されたりしないことを保証しています。全宇宙は大きすぎてメモリに収まらないため、この副作用のモデルを実際に実装することはできませんが、現実の世界をプログラム中で受け渡されるトークンとして表現することができます。
IO
アクション main
はプログラムが開始された時に実行されます。main
は以下3つのうちどれかの型を持ちます:
main : IO Unit
はコマンドライン引数を読むことができず、常に終了コード0
を返す単純なプログラムに使われます。main : IO UInt32
は成功または失敗を知らせる引数のないプログラムに使われます。main : List String → IO UInt32
はコマンドライン引数を取り、成功または失敗を知らせるプログラムに使われます。
do
記法
Leanの標準ライブラリは、ファイルからの読み込みや書き込み、標準入出力とのやり取りなどの作用を表す基本的な IO
アクションを数多く提供しています。これらの基本的な IO
アクションは、副作用を持つプログラムを書くための組み込みドメイン固有言語である do
記法を使ってより大きな IO
アクションにまとめることができます。do
記法は以下のような一連の 文 を含んでいます:
IO
アクションを表す式let
と:=
を使った通常のローカル定義、定義された名前は渡された式の値を指します。let
と←
を使ったローカル定義、定義された名前は渡された式の値を実行した結果を指します。
do
で記述された IO
アクションは一度に1文ずつ実行されます。
さらに、do
の直下にある if
式と match
式は、暗黙的にそれぞれの分岐に do
があると見なされます。do
式の内部では、ネストされたアクション は括弧の直下にある左矢印で記述された式です。Leanコンパイラは、暗黙的にそれらを最も近い do
(match
や if
の分岐に暗黙的にあるものも含みます)に結び付け、一意な名前を付けます。この一意な名前によってネストされたアクションのもともとの位置を置き換えます。
プログラムのコンパイルと実行
lean --run FILE
というコマンドで main
定義を持つ単一ファイルで構成されるLeanプログラムを実行することができます。これは簡単なプログラムの実行には良い方法ですが、ほとんどのプログラムは最終的に複数のファイルを持つプロジェクトへと成長するので、実行する前にコンパイルする必要があります。
Leanのプロジェクトは、依存関係やビルド構成に関する情報とともにライブラリや実行可能ファイルのコレクションである パッケージ へと編成されます。パッケージはLeanのビルドツールであるLakeを使って記述します。新しいディレクトリにLakeパッケージを作成するには lake new
を、現在のディレクトリに作成するには lake init
を使用します。Lakeのパッケージ構成もドメイン固有言語です。プロジェクトをビルドするには lake build
を使用します。
部分性
式評価の数学的モデルに従うことから導かれる結論の1つとして、すべての式が値を持たなければならないということがあります。これにより、データ型のすべてのコンストラクタをカバーできない不完全なパターンマッチと無限ループに陥る可能性のあるプログラムの両方が除外されます。Leanはすべての match
式がすべてのケースをカバーすること、すべての再帰関数は構造的に再帰的であるか停止の明示的な証明があることを保証します。
しかし、実際のプログラムの中にはPOSIXのストリームのように無限ループする可能性のあるデータを扱うために、無限ループの可能性を必要とするものがあります。これに対してLeanは逃げ道を用意しています:定義が partial
とマークされた関数は停止する必要がありません。これには代償が伴います。型はLean言語の第一級であるため、関数は型を返すことができます。しかし、関数が無限ループに入ると型チェッカが無限ループも入る可能性があるため、部分関数は型チェック中に評価されません。さらに、数学的な証明では部分関数の定義を検査することができないため、部分関数を使用するプログラムは形式的な証明にあまり適していません。
休憩:命題・証明・リストの添え字アクセス
多くの言語と同様に、Leanは角括弧を配列とリストへの添え字アクセスに使います。例えば、woodlandCitters
が以下のように定義されているとします:
def woodlandCritters : List String :=
["hedgehog", "deer", "snail"]
ここで、各要素は次のようにして展開することができます:
def hedgehog := woodlandCritters[0]
def deer := woodlandCritters[1]
def snail := woodlandCritters[2]
しかし、4番目の要素を取り出そうとすると、実行時エラーではなくコンパイル時エラーとなります:
def oops := woodlandCritters[3]
failed to prove index is valid, possible solutions:
- Use `have`-expressions to prove the index is valid
- Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
- Use `a[i]?` notation instead, result is an `Option` type
- Use `a[i]'h` notation instead, where `h` is a proof that index is valid
⊢ 3 < List.length woodlandCritters
このエラーメッセージは、Leanが 3 < List.length woodlandCritters
を自動で数学的に証明しようとして(これはルックアップが安全であることを意味します)、それができなかったというものです。リストの範囲外エラーはごくありふれたバグであり、Leanはプログラミング言語と定理証明器の2つの性質を利用して、できるだけ多くのエラーを除外します。
この仕組みを理解するには、命題・証明・タクティクという3つの重要な考え方を理解する必要があります。
命題と証明
命題 (proposition)とは、真にも偽にもなりうる文のことです。以下はすべて命題です:
- 1 + 1 = 2
- 足し算は可換である
- 素数は無限に存在する
- 1 + 1 = 15
- パリはフランスの首都である
- ブエノスアイレスは韓国の首都である
- すべての鳥は飛ぶことができる
一方で、無意味な文は命題ではありません。以下はどれも命題ではありません:
- 1 + 緑 = アイスクリーム
- すべての首都は素数である
- 少なくとも1つのホゲはフガである
命題は2つに分類されます:純粋に数学的なもので、私たちの概念の定義にのみ依存するものと、世界に関する事実であるものです。Leanのような定理証明器は前者のカテゴリを扱っており、ペンギンの飛行能力や都市の法的地位について語ることはありません。
証明 (proof)とは、ある命題が真であることを説得させる論証のことです。数学的命題の場合、これらの論証はそれに関連する概念の定義と論理的論証の規則を利用します。ほとんどの証明は人々が理解できるように書かれており、多くの面倒な詳細は省かれています。Leanのような計算機による定理証明支援器は、数学者が多くの詳細を省きながら証明を書けるように設計されており、欠落している明白なステップを埋めるのはソフトウェアの責任です。これにより見落としや間違いの可能性を減らすことができます。
Leanでは、プログラムの型はそのプログラムがほかのプログラムとどのように相互作用できるかを記述したものです。例えば、Nat → List String
という型のプログラムは、引数 Nat
を受け取り、文字列のリストを生成する関数です。言い換えると、それぞれの型はその型を持つプログラムとしての大事なポイントを指定したものです。
実はLeanにおいて、命題は型です。命題はその文が真であることの根拠としての大事なポイントを指定します。この根拠を提示することで、命題が証明されます。一方で、命題が偽であれば、証明を構築することは不可能です。
例えば、「1 + 1 = 2」という命題はLeanでそのまま記述することができます。この命題の根拠はコンストラクタ rfl
で、この名称は 反射性 (reflexivity)の略です:
def onePlusOneIsTwo : 1 + 1 = 2 := rfl
一方で、rfl
は偽の命題「1 + 1 = 15」を証明しません:
def onePlusOneIsFifteen : 1 + 1 = 15 := rfl
type mismatch
rfl
has type
1 + 1 = 1 + 1 : Prop
but is expected to have type
1 + 1 = 15 : Prop
このエラーメッセージから、rfl
は等号についての文の両辺がすでに同じ数値である場合に2つの式が等しいことを証明できることがわかります。1 + 1
は 2
に直接評価されるので両者は同じとみなされ、 onePlusOneIsTwo
がLeanに受け入れられます。Type
が Nat
や String
、List (Nat × String × (Int → Float))
などのデータ構造や関数を表す型を記述するように、Prop
は命題を記述します。
証明されると命題は 定理 (theorem)と呼ばれます。Leanでは定理を宣言するときには def
の代わりに theorem
キーワードを使うのが一般的です。これにより、読者はどの宣言が数学的な証明であり、どの宣言が定義であるかがわかりやすくなります。一般論として、証明で重要なことは命題が真であるという根拠が存在するということであり、 どの 根拠が提示されたかということは特に重要ではありません。一方で、定義の場合、どの特定の値が選択されるかということは非常に重要です。なにしろ、足し算の定義として常に 0
を返すものは明らかに間違っているからです。
前者の例は以下のように書くこともできます:
def OnePlusOneIsTwo : Prop := 1 + 1 = 2
theorem onePlusOneIsTwo : OnePlusOneIsTwo := rfl
タクティク
Leanにおける証明は通常、根拠を直接示すのではなく タクティク (tactic)を用いて記述されます。タクティクは命題の根拠を構築する小さなプログラムのことです。これらのプログラムは証明したい文( ゴール (goal)とよばれます)と利用可能な仮定を用いて証明していく過程である 証明状態 (proof state)の中で実行されます。ゴールに対してタクティクを実行すると、新しいゴールを含む新しい証明状態が生まれます。すべてのゴールが証明されたとき、証明が完了します。
タクティクを使って証明を書くには、定義を by
で始めます。by
と書くことでこれに続くインデントされたブロックが終わるところまでLeanがタクティクモードになります。タクティクモードでは、Leanは現在の証明状態について継続的なフィードバックを提供します。タクティクを用いた onePlusOneIsTwo
もかなり短いものになります:
theorem onePlusOneIsTwo : 1 + 1 = 2 := by
simp
simp
タクティクは「簡約(simplify)」の略で、Leanでの証明の主戦力です。これはゴールをできるだけ単純な形に書き直し、またこの工程の記述を十分小さいものにしてくれます。特に、単純な等号についての文を証明します。その裏では、詳細な形式的証明が構築されますが、simp
を使うことでこの複雑さを隠すことができます。
タクティクは多くの理由から便利です:
- 多くの証明は細部に至るまで書き出すと複雑で面倒になりますが、タクティクはこうした面白くない部分を自動化してくれます。
- タクティクで書かれた証明は柔軟な自動化によって定義の小さな変更を吸収することができるため、長期にわたるメンテナンスが容易です。
- 1つのタクティクで多くの異なる定理を証明することができるため、Leanが裏でタクティクを使うことでユーザが手で証明を書く手間を省くことができます。例えば、配列のルックアップにはインデックスが添え字の上限内にあることの証明が必要ですが、タクティクは通常ユーザの気を煩わすことなくその証明を構築することができます。
裏側では、添え字アクセスの表記はユーザのルックアップ操作が安全であることを証明するためにタクティクを使用します。このタクティクは simp
であり、ある種の算術的同一性を考慮するように設定されています。
論理結合子
論理の基本的な構成要素である「かつ」・「または」・「真」・「偽」・「~ではない」は 論理結合子 (logical connectives)と呼ばれます。各結合子は、何がその真理の根拠となるかを定義します。例えば、「 A かつ B 」という文を証明するには、 A と B の両方を証明しなければなりません。つまり、「 A かつ B 」の根拠とは、 A の根拠と B の根拠の両方を含むペアのことです。同様に、「 A または B 」の根拠は、 A の根拠と B の根拠のどちらか一方からなります。
特に、これらの結合子のほとんどはデータ型のように定義され、コンストラクタを持ちます。 A と B が命題である場合、「 A
かつ B
」( A ∧ B
と書かれます)は命題です。A ∧ B
の根拠はコンストラクタ And.intro
で構成され、これは A → B → A ∧ B
という型を持ちます。A
と B
を具体的な命題に置き換えれば、1 + 1 = 2 ∧ "Str".append "ing" = "String"
を And.intro rfl rfl
で証明することができます。もちろん、simp
はやはり十分強力であるので以下のように証明してくれます:
theorem addAndAppend : 1 + 1 = 2 ∧ "Str".append "ing" = "String" := by simp
同様に、「 A
または B
」( A ∨ B
と書かれます)には2つのコンストラクタがあります。なぜなら、「 A
または B
」の証明には、2つの命題のうちどちらか1つが真であることしか要求していないからです。このコンストラクタは A → A ∨ B
型の Or.inl
と、B → A ∨ B
型の Or.inr
の2つです。
含意(もし A ならば B である)は関数を用いて表現されます。特に、 A の根拠を B の根拠に変換する関数は、それ自体が A ならば B の根拠となります。また、A → B
が ¬A ∨ B
の省略形です。これは通常の含意の記述とは異なりますが、2つの定式化は等価です。
「かつ」の根拠はコンストラクタであるため、パターンマッチに使用することができます。例えば、「 A かつ B ならば A または B 」の証明は、 A かつ B の根拠から A (もしくは B )の根拠を取り出し、これを使って A または B の根拠を生成する関数です:
theorem andImpliesOr : A ∧ B → A ∨ B :=
fun andEvidence =>
match andEvidence with
| And.intro a b => Or.inl a
論理結合子 | Leanの記法 | 根拠 |
---|---|---|
真 | True | True.intro : True |
偽 | False | 根拠なし |
A かつ B | A ∧ B | And.intro : A → B → A ∧ B |
A または B | A ∨ B | Or.inl : A → A ∨ B もしくは Or.inr : B → A ∨ B |
A ならば B | A → B | A の根拠を B の根拠に変換する関数 |
A ではない | ¬A | A の根拠を False の根拠に変換する関数 |
simp
タクティクはこれらの結合子を使った定理を証明することができます。例えば以下のように使うことができます:
theorem onePlusOneAndLessThan : 1 + 1 = 2 ∨ 3 < 5 := by simp
theorem notTwoEqualFive : ¬(1 + 1 = 5) := by simp
theorem trueIsTrue : True := True.intro
theorem trueOrFalse : True ∨ False := by simp
theorem falseImpliesTrue : False → True := by simp
引数に現れる根拠
simp
はある種の数の等式や不等式を含む命題の証明は得意ですが、変数を含む文の証明は苦手です。例えば、simp
は 4 < 15
を証明できますが、x < 4
であるからといって x < 15
も真であるということは simp
にとっては簡単にはわかりません。インデックス記法は、配列へのアクセスが安全であることを証明するために裏で simp
を使っているので、simp
にちょっと手助けする必要があります。
インデックス記法をうまく機能させる最も簡単な方法のひとつは、データ構造へのルックアップを実行する関数に、必要な安全性の根拠を引数として取らせることです。例えば、リストの3番目の要素を返す関数は一般的には安全とは言えません。なぜなら要素の数が0、1または2個であるかもしれないからです:
def third (xs : List α) : α := xs[2]
failed to prove index is valid, possible solutions:
- Use `have`-expressions to prove the index is valid
- Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
- Use `a[i]?` notation instead, result is an `Option` type
- Use `a[i]'h` notation instead, where `h` is a proof that index is valid
α : Type ?u.3908
xs : List α
⊢ 2 < List.length xs
しかし、インデックス操作が安全であるという証拠からなる引数を追加することで、リストが少なくとも3つの要素を持たなければならないという制約を呼び出し側に課すことができます:
def third (xs : List α) (ok : xs.length > 2) : α := xs[2]
この例では、xs.length > 2
は xs
が2つ以上の要素を 持つかどうか をチェックするプログラムではありません。これは真でも偽でもありうる命題であり、引数 ok
はそれが真であることの証拠でなければなりません。
この関数が具体的なリストで呼ばれた場合、その長さはその時点で既知です。この場合、by simp
は自動的にエビデンスを構築することができます:
#eval third woodlandCritters (by simp)
"snail"
根拠なしの添え字アクセス
インデックス操作がリストの範囲内であることを証明することが現実的でない場合には、ほかの方法があります。はてなマークを付けると戻り値の型が Option
となり、インデックスが範囲内にあれば some
、そうでなければ none
となります。例えば以下のようにふるまいます:
def thirdOption (xs : List α) : Option α := xs[2]?
#eval thirdOption woodlandCritters
some "snail"
#eval thirdOption ["only", "two"]
none
また、インデックスが範囲外になったときに Option
を返すのではなく、プログラムをクラッシュさせるバージョンも存在します:
#eval woodlandCritters[1]!
"deer"
注意!#eval
で実行されるコードはLeanのコンパイラのコンテキストで実行されるため、間違ったインデックスを選択するとIDEがクラッシュする可能性があります。
見るかもしれないメッセージ
Leanがインデックス操作の安全性の根拠をコンパイル時に見つけることができない場合に発生するエラーに加えて、安全でないインデックス操作を使用する多相関数は次のようなメッセージを生成することがあります:
def unsafeThird (xs : List α) : α := xs[2]!
failed to synthesize instance
Inhabited α
これは、Leanを定理を証明するための論理としてもプログラミング言語としても使えるようにするための技術的な制限によるものです。特に、少なくとも1つは値を持つような型のプログラムだけがクラッシュすることが許可されています。これは、Leanにおける命題がその真理の根拠を分類する型の一種であるためです。偽の命題にはこのような根拠はありません。もし空である型を持つプログラムがクラッシュするとしたら、そのクラッシュしたプログラムは偽の命題のための偽の根拠の一種として使われている可能性があります。
内部的には、Leanは少なくとも1つは値を持つことが知られている型のテーブルを保持しています。このエラーはある任意の型 α
がその表にあるとは限らないと言っているのです。この表に追加する方法と、unsafeThird
のような関数をうまく書く方法については次の章で説明します。
リストのルックアップに使われる括弧の間にスペースを入れると、別のメッセージが表示されることがあります。
#eval woodlandCritters [1]
function expected at
woodlandCritters
term has type
List String
スペースを追加すると、Leanは式を関数適用として扱い、インデックスを1つの数値からなるリストとして扱います。このエラーメッセージは、Leanが woodlandCritters
を関数として扱おうとした結果です。
演習問題
- 次の定理を
rfl
を使って証明してください。また5 < 18
に適用したら何が起きるでしょうか?そしてそれは何故でしょうか?2 + 3 = 5
15 - 8 = 7
"Hello, ".append "world" = "Hello, world"
- 次の定理を
by simp
で証明してください。2 + 3 = 5
15 - 8 = 7
"Hello, ".append "world" = "Hello, world"
5 < 18
- リストの5番目の要素をルックアップする関数を書いてください。この関数の引数にはルックアップが安全であるという根拠を渡すようにしてください。
オーバーロードと型クラス
多くの言語では、組み込みのデータ型は特別な扱いをうけます。例えば、CやJavaでは +
を float
や int
の足し算に利用できますが、サードパーティライブラリの任意精度の数値の足し算に使うことはできません。同様に、数値リテラルは組み込み型には直接使用できますが、ユーザ定義の数値型には使用できません。他の言語では、演算子の オーバーロード (overload)機構が用意されており、同じ演算子に新しい型の意味を持たせることができます。C++やC#を含むこの手の言語では、多種多様な組み込み演算子オーバーロードすることができ、コンパイラは型チェッカを使ってどの型に対しての特定の実装であるかを選択します。
数値リテラルや演算子に加えて、多くの言語では関数やメソッドのオーバーロードが認められています。C++やJava、C#、Kotlinでは1つのメソッドについて引数の数や型が異なる複数の実装が認められています。コンパイラは引数の数と型を使用して、どのオーバーロードが意図されたかを判断します。
関数や演算子のオーバーロードには重要な制限があります:多相関数は受け取る型引数について、オーバーロードが存在する型を指定して限定することができません。例えば、文字列・バイト文字列・ファイルポインタに対してオーバーロードされたメソッドが定義されている場合、これら3つのメソッドのどれに対しても機能するような単一のメソッドを書くことができません。その代わりに、この別のメソッドは元のメソッドのオーバーロードを持つ型ごとにいちいちオーバーロードされなければならず、結果として多くの同じような定義が生まれ、単一の多相な定義にすることができません。この制限はさらに、(Javaの等号のような)いくつかの演算子についてどう考えても要らないようなケースも含めて すべての 引数の組み合わせに対して定義されてしまうという結果をも引き起こします。そのためプログラマの注意が不十分だと、実行時にクラッシュしたり、黙って不正な計算をするプログラムが出来上がってしまう可能性があります。
LeanはHaskellで先駆的に開発された 型クラス (type class)と呼ばれる機構を使用してオーバーロードを実装しています。これによって演算子・関数・リテラルを多相性とうまく連動させてオーバーロードを実現しています。型クラスはオーバーロード可能な演算の集まりを記述したものです。新しい型に対してこれらの演算をオーバーロードするには、新しい型に対する各演算の実装を含んだ インスタンス (instance)を作成します。例えば、Add
という名前の型クラスは足し算ができる型を記述しており、Nat
に対する Add
のインスタンスは Nat
に対する足し算の実装を提供します。
クラス と インスタンス という用語はオブジェクト指向言語で言うところのクラスとインスタンスとはあまり関連していないため、オブジェクト指向言語に慣れている人にとっては混乱を招くかもしれません。ただ、両者は日常言語における「クラス」という用語のいくつかの共通の属性を持つグループという意味をルーツとしている点においては共通しています。オブジェクト指向プログラミングにおけるクラスでも共通の属性を持つオブジェクトの集まりを意味しますが、この用語はさらに、そのような集まりを記述するためのプログラミング言語の特定の機構を指します。型クラスもまた、共通の属性を持つ型(すなわち、その型にまつわる演算の実装)を記述する道具ですが、オブジェクト指向プログラミングで見られるクラスとは、それ以外の共通点はありません。
Leanの型クラスは、JavaやC#の インタフェース (interface)によく似ています。型クラスもインタフェースも、型や型のあつまりに対して実装される、概念的には同じような演算の集合を記述します。同様に、型クラスのインスタンスは、JavaやC#のクラスのインスタンスではなく、インタフェースを継承したクラスによって記述されるコードに似ています。JavaやC#のインタフェースとは異なり、何かしらの型の作者がある型クラスを触れないケースでも、その型に型クラスのインスタンスを与えることができます。この点で、型クラスはRustのtraitとよく似ています。
正の整数
数値を扱うプログラムの中には、正の整数だけが意味を持つものもあります。例えば、コンパイラとインタプリタは通常、ソースコードの位置に対して1つの行番号と列番号によるインデックスを使用します。また、空でないリストのみを表すデータ型では、長さが0であると言ってくることはありません。このようなケースに対して自然数に依存して数値が0でないことをいちいちチェックしてコードを汚くしてしまうよりも、正の数だけを表すデータ型を設計する方が便利です。
正の数を表現する1つの方法は、0
の代わりに 1
を基本とすることを除けば、Nat
によく似ています:
inductive Pos : Type where
| one : Pos
| succ : Pos → Pos
このデータ型で意図した値の集合を正しく表すことができますが、使い勝手はあまりよくありません。例えば、数値リテラルを使おうとするとエラーになります:
def seven : Pos := 7
failed to synthesize instance
OfNat Pos 7
上記の代わりにコンストラクタを直接使わなければなりません:
def seven : Pos :=
Pos.succ (Pos.succ (Pos.succ (Pos.succ (Pos.succ (Pos.succ Pos.one)))))
同じように、足し算と掛け算も簡単には使えません:
def fourteen : Pos := seven + seven
failed to synthesize instance
HAdd Pos Pos ?m.291
def fortyNine : Pos := seven * seven
failed to synthesize instance
HMul Pos Pos ?m.291
これらのエラーメッセージはどれも failed to synthesize instance
から始まっています。これは実装されていないオーバーロードされた操作によるエラーであることを示し、実装しなければならない型クラスが記述されています。
クラスとインスタンス
型クラスの構成要素は名前、いくつかのパラメータ、そして メソッド (method)の集まりです。パラメータはオーバーロード可能な演算の対象となる型を、メソッドはオーバーロード可能な演算の名前と型シグネチャを表します。ここでもまたオブジェクト指向言語との用語の衝突があります。オブジェクト指向プログラミングでは、メソッドは基本的にメモリ上の特定のオブジェクトに接続され、そのオブジェクトのプライベート状態に特別にアクセスできる関数のことです。オブジェクトの操作はこうしたメソッドを通じて行われます。一方Leanでは、「メソッド」という用語はオーバーロード可能なものとして宣言された演算を指します。そこにはオブジェクトや値、プライベートのフィールドへの特別な接続は関係しません。
足し算をオーバーロードする1つの方法は、足し算メソッド plus
を備えた Plus
という型クラスを定義することです。ひとたび Nat
に対する Plus
のインスタンスが定義されると、 Plus.plus
を使って2つの Nat
を足すことができるようになります:
#eval Plus.plus 5 3
8
他の型についてもインスタンスを追加していくことたびに、Plus.plus
の引数に取れる型が増えていきます。
以下の型クラスの宣言では、Plua
がクラス名、α : Type
が唯一の引数、plus : α → α → α
が唯一のメソッドです:
class Plus (α : Type) where
plus : α → α → α
この宣言は、型クラス Plus
が存在し、型 α
に対して演算をオーバーロードすることを意味しています。特に、2つの α
を受け取って α
を返す plus
という演算が1つだけ存在します。
型が第一級であるように、型クラスも第一級です。特に、型クラスは別の種類の型です。Plus
の型は Type → Type
です。なぜなら、これは型( α
)を引数に取り、α
に対する Plus
の演算のオーバーロードを記述する新しい型を生成するからです。
plus
を特定の型にオーバーロードするには以下のようにインスタンスを書く必要があります:
instance : Plus Nat where
plus := Nat.add
instance
の後のコロンは、Plus Nat
が実際に型であることを示しています。クラス Plus
の各メソッドには :=
を使って値を代入します。今回の場合、メソッドは plus
だけです。
デフォルトでは、各クラスのメソッドは型クラスと同じ名前の名前空間に定義されます。名前空間を open
することで、利用者がメソッドの前にクラス名を入力する必要がなくなるため便利です。open
コマンドの中で使われている括弧は、名前空間から指定された名前にのみアクセスできるようにすることを示します:
open Plus (plus)
#eval plus 5 3
8
Pos
の足し算の関数と Plus Pos
のインスタンスを定義することで、 plus
を使って Nat
の値に対してだけでなく、Pos
の値に対しても足し算をすることができます:
def Pos.plus : Pos → Pos → Pos
| Pos.one, k => Pos.succ k
| Pos.succ n, k => Pos.succ (n.plus k)
instance : Plus Pos where
plus := Pos.plus
def fourteen : Pos := plus seven seven
ここでまだ Plus float
のインスタンスがないので、 plus
を使って2つの浮動小数点数を足そうとするとおなじみのメッセージが出て失敗します:
#eval plus 5.2 917.25861
failed to synthesize instance
Plus Float
これらのエラーはLeanが指定された型クラスのインスタンスを見つけられなかったことを意味します。
オーバーロードされた足し算
Leanの組み込みの足し算の演算子は、HAdd
と呼ばれる型クラスの糖衣構文であり、加算の2つの引き数が異なる型を持つことを柔軟に許可しています。HAdd
は heterogeneous addition の略です。例えば、HAdd
のインスタンスは Float
に Nat
を加えて新しい Float
の値を出力できるように定義されています。プログラマが x + y
と書くと、 HAdd.hAdd x y
という意味に解釈されます。
HAdd
の汎用性の全貌の理解は この章の別の節 で説明する機能を学んでからになりますが、そこまで要求せずに、引数の型を混在させない Add
というより単純な型クラスも存在します。Leanのライブラリは両方の引数が同じ型である HAdd
のインスタンスを検索した時に Add
のインスタンスが選ばれるように設定されています。
Add Pos
のインスタンスを定義することで、Pos
の値を通常の足し算の記法で使うことができるようになります:
instance : Add Pos where
add := Pos.plus
def fourteen : Pos := seven + seven
文字列への変換
便利な組み込みのクラスとして他には ToString
というものがあります。ToString
のインスタンスは、与えられた型の値を文字列に変換する標準的な方法を提供します。例えば、ToString
のインスタンスは文字列中に値を内挿する場合に使用され、 IO
の説明の最初 で使用されている IO.println
関数が値をどのように表示するかを決定します。
Pos
を String
に変換する方法の一例として、その内部構造を明らかにするというものがあります。以下の関数 posToString
は Pos.succ
の使用を括弧で囲むかどうかを決定する Bool
を受け取っています。
def posToString (atTop : Bool) (p : Pos) : String :=
let paren s := if atTop then s else "(" ++ s ++ ")"
match p with
| Pos.one => "Pos.one"
| Pos.succ n => paren s!"Pos.succ {posToString false n}"
この関数を ToString
のインスタンスに使うと以下のようになります:
instance : ToString Pos where
toString := posToString true
こうすることでいささか過剰ですが、有益な出力が得られます:
#eval s!"There are {seven}"
"There are Pos.succ (Pos.succ (Pos.succ (Pos.succ (Pos.succ (Pos.succ Pos.one)))))"
一方で、すべての正の整数には対応する Nat
があります。これを Nat
に変換し、ToString Nat
インスタンス(つまり、Nat
に対する toString
のオーバーロード)を使用することで、より短い出力を生成することができます:
def Pos.toNat : Pos → Nat
| Pos.one => 1
| Pos.succ n => n.toNat + 1
instance : ToString Pos where
toString x := toString (x.toNat)
#eval s!"There are {seven}"
"There are 7"
複数のインスタンスが定義されている場合、一番最後に定義したものが優先されます。さらに、ある型が ToString
インスタンスを持っている場合、その型が deriving Repr
で定義されていなくても、その型の結果を #eval
で表示するために ToString
が使用されるため、#eval seven
は 7
を出力します。
オーバーロードされた掛け算
掛け算について、HMul
という型クラスがあり、HAdd
と同じように引数の型を混ぜることができます。ちょうど x + y
が HAdd.hAdd x y
と解釈されるように、x * y
は HMul.hMul x y
と解釈されます。同じ型を持つ2つの引数の掛け算で一般的なケースでは Mul
インスタンスで十分です。
Mul
のインスタンスを定義することで通常の掛け算の構文を Pos
に対して使うことができます:
def Pos.mul : Pos → Pos → Pos
| Pos.one, k => k
| Pos.succ n, k => n.mul k + k
instance : Mul Pos where
mul := Pos.mul
インスタンスのおかげで、掛け算は意図通りに実行されます:
#eval [seven * Pos.one,
seven * seven,
Pos.succ Pos.one * seven]
[7, 49, 14]
数値リテラル
Pos
の値を宣言するにあたり、コンストラクタの列をずらずら書き出すのはかなり不便です。この問題を回避する一つの方法は、Nat
を Pos
に変換する関数を提供することでしょう。しかし、この方法には欠点があります。まず、Pos
は 0
を表すことができないため、結果として関数は Nat
を大きい値に変換するか、戻り値の型を Option Pos
となります。どちらもユーザにとってあまり使いやすいものではありません。第二に、関数を明示的に呼び出す必要があるため、正の整数を使用するプログラムは Nat
を使用するプログラムよりもはるかに書きにくくなります。正確な型と便利なAPIの間にトレードオフがあるということは、その型が有用でなくなることを意味します。
Leanでは、自然数のリテラルは OfNat
という型クラスを使って解釈されます:
class OfNat (α : Type) (_ : Nat) where
ofNat : α
この型クラスは2つの引数を取ります: α
は自然数をオーバーロードする型であり、無名の Nat
引数は実際にプログラムで利用する際に遭遇する数値リテラルです。そして ofNat
メソッドが数値リテラルの値として使用されます。このクラスには Nat
引数が含まれるため、その数値が対象の型において意味を持つものに対してのインスタンスのみを定義することが可能になります。
OfNat
は型クラスの引数が型である必要がないことの一例です。Leanの型は関数の引数として渡したり、def
や abbrev
で定義を与えることができる第一級としての言語の構成員であるため、柔軟性の低い言語では許されないような位置で型以外の引数を妨げるような障壁はありません。この柔軟性によって、特定の型だけでなく、特定の値に対してもオーバーロードされた演算を提供することができます。
例えば、4未満の自然数を表す直和型は次のように定義できます:
inductive LT4 where
| zero
| one
| two
| three
deriving Repr
この型に対して どんな 数値リテラルでも使えるようにするのは意味がありませんが、4未満の数に限れば明らかに意味があります:
instance : OfNat LT4 0 where
ofNat := LT4.zero
instance : OfNat LT4 1 where
ofNat := LT4.one
instance : OfNat LT4 2 where
ofNat := LT4.two
instance : OfNat LT4 3 where
ofNat := LT4.three
このインスタンスによって、以下の例が機能します:
#eval (3 : LT4)
LT4.three
#eval (0 : LT4)
LT4.zero
一方で範囲外のリテラルはちゃんと不許可となります:
#eval (4 : LT4)
failed to synthesize instance
OfNat LT4 4
Pos
の場合、OfNat
インスタンスは Nat.zero
以外の すべての Nat
に対して動作する必要があります。別の言い方をすると、すべての自然数 n
に対して、インスタンスは n + 1
に対して動作する必要があります。α
のような名前が自動的に関数の暗黙の引数になり、Leanがそれを埋めてくれるように、インスタンスも自動的に暗黙の引数を取ることができます。このインスタンスでは、引数 n
は任意の Nat
を表し、インスタンスは1つ大きい Nat
に対して定義されます:
instance : OfNat Pos (n + 1) where
ofNat :=
let rec natPlusOne : Nat → Pos
| 0 => Pos.one
| k + 1 => Pos.succ (natPlusOne k)
natPlusOne n
n
は n + 1
でパターンマッチされることでユーザが書いた値より1小さい Nat
を表すので、補助関数 natPlusOne
は引数より1大きい Pos
を返します。これにより、正の整数には自然数のリテラルを使用できますが、0には使用できません。
def eight : Pos := 8
def zero : Pos := 0
failed to synthesize instance
OfNat Pos 0
演習問題
別の表現
正の整数を表す別の方法として、Nat
の対応する値の次の値を表すというものもあります。Pos
の定義を Nat
を含む succ
という名前のコンストラクタを持つ構造体に置き換えると以下のようになります:
structure Pos where
succ ::
pred : Nat
このバージョンの Pos
を便利にするために Add
・Mul
・ToString
・OfNat
を定義してください。
偶数
偶数のみを表すデータ型を定義してください。またそれを便利に使えるように Add
・Mul
・ToString
を定義してください。OfNat
は次の節で紹介する機能を必要とします。
HTTPリクエスト
HTTPリクエストはURIとHTTPバージョンとともに、GET
や POST
などのHTTPメソッドの識別子から始まります。HTTPメソッドのうち興味深いサブセットについてそれを表す帰納型と、HTTPレスポンスを表す構造体を定義してください。レスポンスには ToString
インスタンスを持たせ、デバッグできるようにしてください。型クラスを使用して各HTTPメソッドに異なる IO
アクションを関連付け、IO
アクションで各メソッドを呼び出して結果を表示するテストハーネスを書いてください。
型クラスと多相性
与えられた関数の 任意の オーバーロードにおいて動作するような関数を書くと便利です。例えば、IO.println
は ToString
のインスタンスを持つすべての型に対して動作します。これを実現するには対象のインスタンスを角括弧で囲むことが必要とされ、実際に IO.println
の型は {α : Type} → [ToString α] → α → IO Unit
となっています。この型は IO.println
が受け取る α
型の引数をLeanによって自動的に決定され、α
に対して利用可能な ToString
インスタンスがなければならないことを表しています。この関数は IO
アクションを返します。
多相関数の型のチェック
暗黙の引数を取る関数や型クラスを使用する関数の型をチェックするにはいくつか追加の構文を利用する必要があります。単純に以下のように書くと
#check (IO.println)
メタ変数を伴った以下の型を出力します:
IO.println : ?m.3620 → IO Unit
Leanは暗黙の引数の発見に最善を尽くしますが、それでもメタ変数が存在するということから、暗黙の引数発見のために十分な型情報をまだ発見していないことを示しています。関数シグネチャを理解するために、関数名の前にアットマーク( @
)を付けてこの機能を抑制することができます:
#check @IO.println
@IO.println : {α : Type u_1} → [inst : ToString α] → α → IO Unit
この出力では、インスタンス自体に inst
という名前が与えられています。さらに、Type
の後に u_1
が続いていますが、これはまだ紹介していないLeanの機能を使用しています。今時点ではこれらの Type
へのパラメータは無視してください。
暗黙のインスタンスを取る多相関数の定義
リストの要素をすべて足し合わせる関数は2つのインスタンスを必要とします:Add
は要素を足すためのもので、0
に対する OfNat
インスタンスは空のリストに対しての戻り値です:
def List.sum [Add α] [OfNat α 0] : List α → α
| [] => 0
| x :: xs => x + xs.sum
この関数は Nat
のリストに対して使うことができます:
def fourNats : List Nat := [1, 2, 3, 4]
#eval fourNats.sum
10
しかし Pos
の数値のリストに対しては使えません:
def fourPos : List Pos := [1, 2, 3, 4]
#eval fourPos.sum
failed to synthesize instance
OfNat Pos 0
角括弧で囲まれた必須なインスタンスの指定は 暗黙のインスタンス (instance implicit)と呼ばれます。裏側では、すべての型クラスはオーバーロードされた演算ごとのフィールドを持つ構造体として定義されています。インスタンスはその構造体の値であり、各フィールドには具体的な実装が含まれています。呼び出し先では、Leanが各暗黙引数のインスタンスに渡す値を見つける責任を負います。通常の暗黙引数と暗黙のインスタンス引数の最も重要な違いは、Leanが引数の値を見つけるために使用する戦略です。通常の暗黙引数の場合、Leanは ユニフィケーション (unification)と呼ばれるテクニックを使って、プログラムが型チェッカをパスできるような一意の引数の値を見つけます。このプロセスは関数の定義と呼び出しにかかわる特定の型にのみ依存します。暗黙のインスタンスの場合、Leanはこの代わりにインスタンスの値についての組み込みテーブルを参照します。
Pos
に対する OfNat
のインスタンスが自然数 n
を自動的な暗黙の引数として取っていたように、インスタンスもインスタンス自身の暗黙の引数を取ることができます。「多相性」節 では、多相なポイント型を紹介しました:
structure PPoint (α : Type) where
x : α
y : α
deriving Repr
点の足し算を考える際には、その中にある x
フィールドと y
フィールド同士を足し算する必要があります。したがって、PPoint
の Add
インスタンスには、これらのフィールドの型がどういうものであってもそれ自体にも Add
インスタンスが必要になります。言い換えると、PPoint
の Add
インスタンスには、さらに α
の Add
インスタンスが必要になります:
instance [Add α] : Add (PPoint α) where
add p1 p2 := { x := p1.x + p2.x, y := p1.y + p2.y }
Leanが2つの点の足し算に遭遇すると、まずこのインスタンスを検索して見つけます。そして、Add α
インスタンスをさらに検索します。
このようにして構築されるインスタンスの値は、型クラスの構造体型としての値です。再帰的なインスタンスの探索に成功すると、さらに別の構造体の値への参照を持つ構造体の値が得られます。Add (PPoint Nat)
のインスタンスはこの過程で見つかった Add Nat
のインスタンスへの参照を持ちます。
この再帰的な探索プロセスは、型クラスが単なるオーバーロードされた関数よりもはるかに大きなパワーを提供することを意味します。多相なインスタンスのライブラリは、コンパイラが独自に組み立てるコードを組むためのブロックの集合であり、必要な型以外は何も与えられていません。インスタンスを引数に取る多相関数は、型クラスの機構に対して、裏側で補助関数を組み立てるように潜在的に要求しています。APIのクライアントは、必要な部分をすべて手作業で組み立てる負担から解放されます。
メソッドと暗黙の引数
@OfNat.ofNat
の型は意外に思われるかもしれません。これは {α : Type} → (n : Nat) → [OfNat α n] → α
であり、Nat
の引数 n
は明示的な関数の引数として渡されます。しかし、メソッドの宣言においては ofNat
は単に α
型を持ちます。このように一見矛盾しているように見えるのは、型クラスを宣言すると実際には以下のものが生成されるからです:
- 各オーバーロードされた演算の実装を持った構造体型
- クラスと同じ名前の名前空間
- 各メソッドについて、インスタンスから実装を読んでくるためのクラスの名前空間にある関数
これは新しい構造体を宣言すると、アクセサ関数も宣言されるのと似ています。主な違いは、構造体のアクセサが構造体の値を明示的な引数として受け取るのに対し、型クラスのメソッドはインスタンスの値を暗黙のインスタンスとして受け取り、Leanが自動的に判定するようになっている点です。
Leanがインスタンスを見つけるためには、そのインスタンスの引数が利用可能でなければなりません。これは型クラスへの各引数が、メソッドの引数としてインスタンスの前に現れなければならないことを意味します。これらの引数を暗黙的にすると、Leanがその値を発見する作業を行ってくれるためとても便利です。例えば、@Add.add
は {α : Type} → [Add α] → α → α → α
という型を持っています。この場合、Add.add
の引数がユーザが意図した型に関する情報を提供するため、型の引数 α
は暗黙に指定することができます。この型を使用して Add
インスタンスを検索することができます。
しかし ofNat
の場合、デコードされる特定の Nat
リテラルはほかの引数の一部として現れません。これは、仮にLeanが暗黙の引数 n
を解釈しようとすると、使用する情報がないことを意味します。その結果、非常に不便なAPIになってしまいます。したがってこのような場合、Leanはクラスのメソッドに明示的な引数を使用します。
演習問題
偶数値リテラル
前節の練習問題 に出てきた偶数データ型用の OfNat
のインスタンスを再帰的なインスタンス探索を使って書いてください。ベースとなるインスタンスには OfNat Even 0
ではなく OfNat Even Nat.zero
と書く必要があります。
再帰的なインスタンス探索の深さ
Leanのコンパイラが再帰的なインスタンス探索を試みる回数には上限があります。これは前の練習問題で定義した偶数リテラルのサイズに制限を設けます。実験的にその制限を確認してみてください。
インスタンス探索の制御
Add
クラスのインスタンスは、2つの Pos
型の式を足して新たな Pos
の生成を便利にする点においては十分です。しかし、多くの場合より柔軟性を持たせて、引数の型が異なるような 異なる型上 (heterogeneous)の演算子のオーバーロードを許可することが有用です。例えば、Pos
に Nat
を足したり、Nat
に Pos
を足した結果は常に Pos
になります:
def addNatPos : Nat → Pos → Pos
| 0, p => p
| n + 1, p => Pos.succ (addNatPos n p)
def addPosNat : Pos → Nat → Pos
| p, 0 => p
| p, n + 1 => Pos.succ (addPosNat p n)
これらの関数は自然数と正の整数の足し算に使うことができますが、受け取る2つの型がどちらも同じであることを期待する Add
型クラスに用いることはできません。
異なる型上の演算子についてのオーバーロード
オーバーロードされた足し算 の節で述べたように、Leanは異なる型上の加算をオーバーロードするために HAdd
という型クラスを提供しています。HAdd
クラスは3つの型パラメータを取ります:2つの引数の型と戻り値の型です。HAdd Nat Pos Pos
と HAdd Pos Nat Pos
のインスタンスでは、通常の加算記法を型が入り混じったものに使うことができます:
instance : HAdd Nat Pos Pos where
hAdd := addNatPos
instance : HAdd Pos Nat Pos where
hAdd := addPosNat
上記の2つのインスタンスから、以下の例が通ります:
#eval (3 : Pos) + (5 : Nat)
8
#eval (3 : Nat) + (5 : Pos)
8
HAdd
型クラスの定義は、以下の HPlus
の定義と対応するインスタンスによく似たものになっています:
class HPlus (α : Type) (β : Type) (γ : Type) where
hPlus : α → β → γ
instance : HPlus Nat Pos Pos where
hPlus := addNatPos
instance : HPlus Pos Nat Pos where
hPlus := addPosNat
しかし、HPlus
のインスタンスは HAdd
のインスタンスに比べるとかなり使い勝手が悪いです。これらのインスタンスを #eval
で使用しようとすると、エラーが発生します:
#eval HPlus.hPlus (3 : Pos) (5 : Nat)
typeclass instance problem is stuck, it is often due to metavariables
HPlus Pos Nat ?m.7527
これは型にメタ変数があることが原因で、Leanはこれを解決することができません。
多相性についての最初の説明 で議論したように、メタ変数は推論できなかったプログラムの未知の部分を表します。#eval
の後に式を書くと、Leanは自動で型を決定しようとします。今回のケースではこれができませんでした。HPlus
の3番目の型パラメータが未知であったため、Leanは型クラスのインスタンス検索を行うことができません。しかしインスタンス検索はLeanがこの式の型を決定できる唯一の方法です。つまり、HPlus Pos Nat Pos
インスタンスは式が Pos
型を持つ場合にのみ適用できますが、インスタンス自身以外に式がこの型であることを示す情報はプログラム中に存在しません。
この問題の解決策の1つは、式全体に型注釈を追加することで、3つの型がすべて利用できるようにすることです:
#eval (HPlus.hPlus (3 : Pos) (5 : Nat) : Pos)
8
しかしこの解決策は正の整数のライブラリを使う人にとってかなり不便です。
出力パラメータ
この問題は γ
を 出力パラメータ (output parameter)として宣言することで解決できます。型クラスのほとんどのパラメータはインスタンスを選択するための探索アルゴリズムに入力されます。例えば、OfNat
インスタンスでは、型と自然数の両方が自然数リテラルの特定の解釈を選択されるために使用されます。しかし、場合によっては、型パラメータのうちいくつかがまだわかっていない時でも検索プロセスを開始し、その結果発見されたインスタンスをメタ変数の値を決定するために使用することが便利であることがあります。インスタンス検索を開始するのに必要でないパラメータは outParam
修飾子で宣言によって実行される処理の出力結果の型になります:
class HPlus (α : Type) (β : Type) (γ : outParam Type) where
hPlus : α → β → γ
この出力パラメータによって型クラスのインスタンス検索は事前に γ
を知らなくてもインスタンスを選択することができます。例えば以下のように動作します:
#eval HPlus.hPlus (3 : Pos) (5 : Nat)
8
出力パラメータは一種の関数を定義していると考えるとわかりやすいでしょう。1つ以上の出力パラメータを持つ型クラスの任意のインスタンスはLeanに入力から出力を決定する命令を提供します。このインスタンス検索のプロセスは時に再帰的にもなり、結果的にただのオーバーロードよりも強力になります。出力パラメータはプログラム内の他の型を決定することができ、インスタンス検索によって基礎となるインスタンスの集まりをこの型を持つプログラムに組み込むことができます。
デフォルトインスタンス
パラメータが入力か出力かを決めることで、Leanが型クラス検索を開始する状況が制御されます。特に、型クラス検索はすべての入力が判明するまで行われません。しかし、場合によっては出力パラメータだけでは不十分な場合もあり、入力の一部が不明な場合にもインスタンス探索を行う必要があります。これはPythonやKotlinにある関数のオプショナル引数のデフォルト値のようなものです。ただ、ここではデフォルト 型 が選択されます。
デフォルトインスタンス は すべての入力が既知でない場合でも インスタンス検索に利用可能なインスタンスです。これらのインスタンスのうちどれか1つ使用できる場合、そのインスタンスが使用されます。これにより、未知の型やメタ変数に関連するエラーで失敗することなく、プログラムの型チェックを成功させることができます。一方、デフォルトインスタンスは、インスタンスの選択を予測しにくくします。特に、望ましくないデフォルトインスタンスが選択された場合、式が予想と異なる型になる可能性があり、プログラム中の別の個所で紛らわしい型エラーが発生する可能性があります。デフォルトインスタンスは使いどころに気を付けましょう!
デフォルトインスタンスが役に立つ例として、Add
インスタンスから派生される HPlus
インスタンスがあります。言い換えると通常の加算は、異なる型の間の加算において3つの型がすべて同じである特殊なケースです。これは以下のインスタンスを使って実装できます:
instance [Add α] : HPlus α α α where
hPlus := Add.add
このインスタンスを使って、Nat
のような足すことのできる任意の型に対して hPlus
を使うことができます:
#eval HPlus.hPlus (3 : Nat) (5 : Nat)
8
しかし、このインスタンスは2つの引数の型が既知である場合にのみ使うことができます。例えば、
#check HPlus.hPlus (5 : Nat) (3 : Nat)
は以下の型を出力します。
HPlus.hPlus 5 3 : Nat
これは予想通りです。しかし、
#check HPlus.hPlus (5 : Nat)
は2つのメタ変数を含んだ型を出力します。片方は残りの型で、もう片方は戻り値の型です:
HPlus.hPlus 5 : ?m.7706 → ?m.7708
ほとんどの場合、足し算の一方の引数を与えると、もう一方の引数も同じ型になります。このインスタンスをデフォルトインスタンスにするには、default_instance
属性を適用します:
@[default_instance]
instance [Add α] : HPlus α α α where
hPlus := Add.add
このデフォルトインスタンスによって、先ほどの例は使いやすい型になります:
#check HPlus.hPlus (5 : Nat)
は以下を出力します。
HPlus.hPlus 5 : Nat → Nat
オーバーロード可能でかつ異なる型および同じ型上での演算が定義されているような演算子においては、上記のようにデフォルトインスタンスによって異なる型上のものが期待されているコンテキストで同じ型上の演算を行うようにできます。中置演算子は異なる型上の呼び出しに置き換えられ、可能な場合は同じ型上のデフォルトインスタンスが選択されます。
同様に、単に 5
と書くと OfNat
インスタンスを選択するために、より多くの情報を持っているメタ変数を持つ型ではなく、Nat
が得られます。これは Nat
の OfNat
インスタンスがデフォルトのインスタンスであるためです。
複数のインスタンスが適用できるような場合において、デフォルトインスタンスに選ばれるインスタンスをコントロールする 優先順位 を割り当てることもできます。デフォルトインスタンスの優先順位についての詳細はLeanのマニュアルを参照してください。
演習問題
PPoint
の両方の射影にスカラー値を乗じる HMul (PPoint α) α (PPoint α)
のインスタンスを定義してください。これは Mul α
のインスタンスが存在する任意の型 α
に対して機能できるべきです。例えば、
#eval {x := 2.5, y := 3.7 : PPoint Float} * 2.0
は以下を出力するようにしてください。
{ x := 5.000000, y := 7.400000 }
配列と添え字アクセス
休憩の章 ではリスト内の要素を位置で検索するためのインデックス記法の使い方を説明しています。この構文も型クラスによって管理され、様々な異なる型に対して使うことができます。
配列
例えば、Leanの配列は連結リストよりもはるかに効率的です。Leanでは、Array α
型は α
型の値を保持する可変長配列であり、Javaの ArrayList
、C++の std::vector
、Rustの Vec
などにとても近いものです。コンストラクタ cons
を使用するたびにポインタのインダイレクトが発生する List
とは異なり、配列は連続したメモリ領域を占有します。これはプロセッサのキャッシュとかなり相性が良いです。また、配列の値を検索する時間は一定ですが、連結リストの値を検索する時間はアクセスしたい添え字に比例します。
Leanのような純粋関数型言語では、データ構造内のどのフィールドに対しても変更を行うことはできません。その代わりに、その変更が行われたコピーが作成されます。一方で、配列を使用する場合、Leanのコンパイラとランタイムによる最適化が行われ、配列への参照が一意である場合、配列への変更がメモリ上のデータの更新として実装されます。
配列はリストと同じように記述しますが、先頭に #
を付ける必要があります:
def northernTrees : Array String :=
#["sloe", "birch", "elm", "oak"]
配列内の要素の数は Array.size
を使って求めることができます。例えば、northernTrees.size
の実行結果は 4
となります。配列のサイズより小さい添え字に対しては、リストと同じようにインデックス記法を使用して対応する値を見つけることができます。よって、northernTrees[2]
の実行結果は "elm"
になります。また、コンパイラは添え字がリストの添え字の範囲内にあることを証明する必要があり、配列の添え字の範囲外の値を検索しようとすると、リストと同様にコンパイル時にエラーになります。例えば、northernTrees[8]
の結果は以下のようになります:
failed to prove index is valid, possible solutions:
- Use `have`-expressions to prove the index is valid
- Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
- Use `a[i]?` notation instead, result is an `Option` type
- Use `a[i]'h` notation instead, where `h` is a proof that index is valid
⊢ 8 < Array.size northernTrees
空でないリスト
空でないリストを表すデータ型は、リストの先頭のフィールドと、その後ろに続くフィールドとして空である可能性もある普通のリストを持つ構造体として定義できます:
structure NonEmptyList (α : Type) : Type where
head : α
tail : List α
例えば、空でないリスト idahoSpiders
(アメリカのアイダホ州に生息するクモの種を格納したもの)は "Banded Garden Spider"
と、それに続く他の4つのクモを合わせて合計5つのクモで構成されます:
def idahoSpiders : NonEmptyList String := {
head := "Banded Garden Spider",
tail := [
"Long-legged Sac Spider",
"Wolf Spider",
"Hobo Spider",
"Cat-faced Spider"
]
}
再帰関数でこのリストの特定のインデックスの値を調べるには3つの可能性を考慮する必要があります:
- 添え字が
0
の場合はリストの先頭を返却 - 添え字が
n + 1
で続くリストが空の場合は添え字が範囲外 - 添え字が
n + 1
で続くリストが空でない場合は、続くリストとn
に対して関数が再帰的に呼ばれる
例えば、Option
を返すルックアップ関数は次のように書くことができます:
def NonEmptyList.get? : NonEmptyList α → Nat → Option α
| xs, 0 => some xs.head
| {head := _, tail := []}, _ + 1 => none
| {head := _, tail := h :: t}, n + 1 => get? {head := h, tail := t} n
パターンマッチのそれぞれのケースは上記の可能性に対応しています。get?
の再帰呼び出しでは、定義の本体が暗黙のうちに定義の名前空間にあるため、NonEmptyList
という名前空間修飾子は必要ありません。この関数の別の書き方として、添え字が0より大きい場合にはリストの get?
を使う方法があります:
def NonEmptyList.get? : NonEmptyList α → Nat → Option α
| xs, 0 => some xs.head
| xs, n + 1 => xs.tail.get? n
リストの要素が1つの場合、0
だけが有効なインデックスです。リストの要素が2つの場合、0
と 1
の両方が有効なインデックスです。リストの要素が3つの場合、 0
、1
、2
が有効なインデックスです。言い換えると、空でないリストへの有効なインデックスはリストの長さ未満の自然数であり、末尾のリストの長さ以下です。
添え字が許容されることの根拠を見つけるために使用されるタクティクによって整数についての不等式を解くことができますが、NonEmptyList.inBounds
という名前については何も知らないため、添え字が範囲内であることの意味の定義は abbrev
として書かれなければなりません:
abbrev NonEmptyList.inBounds (xs : NonEmptyList α) (i : Nat) : Prop :=
i ≤ xs.tail.length
この関数は真または偽となるような命題を返します。例えば、2
は idahoSpiders
の範囲内ですが、5
は範囲外です:
theorem atLeastThreeSpiders : idahoSpiders.inBounds 2 := by simp
theorem notSixSpiders : ¬idahoSpiders.inBounds 5 := by simp
論理否定演算子の優先順位は非常に低いため、¬idahoSpiders.inBounds 5
は ¬(idahoSpiders.inBounds 5)
と等価です。
この事実を利用して、添え字が有効であるという根拠を必要とするルックアップ関数を書くことができます。またコンパイル時の根拠をチェックする処理をリストの関数に委譲することで Option
を返す必要もなくなります:
def NonEmptyList.get (xs : NonEmptyList α) (i : Nat) (ok : xs.inBounds i) : α :=
match i with
| 0 => xs.head
| n + 1 => xs.tail[n]
もちろん、同じ根拠を使うことができる標準ライブラリ関数に委譲するのではなく、根拠を直接使うようにこの関数を書くことも可能です。これには本書で後述する証明や命題を扱うテクニックが必要です。
オーバーロードされた添え字アクセス
GetElem
型クラスのインスタンスを定義することで、コレクション型のインデックス記法をオーバーロードすることができます。柔軟性を持たせるために、GetElem
は4つのパラメータを持ちます:
- コレクションの型
- インデックスの型
- コレクションを展開した時に得られる要素の型
- インデックスが範囲内であることの根拠のポイントを決定づける関数
要素の型と根拠の関数はどちらも出力パラメータになっています。GetElem
には getElem
というメソッドが1つだけあり、コレクションの値、インデックスの値、インデックスが範囲内にあることを示す根拠を引数として受け取り、要素を返します:
class GetElem (coll : Type) (idx : Type) (item : outParam Type) (inBounds : outParam (coll → idx → Prop)) where
getElem : (c : coll) → (i : idx) → inBounds c i → item
NonEmptyList α
の場合、3つのパラメータは以下になります:
- コレクションは
NonEmptyList α
自体 - 添え字の型は
Nat
- 要素の型は
α
- もし添え字が末尾のリストの長さ以下であるなら、添え字は範囲内
実は、GetElem
のインスタンスは NonEmptyList.get
から直接与えられます:
instance : GetElem (NonEmptyList α) Nat α NonEmptyList.inBounds where
getElem := NonEmptyList.get
このインスタンスによって、NonEmptyList
は List
と同じような便利さで使うことができます。idahoSpiders[0]
を評価すると "Banded Garden Spider"
が得られる一方で、idahoSpiders[9]
を評価するとコンパイル時エラーとなります:
failed to prove index is valid, possible solutions:
- Use `have`-expressions to prove the index is valid
- Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid
- Use `a[i]?` notation instead, result is an `Option` type
- Use `a[i]'h` notation instead, where `h` is a proof that index is valid
⊢ NonEmptyList.inBounds idahoSpiders 9
コレクション型とインデックス型の両方が GetElem
型クラスの入力パラメータであるため、新しい型を既存のコレクションの添え字として使うことができます。正の整数 Pos
は List
の添え字として完全に妥当なものですが、最初の要素を指すことができないという注意点があります。以下の GetElem
インスタンスによって、Pos
を Nat
と同じような便利さでリストの要素を検索することができます:
instance : GetElem (List α) Pos α (fun list n => list.length > n.toNat) where
getElem (xs : List α) (i : Pos) ok := xs[i.toNat]
また、数値以外の添え字を使うこともできます。例えば、Bool
について、false
をx
に、true
を y
に対応付けることで、点のフィールドを選択するために使用することができます。
instance : GetElem (PPoint α) Bool α (fun _ _ => True) where
getElem (p : PPoint α) (i : Bool) _ :=
if not i then p.x else p.y
このケースにおいて、論理値がれっきとした添え字にになっています。そしてすべての Bool
は有限であるため、根拠は恒真な命題 True
になります。
標準クラス
この節では、Leanの型クラスを使ってオーバーロードできるさまざまな演算子や関数を紹介します。各演算子や関数は型クラスのメソッドに対応します。C++とは異なり、Leanの中置演算子は名前のある関数の省略形として定義されています:これの意味するところは、新しい型に対する演算子のオーバーロードは演算子そのものによる処理ではなく、その裏側にある名前(例えば HAdd.hAdd
)を使って行っているということです。
算術演算
ほとんどの算術演算子は異なる型上の演算子として利用可能で、引数の型が異なる場合があり、また出力パラメータがその演算結果の型を決定します。各異なる型上の演算子それぞれに、型クラスとメソッドから h
を取り除いた同じ型上の演算子が存在します。例えば HAdd.hAdd
に対応するものとして、Add.add
が定義されています。Leanでは以下の算術演算子がオーバーロードされています:
式 | 脱糖後の式 | 型クラス名 |
---|---|---|
x + y | HAdd.hAdd x y | HAdd |
x - y | HSub.hSub x y | HSub |
x * y | HMul.hMul x y | HMul |
x / y | HDiv.hDiv x y | HDiv |
x % y | HMod.hMod x y | HMod |
x ^ y | HPow.hPow x y | HPow |
(- x) | Neg.neg x | Neg |
ビット演算子
Leanには型クラスを使用してオーバーロードされる標準のビット演算子がたくさんあります。例えば固定幅の型に対するインスタンスとして、UInt8
や UInt16
、UInt32
、UInt64
、USize
などがあります。最後のものは実行環境の1文字に対応するサイズで、通常は32ビットか64ビットです。以下のビット演算子がオーバーロードされています:
式 | 脱糖後の式 | 型クラス名 |
---|---|---|
x &&& y | HAnd.hAnd x y | HAnd |
x ||| y | HOr.hOr x y | HOr |
x ^^^ y | HXor.hXor x y | HXor |
~~~ x | Complement.complement x | Complement |
x >>> y | HShiftRight.hShiftRight x y | HShiftRight |
x <<< y | HShiftLeft.hShiftLeft x y | HShiftLeft |
同じ型上の演算子について、And
と Or
という名前は論理結合子としてもうすでに取られてしまっているため、同じ型上の演算子バージョンの HAnd
と HOr
はそれぞれ AndOp
と OrOp
と名付けられています。
同値と順序
2つの値の同値性を評価するには、通常 BEq
クラスを使用します。これは「Boolan equality」の省略形です。Leanは定理証明器として使用されるため、Leanには2種類の同値演算子が存在します:
- 真偽値の同値 (Boolean equality)は他のプログラミング言語にも存在する等号と同じです。これは2つの値を取り、
Bool
値を返す関数です。PythonやC#と同じように、真偽値の同値は等号を2つ用いて書かれます。Leanは純粋な関数型言語であるため、参照の同値と値の同値という個別の概念はありません。ポインタの中身は直接見ることができません。 - 命題の同値 (Propositional equality)は2つのものが等しいということの数学的な文です。命題の同値は関数ではありません;むしろ証明可能な数学的な文です。これは等号を1つ使って記述されます。命題の同値の文は、この等式の根拠を分類する型のようなものです。
これらの等号の概念はどちらも重要であり、それぞれ異なる目的で用いられます。ある判断が2つの値が等しいかどうかということから構成される必要がある場合、そのプログラムでは真偽値の同値が有効です。例えば、"Octopus" == "Cuttlefish"
は false
と評価され、"Octopodes" == "Octo".append "podes"
は true
と評価されます。一方で関数のようないくつかの値については等しいかどうかのチェックができません。例えば (fun (x : Nat) => 1 + x) == (Nat.succ ·)
は以下のエラーを出力します:
failed to synthesize instance
BEq (Nat → Nat)
このメッセージが示している通り、==
は型クラスによるオーバーロードされた演算子です。そのため、式 x == y
は実際には BEq.beq x y
の省略形です。
命題の同値はプログラムの呼び出しではなく、数学的な文です。命題はある文の根拠を記述する型のようなものであるため、真偽値の同値よりも String
や Nat → List Int
などのような型との共通点が多いです。このため、この同値は自動的にチェックすることができません。しかし、2つの式の同値性は、その2つが同じ型である限りLeanで記述することはできます。したがって (fun (x : Nat) => 1 + x) = (Nat.succ ·)
は文として完全に妥当に成立しています。数学の観点において、2つの関数が同値であるとは同じ入力から同じ出力に写す場合のことであるため、先ほどの文は真となります。にもかかわらずこの事実をLeanに納得させるためには2行の証明が必要になります。
一般的に、Leanを定理証明器ではなくプログラミング言語として用いる場合には命題よりも真偽値の関数に頼るほうが非常に容易です。しかし、Bool
のコンストラクタに true
と false
という名前が付けられているため、この違いは時として曖昧です。またいくつかの命題はブール関数と同じようにチェックすることができ、これを 決定可能 (decidable)と呼びます。命題が真か偽かをチェックする関数は 決定手続き (decision procedure)と呼ばれ、命題の真偽の 根拠 を返します。決定可能な命題の例としては、自然数か文字列の等式と不等式、そしてそれ自体が決定可能な命題同士を「かつ」と「または」で組み合わせたものなどがあります。
Leanでは、if
は決定可能な命題と組み合わせることができます。例えば、2 < 4
は以下のように命題になります:
#check 2 < 4
2 < 4 : Prop
このように命題であるにもかかわらず、if
の条件として用いても全く問題ありません。例えば、if 2 < 4 then 1 else 2
は Nat
型を持ち、1
と評価されます。
すべての命題が決定可能ではありません。仮にそうだとすると、コンピュータは決定手続きを実行するだけでどんな真である命題でも証明できることとなり、数学者は失業してしまうでしょう。より具体的に言うと、決定可能な命題はメソッドとして決定手続きを有する Decidable
型クラスのインスタンスを持っています。決定可能でない命題を Bool
のように使おうとすると、Decidable
のインスタンスの検索に失敗してしまいます。例えば、if (fun (x : Nat) => 1 + x) = (Nat.succ ·) then "yes" else "no"
の結果は次のようになります:
failed to synthesize instance
Decidable ((fun x => 1 + x) = fun x => Nat.succ x)
以下の命題(通常は決定可能である)はそれぞれ下記の型クラスでオーバーロードされています:
式 | 脱糖後の式 | 型クラス名 |
---|---|---|
x < y | LT.lt x y | LT |
x ≤ y | LE.le x y | LE |
x > y | LT.lt y x | LT |
x ≥ y | LE.le y x | LE |
新しい命題の定義の仕方についてはまだ披露していないため、LT
と LE
の新しいインスタンスを定義するのは難しいかもしれません。
ところで、<
と ==
、>
を使った値の比較は効率的ではありません。ある値が別の値未満かどうかをチェックし、次にそれらが等しいかどうかのチェックをするとなると、同じデータ全体を2回走査する必要があり、大きなデータ構造では時間がかかってしまいます。この問題を解決するために、JavaとC#には標準でそれぞれ compareTo
と CompareTo
メソッドがあり、これをクラスでオーバーライドすることで、3つの操作を同時に実装することができます。これらのメソッドは、受け取るオブジェクトが引数未満の場合は負の整数を返し、等しい場合は0を返し、引数より大きい場合は正の整数を返します。Leanではこのように整数を使った手法のオーバーロードではなく、これら3つのケースを記述する組み込みの帰納型を持っています:
inductive Ordering where
| lt
| eq
| gt
Ord
型クラスをオーバーロードすることでこれらの比較を実現できます。Pos
に対して、インスタンスの実装は以下のように書けます:
def Pos.comp : Pos → Pos → Ordering
| Pos.one, Pos.one => Ordering.eq
| Pos.one, Pos.succ _ => Ordering.lt
| Pos.succ _, Pos.one => Ordering.gt
| Pos.succ n, Pos.succ k => comp n k
instance : Ord Pos where
compare := Pos.comp
Leanにおいて、Javaでの compareTo
のアプローチが有効であるようなシチュエーションでは、Ord.compare
を使いましょう。
ハッシュ化
JavaとC#ではそれぞれ hashCode
と GetHashCode
メソッドを有しており、ハッシュテーブルのようなデータ構造で使用する値のハッシュを計算します。Leanにおいて Hashale
型クラスがこれに相当します:
class Hashable (α : Type) where
hash : α → UInt64
もし2つの値がその型の BEq
インスタンスのもとで等しいとみなされるなら、それらは同じハッシュを持つべきです。言い換えると、もし x == y
ならば、hash x == hash y
となります。もし x ≠ y
ならば、hash x
と hash y
は必ずしも異なるとは限りません(というのも、UInt64
に属する値の数よりも Nat
の方が無限に多いためです)が、ハッシュで構築されたデータ構造は等しくない値が等しくないハッシュを持つ可能性が高い方がパフォーマンスが向上します。このような期待はJavaやC#でも同様です。
標準ライブラリには UInt64 → UInt64 → UInt64
型の関数 mixHash
があり、コンストラクタの異なるフィールドのハッシュを結合するために使用できます。各コンストラクタに一意な番号を割り当て、その番号と各フィールドのハッシュを融合することで、帰納的なデータ型のための合理的なハッシュ関数を書くことができます。例えば、Pos
に対する Hashable
インスタンスは次のように書けます:
def hashPos : Pos → UInt64
| Pos.one => 0
| Pos.succ n => mixHash 1 (hashPos n)
instance : Hashable Pos where
hash := hashPos
多相型に対する Hashable
インスタンスは再帰的なインスタンス検索が可能です。NonEmptyList α
のハッシュ化は α
がハッシュ化できる場合にのみ可能です:
instance [Hashable α] : Hashable (NonEmptyList α) where
hash xs := mixHash (hash xs.head) (hash xs.tail)
二分木は再帰と BEq
と Hashable
の実装に対するインスタンスの再帰的な検索の両方を利用します:
inductive BinTree (α : Type) where
| leaf : BinTree α
| branch : BinTree α → α → BinTree α → BinTree α
def eqBinTree [BEq α] : BinTree α → BinTree α → Bool
| BinTree.leaf, BinTree.leaf =>
true
| BinTree.branch l x r, BinTree.branch l2 x2 r2 =>
x == x2 && eqBinTree l l2 && eqBinTree r r2
| _, _ =>
false
instance [BEq α] : BEq (BinTree α) where
beq := eqBinTree
def hashBinTree [Hashable α] : BinTree α → UInt64
| BinTree.leaf =>
0
| BinTree.branch left x right =>
mixHash 1 (mixHash (hashBinTree left) (mixHash (hash x) (hashBinTree right)))
instance [Hashable α] : Hashable (BinTree α) where
hash := hashBinTree
標準の型クラスの自動的な導出
BEq
や Hashable
のようなクラスのインスタンスを手作業で実装するのは非常に面倒です。Leanには インスタンス導出 (instance deriving)と呼ばれる機能があり、コンパイラが自動的に多くの型クラスに対して行儀のよい(well-behaved)インスタンスを構築することができます。実は、「構造体」節 の Point
の定義にある deriving Repr
というフレーズはインスタンス導出の一例です。
インスタンスは2つの方法で導出することができます。1つ目は構造体や帰納型を定義する時です。この場合、型宣言の最後に deriving
を追加し、その後にインスタンスを導出させるクラスの名前を追加します。すでに定義されている型の場合は、単独の deriving
コマンドを使用することができます。deriving instance C1, C2, ... for T
と書くことで、T
型に対して C1, C2, ...
のインスタンスを後から導出させることができます。
Pos
と NonEmptyList
に対しての BEq
と Hashable
のインスタンスは非常に少ないコード量で導出することができます:
deriving instance BEq, Hashable for Pos
deriving instance BEq, Hashable, Repr for NonEmptyList
このほか、例えば以下のクラスに対してもインスタンスの導出が可能です:
Inhabited
BEq
Repr
Hashable
Ord
しかし場合によっては、Ord
インスタンスの導出がアプリケーションで必要とされる順序とならないことがあります。このような場合では、手作業で Ord
インスタンスを書いても問題はありません。インスタンスを導出させることができるクラスのコレクションの拡張はLeanに精通したユーザでないと難しいでしょう。
プログラマの生産性とコードの可読性という明確な利点のほかに、インスタンスの導出はコードの保守を容易にします。なぜなら導出されたインスタンスは型の定義に付随して更新されるからです。データ型の更新を伴う変更セットは、同値テストやハッシュ計算について各行ごとに形式的な修正をいちいちする必要がなく、読みやすくなります。
結合
多くのデータ型には、ある種のデータの結合のための演算子があります。Leanにおいて2つの値の結合は HAppend
という型クラスでオーバーロードされます。これは算術演算に使われるような異なる型上の演算です:
class HAppend (α : Type) (β : Type) (γ : outParam Type) where
hAppend : α → β → γ
構文 xs ++ ys
は HAppend.hAppend xs ys
と脱糖されます。同じ型上のケースにおいては、下記のような通常のパターンに従った Append
のインスタンスを実装するだけで充分です:
instance : Append (NonEmptyList α) where
append xs ys :=
{ head := xs.head, tail := xs.tail ++ ys.head :: ys.tail }
上記のインスタンスを定義することで:
#eval idahoSpiders ++ idahoSpiders
は以下の出力になります:
{ head := "Banded Garden Spider",
tail := ["Long-legged Sac Spider",
"Wolf Spider",
"Hobo Spider",
"Cat-faced Spider",
"Banded Garden Spider",
"Long-legged Sac Spider",
"Wolf Spider",
"Hobo Spider",
"Cat-faced Spider"] }
同様に、HAppend
の定義によって、空でないリストを普通にリストに追加することができます:
instance : HAppend (NonEmptyList α) (List α) (NonEmptyList α) where
hAppend xs ys :=
{ head := xs.head, tail := xs.tail ++ ys }
このインスタンスによって以下が可能になり、
#eval idahoSpiders ++ ["Trapdoor Spider"]
以下の出力になります。
{ head := "Banded Garden Spider",
tail := ["Long-legged Sac Spider", "Wolf Spider", "Hobo Spider", "Cat-faced Spider", "Trapdoor Spider"] }
関手
多相型 関手 (functor)とは、その型に格納されているすべての要素を何かしらの関数で変換する map
という名前の関数のオーバーロードを持ちます。ほとんどの言語でこの用語が使われており、例えばC#では System.Linq.Enumerable.Select
と呼ばれるものがこの map
に相当します。例えば、関数 f
をリスト全体にマッピングすると、入力のリストの各要素がその関数の結果で置き換えられた新しいリストが作成されます。関数 f
を Option
にマッピングすると、none
はそのままにし、some x
を some (f x)
に置き換えます。
以下が関手の例と、Functor
インスタンスが map
をどのようにオーバーロードしているかの例です:
Functor.map (· + 5) [1, 2, 3]
の評価は[6, 7, 8]
Functor.map toString (some (List.cons 5 List.nil))
の評価はsome "[5]"
Functor.map List.reverse [[1, 2, 3], [4, 5, 6]]
の評価は[[3, 2, 1], [6, 5, 4]]
このような一般的な演算に対して Functor.map
という名前は少々長いため、Leanは関数をマッピングするための中置演算子を提供しており、 <$>
と書かれます。これにより先ほどの例は次のように書き換えることができます:
(· + 5) <$> [1, 2, 3]
の評価は[6, 7, 8]
toString <$> (some (List.cons 5 List.nil))
の評価はsome "[5]"
List.reverse <$> [[1, 2, 3], [4, 5, 6]]
の評価は[[3, 2, 1], [6, 5, 4]]
NonEmptyList
に対する Functor
のインスタンスは map
関数の設定を必要とします。
instance : Functor NonEmptyList where
map f xs := { head := f xs.head, tail := f <$> xs.tail }
ここで、後続のリストに対しては List
に対する Functor
インスタンスの map
を使用してマッピングしています。このインスタンスは NonEmptyList α
ではなく NonEmptyList
に対して定義されています。というのも、引数の型として α
はこの型クラスの構成に何の関与もないからです。NonEmptyList
は要素の型が何であろうと、関数をマッピングすることができます。もし α
がクラスのパラメータであれば、NonEmptyList Nat
に対してのみ動作する Functor
のバージョンを作ることは可能ですが、map
がどのような要素の型に対しても動作するという性質は関手であることの一部になっています。
以下が PPoint
に対する Functor
のインスタンスです:
instance : Functor PPoint where
map f p := { x := f p.x, y := f p.y }
このケースにおいて、f
は x
と y
の両方に適用されます。
関手に含まれる型自体が関手である場合でも、関数のマッピングは1つ下の階層にしか行きません。つまり、NonEmptyList (PPoint Nat)
に対して map
を使用する場合、マッピングされる関数は Nat
ではなく PPoint Nat
を引数に取る必要があります。
Functor
クラスの定義では、まだ紹介していないもう一つの言語機能である、デフォルトのメソッド定義を使用しています。通常、クラスは相互に関連するようなオーバーロード可能な演算の最小セットを指定し、より大きな機能のライブラリを提供するために、インスタンスの暗黙引数を持つ多相関数を使用します。例えば、関数 concat
は要素が追加可能な空でないリストを連結することができます:
def concat [Append α] (xs : NonEmptyList α) : α :=
let rec catList (start : α) : List α → α
| [] => start
| (z :: zs) => catList (start ++ z) zs
catList xs.head xs.tail
しかし、クラスによってはデータ型の内部を知っていた方が効率的に実装できるような演算も存在します。
このような場合、デフォルトのメソッド定義を提供することができます。デフォルトのメソッド定義は、ほかのメソッドから見たメソッドのデフォルトの実装を提供します。しかし、インスタンスを実装する際には、このデフォルト実装より効率的なものがあれば、それを用いてオーバーライドすることもできます。デフォルトのメソッド定義は class
定義の中で :=
を用いて行われます。
Functor
の場合、マッピングされる関数が引数を無視する時、より効率的な map
の実装方法を持つ型があります。引数を無視する関数は常に同じ値を返すので、 定数関数 (constant function)と呼ばれます。以下は mapConst
がデフォルトで実装されている Functor
の定義です:
class Functor (f : Type → Type) where
map : {α β : Type} → (α → β) → f α → f β
mapConst {α β : Type} (x : α) (coll : f β) : f α :=
map (fun _ => x) coll
BEq
を考慮しない Hashable
インスタンスがバグをはらむように、関数をマッピングする際にデータを移動する Functor
インスタンスもバグを生みます。例えば、List
に対するバグを含む Functor
インスタンスは、引数を捨てて常にリストを返したり、リストを反転させたりするものなどです。また、PPoint
のまずいインスタンスは x
と y
の両方のフィールドに f x
を置いたりするものもあるでしょう。具体的には、Functor
インスタンスは次の2つのルールに従う必要があります:
- 恒等関数のマッピングはもともとの引数をそのまま返さなければならない。
- 2つの関数を合成した関数のマッピングはそれぞれをマッピングしたものを合成したものと同じ作用を持たなければならない。
より正式には、最初のルールは id <$> x
が x
に等しいことを指します。2番目のルールは map (fun y => f (g y)) x
が map f (map g x)
に等しいことを述べています。合成 fun y => f (g y)
は f ∘ g
とも書くことができます。これらのルールは map
の実装がデータを移動したり、一部を削除したりしてしまうことを防ぎます。
見るかもしれないメッセージ
Leanはすべてのクラスのインスタンスを導出させることはできません。例えば、次のコードは
deriving instance ToString for NonEmptyList
以下のエラーを引き起こします:
default handlers have not been implemented yet, class: 'ToString' types: [NonEmptyList]
deriving instance
を呼び出すと、Leanは型クラスのインスタンスに対するコードジェネレータの内部テーブルを参照します。もし対応するコードジェネレータが見つかれば、インスタンスを生成するために指定された型に対してそのコードジェネレータが呼び出されます。しかし、このメッセージは ToString
のコードジェネレータが見つからなかったことを意味します。
演習問題
HAppend (List α) (NonEmptyList α) (NonEmptyList α)
のインスタンスを記述し、動作を確認してください。- 二分木のデータ型に対して
Functor
インスタンスを実装してください。
型強制
数学では、同じ記号を異なる文脈の何かしらの対象の異なる側面について表すということがよくあります。例えば、集合が期待されている場面で環に言及する場合、環の台集合を意図していると理解されます。プログラミング言語では、ある型の値を別の型の値に自動的に変換する規則が一般的に備わっています。例えば、Javaでは byte
型を自動的に int
型に昇格させることができ、Kotlinではnullable型を期待するコンテキストでnon-nullable型を使用することができます。
Leanでは、この2つの目的は 型強制 (coercion)という機構で提供されています。ある型についての式について、コンテキスト中では異なる型が期待されている場合、Leanは型エラーを報告する前にその式の型を強制することを試みます。JavaやC、Kotlinと異なり、型強制は型クラスのインスタンスの定義によって拡張可能です。
正の整数
例えば、すべての正の整数には自然数が対応します。以前定義した関数 Pos.toNat
は Pos
を対応する Nat
に変換します:
def Pos.toNat : Pos → Nat
| Pos.one => 1
| Pos.succ n => n.toNat + 1
型 {α : Type} → Nat → List α → List α
の関数 List.drop
はリストの先頭から指定数の要素を削除します。しかし、List.drop
に Pos
を適用すると、型エラーが発生します:
[1, 2, 3, 4].drop (2 : Pos)
application type mismatch
List.drop 2
argument
2
has type
Pos : Type
but is expected to have type
Nat : Type
List.drop
の作者はこのメソッドを型クラスのメソッドにしなかったため、新しいインスタンスを定義してオーバーロードすることはできません。
型クラス Coe
は、ある型から別の型への強制についてオーバーロードのされ方を記述します:
class Coe (α : Type) (β : Type) where
coe : α → β
Coe Pos Nat
のインスタンスを定義するだけで先ほどのコードは動くようになります:
instance : Coe Pos Nat where
coe x := x.toNat
#eval [1, 2, 3, 4].drop (2 : Pos)
[3, 4]
#check
を使うと、この裏側で行われたインスタンス検索の結果を見ることができます:
#check [1, 2, 3, 4].drop (2 : Pos)
List.drop (Pos.toNat 2) [1, 2, 3, 4] : List Nat
型強制の連鎖
Leanが型強制を検索する時、小さい強制の連鎖から強制を組み立てようとします。例えば、Nat
から Int
への強制が標準で実装されています。このインスタンスと Coe Pos Nat
インスタンスを組み合わせると、以下のようなコードがコンパイラに受理されます:
def oneInt : Int := Pos.one
この定義は Pos
から Nat
へのものと、Nat
から Int
へのものの2つの強制を使用しています。
Leanのコンパイラは循環的な強制があってもはまってしまうことはありません。例えば、2つの型 A
と B
が互いに強制することができても、その相互の強制を使って以下の強制の筋道を見つけることができます:
inductive A where
| a
inductive B where
| b
instance : Coe A B where
coe _ := B.b
instance : Coe B A where
coe _ := A.a
instance : Coe Unit A where
coe _ := A.a
def coercedToB : B := ()
注意:括弧だけの式 ()
は Unit.unit
のコンストラクタの短縮形です。Repr B
のインスタンスを導出すると、
#eval coercedToB
は以下の結果になります:
B.b
Option
型はC#とKotlinのnullable型と同じように扱えます:none
コンストラクタは値がないことを表しています。Leanの標準ライブラリは任意の型 α
から値を some
で包む Option α
への型強制が定義されています。これにより、オプション型は some
を省略することができるため、よりnullable型に似た方法で使うことができます。例えば、リストの最後の要素を見つける関数 List.getLast?
は戻り値 x
を some
で囲むことなく記述することができます:
def List.last? : List α → Option α
| [] => none
| [x] => x
| _ :: x :: xs => last? (x :: xs)
インスタンス検索がこの強制を見つけ、引数を some
で包む coe
の呼び出しを挿入します。これらの強制は連鎖させることができるので、Option
をネストして使用しても、ネストした some
コンストラクタを必要としません:
def perhapsPerhapsPerhaps : Option (Option (Option String)) :=
"Please don't tell me"
型強制が自動的に有効になるのは、Leanが推論した型とプログラムの他の部分から要求された型との間にミスマッチが発生した場合だけです。そのほかのエラーの場合、強制は作動しません。例えば、インスタンスが見つからないというエラーの場合、強制は使用されません:
def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
392
failed to synthesize instance
OfNat (Option (Option (Option Nat))) 392
これは OfNat
に使用する型を手動で指定することで回避できます:
def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
(392 : Nat)
さらに、強制は上向き矢印によって手動で挿入することもできます:
def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
↑(392 : Nat)
いくつかのケースにおいて、これはLeanが正しいインスタンスを見つけることができるようにするために使用できます。また、プログラマの意図をより明確にすることもできます。
空でないリストと依存強制
Coe α β
のインスタンスは、β
型が α
型の各値を表現できる値を持っているときに意味を成します。Nat
から Int
への強制は、Int
型がすべての自然数を保持しているため意味を為します。同様に、List
型はすべての空でないリストを表すことができるので、空でないリストから普通のリストへの強制は意味があります:
instance : Coe (NonEmptyList α) (List α) where
coe
| { head := x, tail := xs } => x :: xs
これによって空でないリストに List
のすべてのAPIの利用が許可されます。
一方で、 Coe (List α) (NonEmptyList α)
のインスタンスを書くことは不可能です。なぜなら、空でないリストは空リストを表現できないからです。この制限は 依存強制 (dependent coercions)と呼ばれる別バージョンの強制を使うことで回避できます。依存強制は、ある型から別の型への強制が特定の値の強制がどのように行われるかということに依存する場合に使用できます。ちょうど、OfNat
型クラスがオーバーロードされる特定の Nat
をパラメータとして受け取るように、依存強制は強制される値をパラメータとして受け取ります:
class CoeDep (α : Type) (x : α) (β : Type) where
coe : β
これにより、値にさらに型クラスの制約を課すか、特定のコンストラクタを直接書くことによって特定の値だけを選択できることができます。例えば、実際には空でない List
は NonEmptyList
に強制することができます:
instance : CoeDep (List α) (x :: xs) (NonEmptyList α) where
coe := { head := x, tail := xs }
型への強制
数学では、集合に付加的な構造を持たせた概念を持つことが一般的です。例えば、モノイドとは、ある集合 S と S の要素 s 、S 上の結合的な二項演算子で s が演算子の左右に対して中立であるようなものです。S はモノイドの「台集合」と呼ばれます。足し算は結合的であり、任意の数への0の加算は恒等であるため、0と足し算を備えた自然数はモノイドを形成します。同様に、1と掛け算を備えた自然数もモノイドを形成します。モノイドは関数型プログラミングでも広く用いられています:リスト、空リスト、append演算子はモノイドを形成し、文字列、空文字列、文字列のappendもモノイドを形成します:
structure Monoid where
Carrier : Type
neutral : Carrier
op : Carrier → Carrier → Carrier
def natMulMonoid : Monoid :=
{ Carrier := Nat, neutral := 1, op := (· * ·) }
def natAddMonoid : Monoid :=
{ Carrier := Nat, neutral := 0, op := (· + ·) }
def stringMonoid : Monoid :=
{ Carrier := String, neutral := "", op := String.append }
def listMonoid (α : Type) : Monoid :=
{ Carrier := List α, neutral := [], op := List.append }
モノイドが与えられると、一度の走査でリストの要素をモノイドの台集合に変換し、モノイドの演算子を使ってそれらを結合する foldMap
関数を書くことができます。モノイドは中立元を持つので、リストが空の時に対応する値が自然に存在し、また演算子が結合的であるため、関数の利用者は再帰関数が左から右へか、右から左へ要素を結合するかを気にする必要はありません。
def foldMap (M : Monoid) (f : α → M.Carrier) (xs : List α) : M.Carrier :=
let rec go (soFar : M.Carrier) : List α → M.Carrier
| [] => soFar
| y :: ys => go (M.op soFar (f y)) ys
go M.neutral xs
モノイドは3つの個別の情報から構成されているにも関わらず、その集合を参照する際にはモノイドの名前だけを参照するのが一般的です。「A をモノイドとし、 x と y をその台集合の要素とする」というよりも「 A をモノイドとし、 x と y を A の要素とする」と言う方が一般的です。この慣習は、モノイドからその台集合への新しい種類の強制を定義することでLeanにおいて実装することができます。
CoeSort
クラスは Coe
クラスと似ていますが、強制の対象は Type
や Prop
といった ソート (sort)でなければなりません。Leanにおいて ソート という用語は型自体を分類する型を指します。ここで分類される型は、それ自体がデータを分類する型を分類する Type
と、それ自体が根拠の真偽を分類する命題を分類する Prop
です。型の不一致が発生した時に Coe
がチェックされるように、CoeSort
はソートが期待されるコンテキストでソート以外のものが提示されたときに使用されます。
モノイドからその台集合への強制はその台の展開で行われます:
instance : CoeSort Monoid Type where
coe m := m.Carrier
この強制により、型注釈はいくぶん仰々しさが軽減されます:
def foldMap (M : Monoid) (f : α → M) (xs : List α) : M :=
let rec go (soFar : M) : List α → M
| [] => soFar
| y :: ys => go (M.op soFar (f y)) ys
go M.neutral xs
CoeSort
のもう一つ便利な例は、Bool
と Prop
の間のギャップを埋めるために使用されます。順序と同値についての節 で説明したように、Leanの if
式は条件が Bool
ではなく、決定可能な命題であることを期待します。しかし、プログラムは通常、真偽値に基づいて分岐する必要があります。2種類の if
式を用意する代わりに、Leanの標準ライブラリは Bool
から問題の Bool
が true
に等しいという命題への強制を定義しています:
instance : CoeSort Bool Prop where
coe b := b = true
ここで、問題のソートは Type
ではなく Prop
です。
関数への強制
プログラミングでよく使われるデータ型の多くは、関数とその関数に関する追加情報で構成されています。例えば、関数はログに表示するための名前や、何かしらの設定データを伴っているかもしれません。さらに、Monoid
の例と同じように、構造体のフィールドに型を置くことは、ある演算を実装する方法が複数あり、型クラスが許可するよりも手動で制御する必要があるような状況で意味を持つことがあります。例えば、JSONシリアライザが出力する値について他のアプリケーションにて特定のフォーマットを期待することがあるため、その場合は詳細が重要になります。時には関数自体が設定データだけから導出可能な場合もあります。
CoeFun
と呼ばれる型クラスは、値を非関数型から関数型に変換することができます。CoeFun
には2つのパラメータがあります:1つ目は関数に変換したい値の型で、2つ目は出力パラメータで対象となる関数型を正確に決定するものです。
class CoeFun (α : Type) (makeFunctionType : outParam (α → Type)) where
coe : (x : α) → makeFunctionType x
2番目のパラメータは、それ自体が型を計算する関数です。Leanでは、型は第一級であり、他のものと同じように関数に渡したり関数から返したりすることができます。
例えば、引数に定数を加算する関数は、実際の関数を定義する代わりに、加算する量のラッパーとして表現することができます:
structure Adder where
howMuch : Nat
引数に5を加える関数は、howMuch
フィールドに 5
を持ちます:
def add5 : Adder := ⟨5⟩
この Adder
型は関数ではないので、引数に適用するとエラーになります:
#eval add5 3
function expected at
add5
term has type
Adder
CoeFun
インスタンスを定義すると、Leanはこの加算器を Nat → Nat
型の関数に変換します:
instance : CoeFun Adder (fun _ => Nat → Nat) where
coe a := (· + a.howMuch)
#eval add5 3
8
すべての Adder
は Nat → Nat
関数に変換されるべきなので、CoeFun
の2番目のパラメータの引数は無視されています。
正しい関数型を決定するために値そのものが必要な場合、CoeFun
の2番目のパラメータは無視されなくなります。例えば、以下のようなJSON値の表現があるとします:
inductive JSON where
| true : JSON
| false : JSON
| null : JSON
| string : String → JSON
| number : Float → JSON
| object : List (String × JSON) → JSON
| array : List JSON → JSON
deriving Repr
JSONのシリアライザは、シリアライズするコードそのものとともにシリアライズ方法についての型を格納する構造体です:
structure Serializer where
Contents : Type
serialize : Contents → JSON
文字列用のシリアライザは、渡された文字列を JSON.string
コンストラクタでつつむだけです:
def Str : Serializer :=
{ Contents := String,
serialize := JSON.string
}
JSONシリアライザを、引数をシリアライズする関数として見るには、シリアライズ可能なデータの内部型を抽出する必要があります:
instance : CoeFun Serializer (fun s => s.Contents → JSON) where
coe s := s.serialize
このインスタンスにより、シリアライザを引数に直接適用することができます:
def buildResponse (title : String) (R : Serializer) (record : R.Contents) : JSON :=
JSON.object [
("title", JSON.string title),
("status", JSON.number 200),
("record", R record)
]
このシリアライザは buildResponse
に直接渡すことができます:
#eval buildResponse "Functional Programming in Lean" Str "Programming is fun!"
JSON.object
[("title", JSON.string "Functional Programming in Lean"),
("status", JSON.number 200.000000),
("record", JSON.string "Programming is fun!")]
余談:文字列としてのJSON
LeanのオブジェクトとしてエンコードされたJSONを理解するのは少し難しいかもしれません。シリアライズされたレスポンスが期待されたものであることを確認するために、JSON
から String
への簡単なコンバータを書くと便利です。最初のステップは、数値の表示を単純化することです。JSON
は整数と浮動小数点数を区別しないため、Float
型は両方を表現するために使用されます。Leanでは、Float.toString
は末尾に0を含みます:
#eval (5 : Float).toString
"5.000000"
この解決策は、0をすべて削除し、小数点以下を削除することで表示を綺麗にする小さな関数を書くことです:
def dropDecimals (numString : String) : String :=
if numString.contains '.' then
let noTrailingZeros := numString.dropRightWhile (· == '0')
noTrailingZeros.dropRightWhile (· == '.')
else numString
この定義によって、#eval dropDecimals (5 : Float).toString
は "5"
を出力し、#eval dropDecimals (5.2 : Float).toString
は "5.2"
を出力します。
次のステップは、区切り文字を挟んだ文字列のリストを追加する補助関数を定義することです:
def String.separate (sep : String) (strings : List String) : String :=
match strings with
| [] => ""
| x :: xs => String.join (x :: xs.map (sep ++ ·))
この関数はJSON配列とオブジェクト中のコンマ区切り要素に対して有用です。#eval ", ".separate ["1", "2"]
は "1, 2"
を、#eval ", ".separate ["1"]
は "1"
を、 #eval ", ".separate []
は ""
をそれぞれ出力します。
最後に、JSON文字列のための文字列エスケープ手順が必要です。幸いなことに、Leanのコンパイラには、Lean.Json.escape
というJSON文字列をエスケープする内部関数がすでに用意されています。この関数を使用する際には、ファイルの先頭に import Lean
を追加してください。
JSON
の値から文字列を出力する関数が partial
と宣言されているのは、Leanがその関数の終了を確認できないためです。これは、asString
への再帰呼び出しが、List.map
によって適用される関数の中で発生するためです。この再帰呼び出しの仕方が複雑であるために、Leanは再帰的呼び出しがちゃんと小さい値に対して実行されていることを確認できません。JSON文字列を生成するだけでよく、その処理について数学的に推論する必要がないアプリケーションでは、関数が partial
であってもめったに問題になることはありません。
partial def JSON.asString (val : JSON) : String :=
match val with
| true => "true"
| false => "false"
| null => "null"
| string s => "\"" ++ Lean.Json.escape s ++ "\""
| number n => dropDecimals n.toString
| object members =>
let memberToString mem :=
"\"" ++ Lean.Json.escape mem.fst ++ "\": " ++ asString mem.snd
"{" ++ ", ".separate (members.map memberToString) ++ "}"
| array elements =>
"[" ++ ", ".separate (elements.map asString) ++ "]"
この定義により、シリアライズされた結果は読みやすくなります:
#eval (buildResponse "Functional Programming in Lean" Str "Programming is fun!").asString
"{\\"title\\": \\"Functional Programming in Lean\\", \\"status\\": 200, \\"record\\": \\"Programming is fun!\\"}"
見るかもしれないメッセージ
自然数リテラルは OfNat
型クラスでオーバーロードされます。強制はインスタンスが見つからない場合ではなく、型が一致しない場合に発生するため、型の OfNat
インスタンスが見つからなくても Nat
からの強制が適用されることはありません:
def perhapsPerhapsPerhapsNat : Option (Option (Option Nat)) :=
392
failed to synthesize instance
OfNat (Option (Option (Option Nat))) 392
設計上の考慮事項
強制は強力なツールであるが、責任をもって使用すべきです。一方で、モデル化したいドメインにおける日常的なルールにAPIを自然に従わせることが可能です。これは手動で変換を行う関数の官僚的な濫用と、明確なプログラムとの違いになり得ます。AbelsonとSussmanは Structure and Interpretation of Computer Programs (MIT Press, 1996)の序文でこのように書いています。
プログラムは人間が読むために書かれ、機械が実行するために付随的に書かれなければならない。 (Programs must be written for people to read, and only incidentally for machines to execute.)
賢く使用された型強制は、ドメインのエキスパートとの対話の基礎となる、読みやすいコードを実現する貴重な手段です。しかし、強制に大きく依存するAPIには、いくつかの重要な制限があります。読者がライブラリで強制を利用したい際には、その前にこれらの制限についてよく考えてください。
まず第一に、型強制はLeanが関係するすべての型を知るのに十分な型情報が利用可能なコンテキストでのみ適用されます。というのも強制の型クラスには出力パラメータが無いからです。これは関数の戻り値の型注釈が、型エラーとうまく適用された強制との違いになることを意味します。例えば、空でないリストからリストへの強制は以下のプログラムを動作させます:
def lastSpider : Option String :=
List.getLast? idahoSpiders
一方で、型注釈が省略された場合、結果の型が不明であるため、Leanは強制を見つけることができません:
def lastSpider :=
List.getLast? idahoSpiders
application type mismatch
List.getLast? idahoSpiders
argument
idahoSpiders
has type
NonEmptyList String : Type
but is expected to have type
List ?m.34258 : Type
より一般的には、何らかの理由で強制が適用されなかった場合、ユーザには元の型エラーが返され、強制の連鎖のデバッグを難しくします。
最後に、フィールドのアクセサ記法のコンテキストでは、強制は適用されません。つまり、強制する必要がある式とそうでない式の間にはまだ重要な違いがあり、この違いはAPIの利用者にも見えるということです。
その他の便利な機能
インスタンスのためのコンストラクタ記法
裏側では、型クラスは構造体型であり、インスタンスはこれらの型の値です。唯一の違いは、Leanが型クラスに関する追加情報(どのパラメータが出力パラメータであるかなど)を保存していることと、インスタンスが検索のために登録されていることです。構造体型の値は通常 ⟨...⟩
構文か波括弧とフィールドを使って定義されます。インスタンスは通常 where
を使って定義されます。これらの構文は両方の定義どちらでも使うことができます。
例えば、林業に対するアプリケーションでは樹木を次のように表現します:
structure Tree : Type where
latinName : String
commonNames : List String
def oak : Tree :=
⟨"Quercus robur", ["common oak", "European oak"]⟩
def birch : Tree :=
{ latinName := "Betula pendula",
commonNames := ["silver birch", "warty birch"]
}
def sloe : Tree where
latinName := "Prunus spinosa"
commonNames := ["sloe", "blackthorn"]
これら3つの記法は等価です。
同じように、型クラスのインスタンスもこれら3つすべての記法で定義可能です:
class Display (α : Type) where
displayName : α → String
instance : Display Tree :=
⟨Tree.latinName⟩
instance : Display Tree :=
{ displayName := Tree.latinName }
instance : Display Tree where
displayName t := t.latinName
一般的に、インスタンスには where
構文を使用し、構造体には波括弧構文を使用します。⟨...⟩
の構文は、構造体型がタプルとよく似ていることを強調する際には便利です。つまり、フィールドに名前がついてはいますが、そのことがあまり重要でない場合です。しかし、ほかの選択肢を使うことが理にかなっている状況もあります。特に、ライブラリがインスタンス値を構築する関数を提供する場合です。このような関数の使い方として最も簡単な方法は、インスタンス宣言の :=
の後にこの関数を呼び出すのことです。
例の記述
Leanのコードを試したい場合、#eval
や #check
コマンドよりも定義を使った方が便利な場合があります。第一に、定義は出力を生成しないので、読者の注意を最も興味深い出力に集中させることができます。第二に、Leanのプログラムを書くにあたって型シグネチャから始めることが最も簡単で、これによってプログラムを書いている間により良いエラーメッセージと支援をLeanから得ることができます。一方、#eval
と #check
はLeanが与えられた式から型を決定できるコンテキストで使用することが最も簡単です。第三に、#eval
は関数のように、ToString
や Repr
インスタンスを持たない式には使用できません。最後に、複数行にわたる do
ブロック、let
式、その他の構文形は #eval
や #check
で型注釈を記述することが特に難しいです。 というのもシンプルにそれらに求められる括弧がどう挿入されるかの予測が難しいからです。
これらの問題を回避するために、Leanはソースファイル内の例を明示的に示すことをサポートしています。例とは名前のない定義のようなものです。例えば、コペンハーゲンの緑地でよくみられる鳥についての空でないリストは以下のように書くことができます。
example : NonEmptyList String :=
{ head := "Sparrow",
tail := ["Duck", "Swan", "Magpie", "Eurasian coot", "Crow"]
}
例は引数を受け付けることで関数を定義することもできるでしょう:
example (n : Nat) (k : Nat) : Bool :=
n + k == k + n
これは裏側で関数を作成しますが、この関数には名前がなく、呼び出すことができません。とはいえ、この関数は任意の型や未知の型の値でライブラリがどのように使えるのかを示すのに便利です。ソースファイルでは、example
宣言はその例がライブラリの概念をどのように表現しているかを説明するコメントと組み合わせるのが最適です。
まとめ
型クラスとオーバーロード
型クラスは、関数や演算子をオーバーロードするための機構です。多相関数は複数の型で使用できますが、どの型で使用しても同じように動作します。例えば、2つのリストを結合する多相関数は、リスト内の要素の型に関係なく使用できますが、どの型が見つかったかによって異なる動作をすることはできません。一方で、型クラスでオーバーロードされる演算もまた複数の型で使用することができます。しかし、それぞれの型はオーバーロードされた演算の独自の実装を必要とします。つまり、与えられた型によって動作が異なる可能性があります。
型クラス は名前とパラメータ、そしていくつかの名前と型からなる本体を持ちます。名前はオーバーロードされる演算を参照するためのもので、パラメータはオーバーロード可能な定義を決定し、そして本体はオーバーロードされる演算の名前と型シグネチャを提供します。オーバーロード可能な各演算は、型クラスの メソッド と呼ばれます。型クラスのいくつかのメソッドはほかのメソッドによるデフォルトの実装をされることもあり、実装者にとって必要がない場合はそれらを手で実装する必要はありません。
型クラスの インスタンス は、与えられたパラメータに対するメソッドの実装を提供します。インスタンスは多相的であることもあり。その場合はさまざまなパラメータに対して動作することができます。また、ある特定の型に対してより効率的なバージョンが存在する場合には、デフォルトメソッドのより具体的な実装を提供することもあります。
型クラスのパラメータは 入力パラメータ (デフォルト)か 出力パラメータ ( outParam
修飾子で示されます)のどちらかです。Leanはすべての入力パラメータがメタ変数でなくなるまでインスタンスの検索を開始しませんが、出力パラメータはインスタンスの検索中に解決することができます。型クラスのパラメータは型である必要ではなく、通常の値も可能です。自然数リテラルをオーバーロードするために使用される OfNat
型クラスは、オーバーロードされた Nat
自身をパラメータとして受け取り、これによりインスタンスにその数値を限定させることができます。
インスタンスには @[default_instance]
属性を付けることができます。インスタンス検索において型にメタ変数が存在してインスタンスを見つけることができない場合にはデフォルトインスタンスがフォールバックとして選択されます。
通常の文法に対しての型クラス
Leanのほとんどの中置演算子は型クラスでオーバーライドされています。例えば、加算演算子は Add
という型クラスに対応しています。これらの演算子のほとんどには2つの引数が同じ型でなくても良い異なる型上の演算子が存在します。これらの異なる型上の演算子は HAdd
のような H
で始まるバージョンのクラスを使ってオーバーロードされます。
インデックス構文は GetElem
という型クラスを使ってオーバーロードされます。GetElem
には2つの出力パラメータがあり、コレクションから抽出される要素の型と、添え字の値がコレクションの範囲内にあることの根拠となるものを決定するために使用できる関数の2つです。この根拠は命題として記述され、インデックス記法が使用されると、Leanはこの命題の証明を試みます。Leanがコンパイル時にリストや配列のアクセス操作が境界内にあることをチェックできない場合、インデックス操作に ?
を追加することでこのチェックを実行時に行うように遅延させることができます。
関手
関手とは、マッピング操作をサポートする多相型です。このマッピング操作は、すべての要素を「データ中のその位置で」変換し、ほかの構造は変更しません。例えば、リストは関手であり、マッピング操作はリスト内の要素を削除したり、重複させたり、並べ替えたりすることはできません。
関手は map
を持つことで定義されますが、Leanの Functor
型クラスにはそれ以外にも、定数関数を値にマッピングするデフォルトメソッドが追加されており、多相型変数で与えられた型を持つすべての値を同じ新しい値で置き換えます。関手によっては、これは構造体全体を走査するよりも効率的に行うことができます。
インスタンスの導出
多くの型クラスはとても標準的な実装を持っています。例えば、真偽値の同値クラス BEq
は通常、まず両方の引数が同じコンストラクタでビルドされているかどうかをチェックし、次にすべての引数が等しいかどうかをチェックすることで実装されています。これらのクラスのインスタンスは 自動的に 生成されます。
帰納型や構造体を定義するとき、宣言の最後に deriving
節を記述すると、自動的にインスタンスが生成されます。さらに deriving instance ... for ...
コマンドをデータ型の定義の外側で使用すると、インスタンスを生成することができます。インスタンスを導出させることができるクラスはそれぞれ特別な処理を必要とするため、すべてのクラスが導出できるわけではありません。
型強制
期待された型と異なる型を指定した際に、通常であればコンパイル時にエラーとなるところを、Leanではある型から別の型にデータを変換する関数への呼び出しを挿入する型強制によってエラーを回避できます。例えば、任意の型 α
から Option α
型への強制は、some
コンストラクタではなく、値を直接書くことを可能にし、Option
をオブジェクト指向言語のnullable型のように使うことができます。
強制には複数の種類があります。これらは異なる種類のエラーから回復することができ、それぞれの型クラスで表現されます。Coe
クラスは型エラーを回復するために使われます。Leanが β
型を期待するコンテキストに α
型の式を置くと、Leanはまず α
型を β
型に変換できるような強制の連鎖を通そうと試み、それができなかった場合にのみエラーを表示します。CoeDep
クラスは、強制される特定の値を追加パラメータとして受け取り、その値に対してさらに型クラスの検索を行うか、インスタンス内でコンストラクタを使用して変換の範囲を限定することができます。CoeFun
クラスは、関数適用をコンパイルする際に「not a function」エラーとなるような値を途中で捕まえ、可能であれば関数の位置の値を実際の関数に変換できるようにします。
モナド
C#とKotlinでは、?.
演算子でnullの可能性がある値に対してプロパティを検索したりメソッドを呼び出したりすることができます。レシーバが null
の場合、式全体がnullになります。それ以外の場合、もとの null
でない値が呼び出しを受けます。?.
は連鎖して使用することができ、この場合一番初めに現れる null
の結果によって一連の処理が終了します。このようにnullチェックの連鎖させ方は、ネストの深い if
文を書いたりメンテしたりするよりもずっと便利です。
同様に、例外はエラーコードを手作業でチェックしたり伝播させたりするよりもはるかに便利です。また、ロギングは各関数がログの結果と戻り値の両方を返すのではなく、専用のロギングフレームワークを持つことで最も簡単に実現できます。連鎖したnullチェックと例外は、通常、言語設計者側にこのユースケースを予測する必要がある一方で、ロギングフレームワークでは通常、ログの蓄積からロギングを行うコードを切り離すために副作用を利用します。
これらの機能やその他はすべて、Monad
と呼ばれる共通のAPIのインスタンスとしてライブラリ化することができます。LeanはこのAPIを使うための専用の構文を提供しますが、裏で何が起こっているのかを理解することを阻害してもいます。この章では、手作業によるnullチェックのネストに関する細かい説明から始まり、そこから便利で一般的なAPIへと発展していきます。信じがたい読者におかれましては、ぜひその疑念を抱いたまま読み進めてください。
none
チェック:DRY原則
Leanにおいて、nullチェックをパターンマッチで連鎖させることができます。リストの最初の要素の取得は、ただインデックス記法を使うだけで可能です:
def first (xs : List α) : Option α :=
xs[0]?
空のリストは最初の要素を持たないため、この結果は Option
型となります。最初と3番目の要素を取り出すには、それぞれ none
ではないことをチェックする必要があります:
def firstThird (xs : List α) : Option (α × α) :=
match xs[0]? with
| none => none
| some first =>
match xs[2]? with
| none => none
| some third =>
some (first, third)
同様に、1番目と3番目、5番目の要素の取得ではさらに none
でないことのチェックが増えます:
def firstThirdFifth (xs : List α) : Option (α × α × α) :=
match xs[0]? with
| none => none
| some first =>
match xs[2]? with
| none => none
| some third =>
match xs[4]? with
| none => none
| some fifth =>
some (first, third, fifth)
そしてこの流れで7番目の要素の取得を追加するといよいよ手に負えなくなってきます:
def firstThirdFifthSeventh (xs : List α) : Option (α × α × α × α) :=
match xs[0]? with
| none => none
| some first =>
match xs[2]? with
| none => none
| some third =>
match xs[4]? with
| none => none
| some fifth =>
match xs[6]? with
| none => none
| some seventh =>
some (first, third, fifth, seventh)
このコードでは、数字の抽出と数字がすべて存在していることのチェックという2つの関心事に対処しようとしています。しかし、2つ目の関心事には none
のケースを処理するコードをコピペすることで対処しています。これがこのコードの根本的な問題です。プログラミングにおいて、一般的に繰り返し処理を補助関数として抽出することは良い習慣です:
def andThen (opt : Option α) (next : α → Option β) : Option β :=
match opt with
| none => none
| some x => next x
この補助関数はC#やKotlinの ?.
と同じように使用され、none
値の伝播を行います。この関数は2つの引数を取ります:オプション値と、値が none
でない場合に適用する関数です。第1引数が none
の場合、この補助関数は none
を返します。第1引数が none
でない場合、第2引数の関数が some
コンストラクタの中身に適用されます。
これで、firstThird
はパターンマッチの代わりに andThen
を使って書き換えることができます:
def firstThird (xs : List α) : Option (α × α) :=
andThen xs[0]? fun first =>
andThen xs[2]? fun third =>
some (first, third)
Leanで関数に引数を渡す際には括弧を付ける必要はありません。以下は括弧と関数の本体にインデントを付けた同等の定義です:
def firstThird (xs : List α) : Option (α × α) :=
andThen xs[0]? (fun first =>
andThen xs[2]? (fun third =>
some (first, third)))
andThen
補助関数は値が流れる「パイプライン」のようなものを提供します。この事実は上記のやや変わったインデントバージョンでよりはっきりします。andThen
を記述する構文を改善することで、これらの計算をさらに理解しやすくすることができます。
中置演算子
Leanでは、infix
、infixl
、infixr
コマンドを使って、それぞれ非結合、左結合、右結合演算子を宣言することができます。左結合 演算子が連続して複数回使用されると、式の左側に括弧が積みあがっていきます。加算演算子 +
は左結合であるため、w + x + y + z
は (((w + x) + y) + z)
と同等です。指数演算子 ^
は右結合であるため、w ^ x ^ y ^ z
は (w ^ (x ^ (y ^ z)))
と同等です。<
などの比較演算子は非結合演算子であるため、x < y < z
は構文エラーであり、手作業で括弧を入れる必要があります。
以下の宣言によって andThen
は中置演算子となります:
infixl:55 " ~~> " => andThen
コロンの後ろの数字は新しい中置演算子の 優先順位 を宣言しています。通常の数学表記において +
と *
はどちらも左結合ですが、x + y * z
は x + (y * z)
と等価です。Leanでは、+
の優先順位は65で、*
の優先順位は70です。優先順位の高い演算子は低い演算子よりも前に適用されます。~~>
の宣言から、+
と *
はどちらもこの演算子よりも優先順位が高いため、+
と *
が先に適用されます。一般的に、演算子の集まりに対して最も便利な優先順位を定義するには、いろんなパターンを何度も試して経験値をためる必要があります。
新しい中置演算子の後ろに続くのは二重矢印 =>
で、これは中置演算子に使用する名前付き関数を指定します。Leanの標準ライブラリはこの機能を使って +
と *
をそれぞれ HAdd.hAdd
と HMul.hMul
を指し示す中置演算子として定義しており、型クラスを使ってこれらの中置演算子をオーバーロードできるようにしています。しかし、ここでは andThen
は普通の関数です。
andThen
のために中置演算子を定義することで、firstThird
は none
チェックの「パイプライン」感を前面に押し出した形で書き直すことができます:
def firstThirdInfix (xs : List α) : Option (α × α) :=
xs[0]? ~~> fun first =>
xs[2]? ~~> fun third =>
some (first, third)
このスタイルの方は大きな関数の記述をより簡潔にします:
def firstThirdFifthSeventh (xs : List α) : Option (α × α × α × α) :=
xs[0]? ~~> fun first =>
xs[2]? ~~> fun third =>
xs[4]? ~~> fun fifth =>
xs[6]? ~~> fun seventh =>
some (first, third, fifth, seventh)
エラーメッセージの伝播
Leanのような純粋関数型言語には、エラー処理のための例外アルゴリズムが組み込まれていません。なぜなら、例外をスローしたりキャッチしたりすることは式のステップバイステップな評価モデルの範囲外であるからです。しかし、関数型プログラムでもエラー処理は必要です。firstThirdFifthSeventh
の場合、リストがどれくらいの長さで、どこで検索に失敗したかを知ることはユーザにとって重要でしょう。
この実現方法として、エラーと計算結果のどちらにもなりうるデータ型を定義し、例外を持つ関数をこのデータ型を返す関数に変換することが通例です:
inductive Except (ε : Type) (α : Type) where
| error : ε → Except ε α
| ok : α → Except ε α
deriving BEq, Hashable, Repr
型変数 ε
は関数が発生させる可能性のあるエラーの型を表します。呼び出し元はエラーと成功の両方を処理することが期待されているため、型変数 ε
はJavaのチェックされた例外のリストのような役割を果たします。
Option
と同様に、Except
はリスト内のエントリが見つからなかった場合に使用することができます。この場合のエラーの型は String
です:
def get (xs : List α) (i : Nat) : Except String α :=
match xs[i]? with
| none => Except.error s!"Index {i} not found (maximum is {xs.length - 1})"
| some x => Except.ok x
範囲内の値の検索は Except.ok
を出力します:
def ediblePlants : List String :=
["ramsons", "sea plantain", "sea buckthorn", "garden nasturtium"]
#eval get ediblePlants 2
Except.ok "sea buckthorn"
範囲外の値の検索は Except.error
を出力します:
#eval get ediblePlants 4
Except.error "Index 4 not found (maximum is 3)"
1回のリストの検索はこれで結果かエラーの返却を便利にします:
def first (xs : List α) : Except String α :=
get xs 0
しかし、2回の検索となると潜在的なエラーの対応が要求されます:
def firstThird (xs : List α) : Except String (α × α) :=
match get xs 0 with
| Except.error msg => Except.error msg
| Except.ok first =>
match get xs 2 with
| Except.error msg => Except.error msg
| Except.ok third =>
Except.ok (first, third)
リストの検索を追加すると、必要なエラー処理も増えます:
def firstThirdFifth (xs : List α) : Except String (α × α × α) :=
match get xs 0 with
| Except.error msg => Except.error msg
| Except.ok first =>
match get xs 2 with
| Except.error msg => Except.error msg
| Except.ok third =>
match get xs 4 with
| Except.error msg => Except.error msg
| Except.ok fifth =>
Except.ok (first, third, fifth)
ここにまたリスト検索を追加するとかなり手に負えなくなってきます:
def firstThirdFifthSeventh (xs : List α) : Except String (α × α × α × α) :=
match get xs 0 with
| Except.error msg => Except.error msg
| Except.ok first =>
match get xs 2 with
| Except.error msg => Except.error msg
| Except.ok third =>
match get xs 4 with
| Except.error msg => Except.error msg
| Except.ok fifth =>
match get xs 6 with
| Except.error msg => Except.error msg
| Except.ok seventh =>
Except.ok (first, third, fifth, seventh)
繰り返しになりますが、よくあるパターンを補助関数にくくりだすことができます。関数の各ステップでエラーチェックが行われ、成功した場合のみ後続の計算が行われます。Except
のために andThen
の新しいバージョンを以下のように定義することができます:
def andThen (attempt : Except e α) (next : α → Except e β) : Except e β :=
match attempt with
| Except.error msg => Except.error msg
| Except.ok x => next x
Option
とまったく同じように、このバージョンの andThen
は firstThird
のより簡潔な定義を提供します:
def firstThird' (xs : List α) : Except String (α × α) :=
andThen (get xs 0) fun first =>
andThen (get xs 2) fun third =>
Except.ok (first, third)
Option
と Except
のどちらの場合も、2つのパターンが繰り返されています:1つは各ステップでの中間結果のチェックで、これは andThen
にくくりだされています。もう1つは最終的な成功結果であり、それぞれ some
と Except.ok
で表されます。便宜上、成功は ok
という補助関数にくくりだすことができます:
def ok (x : α) : Except ε α := Except.ok x
同じように、失敗も fail
という補助関数にくくりだすことが可能です:
def fail (err : ε) : Except ε α := Except.error err
ok
と fail
を使うことで、get
はもうちょっと読みやすくなります:
def get (xs : List α) (i : Nat) : Except String α :=
match xs[i]? with
| none => fail s!"Index {i} not found (maximum is {xs.length - 1})"
| some x => ok x
andThen
のための中置演算子を追加することで、firstThird
は Option
を返すバージョンと同じくらい簡潔になります:
infixl:55 " ~~> " => andThen
def firstThird (xs : List α) : Except String (α × α) :=
get xs 0 ~~> fun first =>
get xs 2 ~~> fun third =>
ok (first, third)
このテクニックはより大きい関数に対しても同様にスケールアップします:
def firstThirdFifthSeventh (xs : List α) : Except String (α × α × α × α) :=
get xs 0 ~~> fun first =>
get xs 2 ~~> fun third =>
get xs 4 ~~> fun fifth =>
get xs 6 ~~> fun seventh =>
ok (first, third, fifth, seventh)
ロギング
ある数値が偶であるとは、2で割った時に余りがないことです:
def isEven (i : Int) : Bool :=
i % 2 == 0
関数 sumAndFindEvens
は総和を計算しつつ、リストを進みながら偶数のみを記憶します:
def sumAndFindEvens : List Int → List Int × Int
| [] => ([], 0)
| i :: is =>
let (moreEven, sum) := sumAndFindEvens is
(if isEven i then i :: moreEven else moreEven, sum + i)
この関数はプログラミングにおけるよくあるパターンのシンプルな例です。多くのプログラムはデータ構造を一度走査し、メインの結果を計算しながら脇役的な結果を蓄積する必要があります。この例の1つがロギングです:IO
アクションであるプログラムは常にログをディスク上のファイルに記録することができますが、ディスクはLeanの関数の数学的世界の外側にあるため、IO
に基づくログについて証明することは非常に難しくなります。別の例として、木に含まれるすべてのノードの総和を通りがけ順で計算しながら、同時に通過した各ノードを記録するような関数があります:
def inorderSum : BinTree Int → List Int × Int
| BinTree.leaf => ([], 0)
| BinTree.branch l x r =>
let (leftVisited, leftSum) := inorderSum l
let (hereVisited, hereSum) := ([x], x)
let (rightVisited, rightSum) := inorderSum r
(leftVisited ++ hereVisited ++ rightVisited, leftSum + hereSum + rightSum)
sumAndFindEvens
と inorderSum
のどちらも共通の繰り返し構造を持ちます。計算の各ステップはメインの結果とそれに沿って貯めたデータのリストからなるペアを返却します。その後これらのリストは結合され、計算されたメインの結果とペアにされます。sumAndFindEvens
を少し書き換えるだけで偶数の保存と合計の計算をよりきれいに分けた共通の構造がよりはっきりします:
def sumAndFindEvens : List Int → List Int × Int
| [] => ([], 0)
| i :: is =>
let (moreEven, sum) := sumAndFindEvens is
let (evenHere, ()) := (if isEven i then [i] else [], ())
(evenHere ++ moreEven, sum + i)
この明確さのおかげで、値と蓄積された結果からなるペアには固有の名前を与えることできます:
structure WithLog (logged : Type) (α : Type) where
log : List logged
val : α
同様に、計算の次のステップへ値を渡す際に蓄積された結果のリストを保存するプロセスは、再び andThen
という名前の補助関数にくくりだすことができます:
def andThen (result : WithLog α β) (next : β → WithLog α γ) : WithLog α γ :=
let {log := thisOut, val := thisRes} := result
let {log := nextOut, val := nextRes} := next thisRes
{log := thisOut ++ nextOut, val := nextRes}
結果がエラーである場合、ok
は常に成功する操作を表します。しかし、ここではログに何も記録せずにそのまま値を返す操作です:
def ok (x : β) : WithLog α β := {log := [], val := x}
Except
が処理の不確実さとして fail
を提供するように、WithLog
はアイテムをログに追加できるようにします。この操作には特に意味のある戻り値がないため、Unit
を返却します:
def save (data : α) : WithLog α Unit :=
{log := [data], val := ()}
WithLog
と andThen
、ok
、save
によってロギングという関心事から総和の関心事の分離を以下のプログラムによって実現できます:
def sumAndFindEvens : List Int → WithLog Int Int
| [] => ok 0
| i :: is =>
andThen (if isEven i then save i else ok ()) fun () =>
andThen (sumAndFindEvens is) fun sum =>
ok (i + sum)
def inorderSum : BinTree Int → WithLog Int Int
| BinTree.leaf => ok 0
| BinTree.branch l x r =>
andThen (inorderSum l) fun leftSum =>
andThen (save x) fun () =>
andThen (inorderSum r) fun rightSum =>
ok (leftSum + x + rightSum)
ここでもまた、中置演算子が正しいステップに焦点を合わせるのに役立ちます:
infixl:55 " ~~> " => andThen
def sumAndFindEvens : List Int → WithLog Int Int
| [] => ok 0
| i :: is =>
(if isEven i then save i else ok ()) ~~> fun () =>
sumAndFindEvens is ~~> fun sum =>
ok (i + sum)
def inorderSum : BinTree Int → WithLog Int Int
| BinTree.leaf => ok 0
| BinTree.branch l x r =>
inorderSum l ~~> fun leftSum =>
save x ~~> fun () =>
inorderSum r ~~> fun rightSum =>
ok (leftSum + x + rightSum)
木構造のノードへの採番
木の 通り順 (inorder numbering)は木の中の各データポイントに、その木を通り順に訪れた際の各ステップを割り当てます。例として以下の木 aTree
を考えてみましょう:
open BinTree in
def aTree :=
branch
(branch
(branch leaf "a" (branch leaf "b" leaf))
"c"
leaf)
"d"
(branch leaf "e" leaf)
これの通り順は以下のようになります:
BinTree.branch
(BinTree.branch
(BinTree.branch (BinTree.leaf) (0, "a") (BinTree.branch (BinTree.leaf) (1, "b") (BinTree.leaf)))
(2, "c")
(BinTree.leaf))
(3, "d")
(BinTree.branch (BinTree.leaf) (4, "e") (BinTree.leaf))
木は再帰関数で処理することが最も自然ですが、木における通常の再帰パターンでは通り順の採番を計算することは難しいです。これは、木の左側の部分木のどこかに割り当てられる最大の番号がノードのデータ値の採番を決定することに使われ、さらに右の部分木の採番の開始位置を決定することに使われるからです。命令型言語では、この問題は次に割り当てる番号を格納する可変変数を使用することで回避できます。次のPythonプログラムでは、可変変数を使って通り順の採番を計算します:
class Branch:
def __init__(self, value, left=None, right=None):
self.left = left
self.value = value
self.right = right
def __repr__(self):
return f'Branch({self.value!r}, left={self.left!r}, right={self.right!r})'
def number(tree):
num = 0
def helper(t):
nonlocal num
if t is None:
return None
else:
new_left = helper(t.left)
new_value = (num, t.value)
num += 1
new_right = helper(t.right)
return Branch(left=new_left, value=new_value, right=new_right)
return helper(tree)
aTree
に相当するPythonの採番対象は以下のようになり:
a_tree = Branch("d",
left=Branch("c",
left=Branch("a", left=None, right=Branch("b")),
right=None),
right=Branch("e"))
これの採番結果は以下になります:
>>> number(a_tree)
Branch((3, 'd'), left=Branch((2, 'c'), left=Branch((0, 'a'), left=None, right=Branch((1, 'b'), left=None, right=None)), right=None), right=Branch((4, 'e'), left=None, right=None))
Leanには可変変数が無いにもかかわらず、回避策が存在します。違う視点から見てみると、可変変数は2つの関連した側面を持っていると考えることができます:すなわち、関数が呼び出された時の値とその関数が終了した時点での値です。言い換えれば、可変変数を使う関数は、可変変数の初期値を引数として取り、その変数の最終結果と関数の結果のペアを返す関数とみなすことができます。この最終値は次のステップの引数として渡すことができます。
Pythonの例で可変変数を宣言している外部関数と、その変数を更新する内部の補助関数が使われているように、この関数は、変数の初期値を提供し、採番された木の計算中に変数の値をスレッドする内部の補助関数の結果を明示的に返す外部関数としてLean上で実装できます:
def number (t : BinTree α) : BinTree (Nat × α) :=
let rec helper (n : Nat) : BinTree α → (Nat × BinTree (Nat × α))
| BinTree.leaf => (n, BinTree.leaf)
| BinTree.branch left x right =>
let (k, numberedLeft) := helper n left
let (i, numberedRight) := helper (k + 1) right
(i, BinTree.branch numberedLeft (k, x) numberedRight)
(helper 0 t).snd
このコードは none
を伝播する Option
コードと error
を伝播する Except
コード、ログを蓄積する WithLog
コードと同じように2つの関心事が混在しています:カウンタの値の伝播と、実際に木を走査して結果を見つけることです。これらの場合と同じように、andThen
補助関数を定義することで、計算のあるステップから別のステップに状態を伝播させることができます。このためにまず初めに行うことは、引数として入力状態を受け取り、値と一緒に出力状態を返すパターンに名前を付けることです:
def State (σ : Type) (α : Type) : Type :=
σ → (σ × α)
State
の場合、ok
は入力状態を変更せずに、与えられた値と一緒に返却する関数です:
def ok (x : α) : State σ α :=
fun s => (s, x)
可変変数を扱う場合、値の読み取りと新しい値での更新との2つの基本的な演算があります。現在の値の読み取りは、入力状態を変更せずに出力状態に置き、さらにその値を値フィールドに置く関数として実装できます:
def get : State σ σ :=
fun s => (s, s)
新しい値の書き込みは、入力状態を無視し、与えられた値を出力状態に設定することで構成されます:
def set (s : σ) : State σ Unit :=
fun _ => (s, ())
最終的に、状態を使用する2つの計算は、まず最初の関数の出力状態と戻り値を取得し、それらを次の関数に渡すことでつなげることができます:
def andThen (first : State σ α) (next : α → State σ β) : State σ β :=
fun s =>
let (s', x) := first s
next x s'
infixl:55 " ~~> " => andThen
State
とその補助関数を使うことで、局所的な可変変数をシミュレートすることができます:
def number (t : BinTree α) : BinTree (Nat × α) :=
let rec helper : BinTree α → State Nat (BinTree (Nat × α))
| BinTree.leaf => ok BinTree.leaf
| BinTree.branch left x right =>
helper left ~~> fun numberedLeft =>
get ~~> fun n =>
set (n + 1) ~~> fun () =>
helper right ~~> fun numberedRight =>
ok (BinTree.branch numberedLeft (n, x) numberedRight)
(helper t 0).snd
State
は1つの可変変数のみをシミュレートするため、get
と set
の呼び出しで特定の変数名を指定する必要がありません。
モナド:関数的デザインパターン
これらの例は以下のようにまとめられます:
Option
やExcept ε
、WithLog logged
、State σ
などの多相型- この型を持つプログラム列における繰り返される側面のケアを行う
andThen
演算 - この型を扱う中で(ある意味で)もっとも退屈な方法である
ok
演算 - その他、この型を用いる
none
やfail
、save
、get
などの演算のあつまり
このAPIのスタイルは モナド (monad)と呼ばれます。モナドの考え方は圏論と呼ばれる数学の1分野から派生したものですが、プログラミングで用いるにあたって圏論の理解は必要ありません。モナドの重要な考え方は、純粋関数型言語であるLeanが提供するツールを用いることで、それぞれのモナドが特定の種類の副作用をエンコードするということです。例えば、Option
は none
を返して失敗するプログラムを表し、Except
は例外を投げるプログラムを、WithLog
は実行中にログを蓄積するプログラムを、State
は単一の可変変数を持つプログラムをそれぞれ表します。
モナド型クラス
モナドの型ごとに ok
や andThen
のような演算子を導入する代わりに、Lean標準ライブラリにはそれらをオーバーロードできる型クラスがあるため、同じ演算子を 任意の モナドに使うことができます。モナドには ok
と andThen
に相当する2つの演算があります:
class Monad (m : Type → Type) where
pure : α → m α
bind : m α → (α → m β) → m β
この定義は実態より若干簡略化されています。Leanのライブラリにおける実際の定義はもう少し複雑ですが、それは後程紹介しましょう。
Option
と Except
の Monad
インスタンスは、それぞれの andThen
演算を適合させることで作成できます:
instance : Monad Option where
pure x := some x
bind opt next :=
match opt with
| none => none
| some x => next x
instance : Monad (Except ε) where
pure x := Except.ok x
bind attempt next :=
match attempt with
| Except.error e => Except.error e
| Except.ok x => next x
例として出した firstThirdFifthSeventh
は Option α
と Except String α
の戻り値型に対して個別に定義されていました。しかし今やこの関数を 任意の モナドに多相的に定義することができます。ただし、別のモナドによる異なる実装では結果を見つけることができないかもしれないため、引数としてルックアップ関数が必要です。例で出した ~~>
と同じ役割を果たしていた bind
の中置バージョンは >>=
です。
def firstThirdFifthSeventh [Monad m] (lookup : List α → Nat → m α) (xs : List α) : m (α × α × α × α) :=
lookup xs 0 >>= fun first =>
lookup xs 2 >>= fun third =>
lookup xs 4 >>= fun fifth =>
lookup xs 6 >>= fun seventh =>
pure (first, third, fifth, seventh)
この firstThirdFifthSeventh
の実装は、のろい哺乳類と速い鳥の名前のリストを例として Option
について利用することが可能です:
def slowMammals : List String :=
["Three-toed sloth", "Slow loris"]
def fastBirds : List String := [
"Peregrine falcon",
"Saker falcon",
"Golden eagle",
"Gray-headed albatross",
"Spur-winged goose",
"Swift",
"Anna's hummingbird"
]
#eval firstThirdFifthSeventh (fun xs i => xs[i]?) slowMammals
none
#eval firstThirdFifthSeventh (fun xs i => xs[i]?) fastBirds
some ("Peregrine falcon", "Golden eagle", "Spur-winged goose", "Anna's hummingbird")
Except
のルックアップ関数 get
をもう少し実態に即したものに名称変更することで、先ほどと全く同じ firstThirdFifthSeventh
の実装を Except
についても使うことができます:
def getOrExcept (xs : List α) (i : Nat) : Except String α :=
match xs[i]? with
| none => Except.error s!"Index {i} not found (maximum is {xs.length - 1})"
| some x => Except.ok x
#eval firstThirdFifthSeventh getOrExcept slowMammals
Except.error "Index 2 not found (maximum is 1)"
#eval firstThirdFifthSeventh getOrExcept fastBirds
Except.ok ("Peregrine falcon", "Golden eagle", "Spur-winged goose", "Anna's hummingbird")
m
が Monad
インスタンスを持っていなければならないということは、>>=
と pure
演算が利用できるということを意味します。
良く使われるモナドの演算
実に多くの型がモナドであるため、任意の モナドに対して多相性を持つ関数は非常に強力です。例えば、関数 mapM
はモナド用の map
で、Monad
を使って関数を適用した結果を順番に並べたり組み合わせたりします:
def mapM [Monad m] (f : α → m β) : List α → m (List β)
| [] => pure []
| x :: xs =>
f x >>= fun hd =>
mapM f xs >>= fun tl =>
pure (hd :: tl)
関数 f
の戻り値の型によって、どの Monad
インスタンスを使うかが決まります。つまり、mapM
はログを生成する関数や失敗する可能性のある関数、可変状態を使用する関数などのどれにでも使用することができるのです。f
の型が利用可能な作用を決定するため、API設計者はその作用を厳密に制御することができます。
本章の導入 で説明したように、State σ α
という型は σ
型の可変変数を使用し、α
型の値を返すプログラムを表します。これらのプログラムは、実際には初期状態から値と最終状態のペアへの関数です。Monad
クラスは引数がただの型であることを求めます。つまり、Monad
となる型は Type → Type
であることを要求されます。したがって State
のインスタンスは状態の型 σ
を指定しておく必要があり、これがインスタンスのパラメータになることを意味します:
instance : Monad (State σ) where
pure x := fun s => (s, x)
bind first next :=
fun s =>
let (s', x) := first s
next x s'
これは bind
を使って get
と set
を連続して呼び出している間において状態の型を変更できないことを意味し、ステートフルな計算の規則として理にかなっています。increment
演算子は保存された状態を指定された量だけ増価させ、古い値を返します:
def increment (howMuch : Int) : State Int Int :=
get >>= fun i =>
set (i + howMuch) >>= fun () =>
pure i
mapM
を increment
と一緒に使うと、リスト内の要素の合計を計算するプログラムになります。より具体的には、可変変数には最終的な合計が格納され、戻り値のリストには実行中の各合計が格納されます。言い換えると、mapM increment
は List Int → State Int (List Int)
型を持ち、State
の定義を展開すると List Int → Int → (Int × List Int)
が得られます。これらは引数として合計の初期値を引数に取り、これは 0
でなければなりません:
#eval mapM increment [1, 2, 3, 4, 5] 0
(15, [0, 1, 3, 6, 10])
ロギングの作用 はWithLog
を使って表現することができました。State
と同様に、その Monad
インスタンスはログに記録されているデータの型に対して多相的です:
instance : Monad (WithLog logged) where
pure x := {log := [], val := x}
bind result next :=
let {log := thisOut, val := thisRes} := result
let {log := nextOut, val := nextRes} := next thisRes
{log := thisOut ++ nextOut, val := nextRes}
saveIfEven
は偶数についてログを出力して、引数について何もせずに返却する関数です:
def saveIfEven (i : Int) : WithLog Int Int :=
(if isEven i then
save i
else pure ()) >>= fun () =>
pure i
この関数を mapM
と一緒に使うと、偶数についてのログと変更されていないリストがペアになった結果が得られます:
#eval mapM saveIfEven [1, 2, 3, 4, 5]
{ log := [2, 4], val := [1, 2, 3, 4, 5] }
恒等モナド
モナドは失敗、例外、ロギングなどの作用を持つプログラムをデータや関数として明示的に表現します。しかし、柔軟性のためにモナドを使用するように設計されたAPIに対して、時にはエンコードされた作用を必要としない利用者もいます。恒等モナド (identity monad)は作用を持たないモナドであり、純粋なコードをモナドAPIで使用することを可能にします:
def Id (t : Type) : Type := t
instance : Monad Id where
pure x := x
bind x f := f x
pure
の型は α → Id α
であるべきですが、Id α
はただの α
に簡約されます。同様に、bind
の型は α → (α → Id β) → Id β
であるべきです。これは α → (α → β) → β
に簡約されるため、2番目の引数を1番目の引数に適用して結果を求めることができます。
恒等関手を使うことで、mapM
は map
と同じものになります。ただし、このように呼ぶにあたって、Leanは意図したモナドが Id
であることのヒントを要求します:
#eval mapM (m := Id) (· + 1) [1, 2, 3, 4, 5]
[2, 3, 4, 5, 6]
ヒントを無くすとエラーになります:
#eval mapM (· + 1) [1, 2, 3, 4, 5]
failed to synthesize instance
HAdd Nat Nat (?m.9063 ?m.9065)
このエラーでは、あるメタ変数を別の変数に適用することで、Leanが型レベルの計算を逆方向に実行していないことを示しています。この関数の戻り値の型は、ほかの型に適用されたモナドであることが期待されます。同様に、型がどのモナドを使用するかについての具体的なヒントを提供しない関数で mapM
を使用すると、「インスタンス問題のスタック」というメッセージが表示されます:
#eval mapM (fun x => x) [1, 2, 3, 4, 5]
typeclass instance problem is stuck, it is often due to metavariables
Monad ?m.9063
モナドの約定
Beq
と Hashable
のインスタンスのすべてのペアが等しい2つの値が同じハッシュ値を持つことを保証しなければならないように、Monad
のインスタンスにも従うべき約定が存在します。まず、pure
は bind
の左単位でなければなりません。つまり、bind (pure v) f
は f v
と同じでなければなりません。次に、pure
は bind
の右単位でもあるべきで、bind v pure
は v
と同じとなります。最後に、bind
は結合的であるべきで、bind (bind v f) g
は bind v (fun x => bind (f x) g)
と同じです。
この約定はより一般的に作用を持つプログラムに期待される特性を規定しています。pure
は作用を持たないため、bind
で作用を続けても結果は変わらないはずです。bind
の結合性は、基本的に物事が起こっている順序が保たれていれば、処理の実行順番自体はどう行っても問題ないことを言っています。
演習問題
木へのマッピング
関数 BinTree.mapM
を定義してください。リストの mapM
と同様に、この関数は木の各データ要素に行きがけ順でモナド関数を適用する必要があります。型シグネチャは以下のようになります:
def BinTree.mapM [Monad m] (f : α → m β) : BinTree α → m (BinTree β)
Optionモナドの約定
まず Option
の Monad
インスタンスがモナドの約定を満たすことへの根拠を書き出してください。次に、以下のインスタンスを考えてみましょう:
instance : Monad Option where
pure x := some x
bind opt next := none
どちらのメソッドも正しい型です。ではなぜこのインスタンスはモナドの約定を破っているのでしょうか?
例:モナドにおける算術
モナドは副作用のない言語で副作用のあるプログラムをエンコードする手段です。この事実を以って、純粋関数型言語で普通のプログラムを書くために大変面倒な手続きを踏まなければならないために、純粋関数型プログラムには重要なものが欠けていると認めているようなものだと考えるのは容易でしょう。しかし、Monad
のAPIを使うことはプログラムに構文上のコストを課すことになる一方で2つの重要な利点をもたらします:
- プログラムはその型の中に、どの作用を使うか使うかについて表明しなければなりません。型シグネチャをざっと見ただけで、そのプログラムが何を受け入れ、何を返すかだけでなく、そのプログラムができることの すべて が記述されます。
- すべての言語で同じ作用が提供されているとは限りません。例えば、一部の言語だけが例外の機構を持ちます。他の言語には Icon言語における複数値検索 や、SchemeやRubyの継続などのようなユニークで独特な作用が存在します。モナドは どんな 作用でもエンコードできるため、プログラマは言語開発者が提供する作用に囚われることなく与えられたアプリケーションに最適な作用を選ぶことができます。
さまざまなモナドで意味をもつプログラムの一例として、算術式の評価器があります。
算術式
算術式は整数値のリテラルか2つの式に適用されるプリミティブな二項演算子です。演算子は加算、減算、乗算、除算からなります:
inductive Expr (op : Type) where
| const : Int → Expr op
| prim : op → Expr op → Expr op → Expr op
inductive Arith where
| plus
| minus
| times
| div
2 + 3
という式は以下のように表現されます:
open Expr in
open Arith in
def twoPlusThree : Expr Arith :=
prim plus (const 2) (const 3)
また、14 / (45 - 5 * 9)
は以下のように表現されます:
open Expr in
open Arith in
def fourteenDivided : Expr Arith :=
prim div (const 14) (prim minus (const 45) (prim times (const 5) (const 9)))
式の評価
式の中に除算が含まれ、かつ0による除算は未定義であるため、評価には失敗の可能性があります。失敗の表現の手段の1つとして、Option
を使うことができます:
def evaluateOption : Expr Arith → Option Int
| Expr.const i => pure i
| Expr.prim p e1 e2 =>
evaluateOption e1 >>= fun v1 =>
evaluateOption e2 >>= fun v2 =>
match p with
| Arith.plus => pure (v1 + v2)
| Arith.minus => pure (v1 - v2)
| Arith.times => pure (v1 * v2)
| Arith.div => if v2 == 0 then none else pure (v1 / v2)
この定義では Monad Option
のインスタンスを使用して、二項演算子の2つの分岐それぞれの評価による失敗を伝播します。しかし、この関数は部分式の評価と結果への二項演算子の適用という2つの関心事を混在させています。この状況は、この関数を2つの関数に分割することで改善することができます:
def applyPrim : Arith → Int → Int → Option Int
| Arith.plus, x, y => pure (x + y)
| Arith.minus, x, y => pure (x - y)
| Arith.times, x, y => pure (x * y)
| Arith.div, x, y => if y == 0 then none else pure (x / y)
def evaluateOption : Expr Arith → Option Int
| Expr.const i => pure i
| Expr.prim p e1 e2 =>
evaluateOption e1 >>= fun v1 =>
evaluateOption e2 >>= fun v2 =>
applyPrim p v1 v2
#eval evaluateOption fourteenDivided
を実行すると期待通り none
が出力されますが、これはあまり有用なエラーメッセージではありません。このコードは none
コンストラクタを明示的に扱う代わりに >>=
を使って書かれているため、失敗時にエラーメッセージを表示するために必要なのはわずかな修正だけです:
def applyPrim : Arith → Int → Int → Except String Int
| Arith.plus, x, y => pure (x + y)
| Arith.minus, x, y => pure (x - y)
| Arith.times, x, y => pure (x * y)
| Arith.div, x, y =>
if y == 0 then
Except.error s!"Tried to divide {x} by zero"
else pure (x / y)
def evaluateExcept : Expr Arith → Except String Int
| Expr.const i => pure i
| Expr.prim p e1 e2 =>
evaluateExcept e1 >>= fun v1 =>
evaluateExcept e2 >>= fun v2 =>
applyPrim p v1 v2
変更点は、型シグネチャが Option
ではなく Except String
に言及していることと、失敗した場合に none
ではなく Except.error
を使用していることだけです。evaluate
をモナドに対して多相にし、引数として applyPrim
を渡すことで、1つの評価器で両方の形式でのエラー報告ができるようになります:
def applyPrimOption : Arith → Int → Int → Option Int
| Arith.plus, x, y => pure (x + y)
| Arith.minus, x, y => pure (x - y)
| Arith.times, x, y => pure (x * y)
| Arith.div, x, y =>
if y == 0 then
none
else pure (x / y)
def applyPrimExcept : Arith → Int → Int → Except String Int
| Arith.plus, x, y => pure (x + y)
| Arith.minus, x, y => pure (x - y)
| Arith.times, x, y => pure (x * y)
| Arith.div, x, y =>
if y == 0 then
Except.error s!"Tried to divide {x} by zero"
else pure (x / y)
def evaluateM [Monad m] (applyPrim : Arith → Int → Int → m Int): Expr Arith → m Int
| Expr.const i => pure i
| Expr.prim p e1 e2 =>
evaluateM applyPrim e1 >>= fun v1 =>
evaluateM applyPrim e2 >>= fun v2 =>
applyPrim p v1 v2
これを applyPrimOption
で使うとまさに evaluate
の最初のバージョンと同じように動きます:
#eval evaluateM applyPrimOption fourteenDivided
none
同様に、applyPrimExcept
を用いるとエラーメッセージのバージョンと同じように動作します:
#eval evaluateM applyPrimExcept fourteenDivided
Except.error "Tried to divide 14 by zero"
このコードはさらに改善できます。関数 applyPrimOption
と applyPrimExcept
の違いは除算の扱いだけであるため、除算を評価器の別のパラメータとして抽出することができます:
def applyDivOption (x : Int) (y : Int) : Option Int :=
if y == 0 then
none
else pure (x / y)
def applyDivExcept (x : Int) (y : Int) : Except String Int :=
if y == 0 then
Except.error s!"Tried to divide {x} by zero"
else pure (x / y)
def applyPrim [Monad m] (applyDiv : Int → Int → m Int) : Arith → Int → Int → m Int
| Arith.plus, x, y => pure (x + y)
| Arith.minus, x, y => pure (x - y)
| Arith.times, x, y => pure (x * y)
| Arith.div, x, y => applyDiv x y
def evaluateM [Monad m] (applyDiv : Int → Int → m Int): Expr Arith → m Int
| Expr.const i => pure i
| Expr.prim p e1 e2 =>
evaluateM applyDiv e1 >>= fun v1 =>
evaluateM applyDiv e2 >>= fun v2 =>
applyPrim applyDiv p v1 v2
このリファクタリングされたコードによって、2つのコードの実行の流れは失敗の扱いが異なるだけであるという事実がはっきりしました。
さらなる作用
評価器を扱うにあたって興味深いことは、失敗と例外だけにとどまりません。除算の唯一の副作用は失敗ですが、ほかのプリミティブな演算子を式に追加することで、ほかの作用を表現することが可能になります。
追加のリファクタリングの最初のステップとして、プリミティブのデータ型から除算を抜き出します:
inductive Prim (special : Type) where
| plus
| minus
| times
| other : special → Prim special
inductive CanFail where
| div
CanFail
という名前で、除算で導入される作用が潜在的な失敗であることを示唆します。
第二のステップは、evaluateM
に対する除算のハンドラの引数の範囲を広げて、どんな特殊演算子でも処理できるようにすることです:
def divOption : CanFail → Int → Int → Option Int
| CanFail.div, x, y => if y == 0 then none else pure (x / y)
def divExcept : CanFail → Int → Int → Except String Int
| CanFail.div, x, y =>
if y == 0 then
Except.error s!"Tried to divide {x} by zero"
else pure (x / y)
def applyPrim [Monad m] (applySpecial : special → Int → Int → m Int) : Prim special → Int → Int → m Int
| Prim.plus, x, y => pure (x + y)
| Prim.minus, x, y => pure (x - y)
| Prim.times, x, y => pure (x * y)
| Prim.other op, x, y => applySpecial op x y
def evaluateM [Monad m] (applySpecial : special → Int → Int → m Int): Expr (Prim special) → m Int
| Expr.const i => pure i
| Expr.prim p e1 e2 =>
evaluateM applySpecial e1 >>= fun v1 =>
evaluateM applySpecial e2 >>= fun v2 =>
applyPrim applySpecial p v1 v2
作用なし
Empty
型はコンストラクタを持たないため、ScalaやKotlinの Nothing
型のように値を持ちません。ScalaとKotlinでは、Nothing
型はプログラムをクラッシュさせたり、例外を投げたり、常に無限ループに陥ったりする関数のような、決して結果を返さない計算の表現に用いることができます。関数やメソッドへの Nothing
型の引数は、適切な引数値が存在しないため、デッドコードを表します。Leanは無限ループや例外をサポートしていないものの、Empty
は型システムに対して、関数を呼び出すことができないことを示すものとして有用です。E
がコンストラクタを持たない型の式である時、nomatch E
という構文を使うと、E
を絶対に呼ぶことができないことを受けて、現在の式が結果を返す必要がないことをLeanに示します。
Prim
のパラメータとして Empty
を使用することは、Prim.plus
、Prim.minus
、Prim.times
以外に追加のケースが無いことを意味します。2つの整数に Empty
型の演算子を適用する関数は決して呼び出すことができないため、この関数は結果を返す必要はありません。したがって、これは 任意の モナドで使うことができます:
def applyEmpty [Monad m] (op : Empty) (_ : Int) (_ : Int) : m Int :=
nomatch op
恒等モナドである Id
と一緒に使うことで、何の作用も持たない式を評価することができます:
open Expr Prim in
#eval evaluateM (m := Id) applyEmpty (prim plus (const 5) (const (-14)))
-9
非決定論探索
0による除算に遭遇した際に単純に失敗させる代わりに、その計算を諦めて別の入力を試すのも賢い選択でしょう。適切なモナドがあれば、同じ evaluateM
を使って失敗しない答えの 集合 を非決定論的に探索することができます。これには除算に加えて、結果の選択肢を指定する手段が必要になります。これを行う1つの方法は、失敗しない結果を探索している間に、評価者に引数のどちらかを選ぶように指示する関数 choose
を式の言語に追加することです。
これで評価器の結果は単一の値ではなく値の多重集合になります。多重集合への評価のルールは以下の通りです:
- 定数 \( n \) は単集合 \( {n} \) に評価されます。
- 除算以外の算術演算子はオペランド1のデカルト積から各ペアに対して呼び出されます。したがって \( X + Y \) は \( \{ x + y \mid x ∈ X, y ∈ Y \} \) に評価されます。
- 除算 \( X / Y \) は \( \{ x / y \mid x ∈ X, y ∈ Y, y ≠ 0\} \) に評価されます。つまり、 \( Y \) に含まれるすべての \( 0 \) の値は捨てられます。
- 選択 \( \mathrm{choose}(x, y) \) は \( \{ x, y \} \) に評価されます。
例えば、 \( 1 + \mathrm{choose}(2, 5) \) は \( \{ 3, 6 \} \) に、 \(1 + 2 / 0 \) は \( \{\} \) に、 \( 90 / (\mathrm{choose}(-5, 5) + 5) \) は \( \{ 9 \} \) にそれぞれ評価されます。集合ではなく多重集合を使うことで、要素の一意性チェックの必要性を除いてコードを単純化します。
この非決定論的な作用を表現するモナドは答えが無い状況と、少なくとも1つの答えとそれ以外の答えがある状況を表現できなければなりません:
inductive Many (α : Type) where
| none : Many α
| more : α → (Unit → Many α) → Many α
このデータ型は List
にとってもそっくりです。異なる点として、リストでは cons
で後続のリストを格納していたのに対して、more
は必要に応じて後続の値を計算する関数を格納しています。つまり、Many
の利用者は、ある程度の数の結果が見つかった時点で検索を停止することができます。
単一の結果はそれ以上の結果を返さない more
コンストラクタで表されます:
def Many.one (x : α) : Many α := Many.more x (fun () => Many.none)
計算結果の2つの多重集合の和は1つ目の多重集合が空かどうかをチェックすることによって計算できます。もし空であれば、2番目の多重集合が和の結果になります。そうでない場合、和は1つ目の多重集合の最初の要素に、1つ目の残りと2つ目の多重集合の和を続けたもので構成されます:
def Many.union : Many α → Many α → Many α
| Many.none, ys => ys
| Many.more x xs, ys => Many.more x (fun () => union (xs ()) ys)
探索プロセスをリストから始めると便利です。Many.fromList
はリストを結果の多重集合に変換します:
def Many.fromList : List α → Many α
| [] => Many.none
| x :: xs => Many.more x (fun () => fromList xs)
同様に、検索の実行結果に対して、そこから何個かの値の抽出、もしくはすべて抽出できると便利です:
def Many.take : Nat → Many α → List α
| 0, _ => []
| _ + 1, Many.none => []
| n + 1, Many.more x xs => x :: (xs ()).take n
def Many.takeAll : Many α → List α
| Many.none => []
| Many.more x xs => x :: (xs ()).takeAll
Monad Many
インスタンスは bind
演算子を必要とします。非決定論的検索において、2つの演算の紐づけは、まず1つ目のステップからすべての可能性を取り出し、それらすべてについて後続のプログラムを走らせて、得られた結果の和を取ることで構成されます。言い換えると、例えば1つ目のステップで3つの可能性が返された場合、2つ目のステップではそれらすべてを試す必要があります。2つ目のステップでは各入力について任意の数の答えが返されるため、それらの和を取ることで探索空間全体を表現することになります。
def Many.bind : Many α → (α → Many β) → Many β
| Many.none, _ =>
Many.none
| Many.more x xs, f =>
(f x).union (bind (xs ()) f)
Many.one
と Many.bind
はモナドの約定に従います。Many.bind (Many.one v) f
が f v
と同じであることをチェックするには、式として取りうるものを可能な限り評価することから始めます:
Many.bind (Many.one v) f
===>
Many.bind (Many.more v (fun () => Many.none)) f
===>
(f v).union (Many.bind Many.none f)
===>
(f v).union Many.none
空の多重集合は union
に対して右単位であるので、答えは f v
に等しくなります。Many.bind v Many.one
が v
と等しいことをチェックするには、bind
が v
の各要素に Many.one
を適用した和を取ることを考えてみましょう。言い換えると、もし v
が {v1, v2, v3, ..., vn}
の形式である場合、Many.bind v Many.one
は {v1} ∪ {v2} ∪ {v3} ∪ ... ∪ {vn}
であり、これは {v1, v2, v3, ..., vn}
です。
最後に、Many.bind
の結合性のチェックは、Many.bind (Many.bind bind v f) g
が Many.bind v (fun x => Many.bind (f x) g)
と同じであることを確かめましょう。もし v
が {v1, v2, v3, ..., vn}
の形式である場合、まず以下のようになり:
Many.bind v f
===>
f v1 ∪ f v2 ∪ f v3 ∪ ... ∪ f vn
これから以下が導かれ、
Many.bind (Many.bind bind v f) g
===>
Many.bind (f v1) g ∪
Many.bind (f v2) g ∪
Many.bind (f v3) g ∪
... ∪
Many.bind (f vn) g
また、同様に、
Many.bind v (fun x => Many.bind (f x) g)
===>
(fun x => Many.bind (f x) g) v1 ∪
(fun x => Many.bind (f x) g) v2 ∪
(fun x => Many.bind (f x) g) v3 ∪
... ∪
(fun x => Many.bind (f x) g) vn
===>
Many.bind (f v1) g ∪
Many.bind (f v2) g ∪
Many.bind (f v3) g ∪
... ∪
Many.bind (f vn) g
したがって、両辺は等しくなるため、Many.bind
は結合的となります。
結果としてモナドのインスタンスは次のようになります:
instance : Monad Many where
pure := Many.one
bind := Many.bind
このモナドを使って、試しにリスト中の足し合わせたら15になるすべての組み合わせを計算してみます:
def addsTo (goal : Nat) : List Nat → Many (List Nat)
| [] =>
if goal == 0 then
pure []
else
Many.none
| x :: xs =>
if x > goal then
addsTo goal xs
else
(addsTo goal xs).union
(addsTo (goal - x) xs >>= fun answer =>
pure (x :: answer))
探索プロセスはリストに対して再帰的に行われます。空のリストに対しては、ゴールが 0
であれば探索を成功とし、そうでなければ失敗とします。リストが空でない場合、2つの可能性があります:リストの先頭がゴールより大きい場合とそうでない場合であり、前者の場合はどんな成功した探索にも寄与することができず、対して後者は可能性があります。もしリストの先頭が候補 ではない 場合、探索は後続のリストに対して実行されます。もし先頭要素が候補である場合、Many.union
で組み合わされる2つの可能性があります:すなわち解が先頭要素を含む場合とそうでない場合です。先頭を含まない解は後続のリストに対して再帰することで得られ、先頭を含む解はゴールから先頭の値を引き、それと後続のリストに対する再帰呼び出し結果にもとの先頭要素を付け加えることで得られます。
結果の多重集合を生成する算術の評価器に戻ると、both
と neither
演算子は以下のように書くことができます:
inductive NeedsSearch
| div
| choose
def applySearch : NeedsSearch → Int → Int → Many Int
| NeedsSearch.choose, x, y =>
Many.fromList [x, y]
| NeedsSearch.div, x, y =>
if y == 0 then
Many.none
else Many.one (x / y)
これらの演算子を用いて、先ほどの例を評価することができます:
open Expr Prim NeedsSearch
#eval (evaluateM applySearch (prim plus (const 1) (prim (other choose) (const 2) (const 5)))).takeAll
[3, 6]
#eval (evaluateM applySearch (prim plus (const 1) (prim (other div) (const 2) (const 0)))).takeAll
[]
#eval (evaluateM applySearch (prim (other div) (const 90) (prim plus (prim (other choose) (const (-5)) (const 5)) (const 5)))).takeAll
[9]
カスタムの環境
評価器は文字列を演算子として使えるようにし、文字列から演算子の実処理へのマッピングを提供することで、評価器をユーザが拡張できるようにすることができます。例えば、ユーザは剰余演算子や2つの引数に対して大きい方を返すものなどを評価器に拡張することができます。関数名から関数の実装へのマッピングは 環境 (environment)と呼ばれます。
環境は各再帰呼び出しで渡される必要があります。知らない人からすると、evaluateM
は環境を保持するために余分な引数を必要とし、この引数は呼び出しのたびに渡される必要があると思われるかもしれません。しかし、このように引数を渡すこともモナドの一種であるため、適切な Monad
インスタンスを使用することで、評価器を変更せずに使用することができます。
関数をモナドとして使用することは一般的に リーダ (reader)モナドと呼ばれます。リーダモナドで式を評価する場合、以下のルールが用いられます:
- 定数 \( n \) は定数関数 \( λ e . n \) に評価され、
- 算術演算子は引数をオペランドに渡す関数に評価され、したがって \( f + g \) が \( λ e . f(e) + g(e) \) に評価され、
- カスタム演算子はカスタム演算子を引数に適用した結果に評価され、したがって \( f \ \mathrm{OP}\ g \) は未知の演算子に対してのフォールバックとして \( 0 \) を提供するようにして、以下のように評価されます。
\[
λ e .
\begin{cases}
h(f(e), g(e)) & \mathrm{if}\ e\ \mathrm{contains}\ (\mathrm{OP}, h) \\
0 & \mathrm{otherwise}
\end{cases}
\]
Leanでリーダモナドを定義するには、まず
Reader
型クラスとユーザが環境を把握するための作用を定義します:def Reader (ρ : Type) (α : Type) : Type := ρ → α def read : Reader ρ ρ := fun env => env
慣例として、環境はギリシャ文字
ρ
(発音は「rho」)が用いられます。算術式の定数が定数関数に評価されるという事実から
Reader
に対するpure
の適切な定義が定数関数であることが示唆されます:def Reader.pure (x : α) : Reader ρ α := fun _ => x
他方、
bind
は少々トリッキーです。この関数の型はReader ρ α → (α → Reader ρ β) → Reader ρ β
になります。この型はReader
の定義を展開した(ρ → α) → (α → ρ → β) → ρ → β
によって理解しやすくなります。これは第1引数として環境を受け入れる関数を受け取り、第2引数は環境を環境を受け入れる関数の結果を、さらに別の環境を受け入れる関数に変換しなければなりません。これらを組み合わせた結果自体が環境を待つような関数となります。Leanを対話的に使うことで、この関数を書く手助けを得ることができます。最初のステップは、できるだけ多くのヒントを得るために、引数と戻り値の型をアンダースコア付きで明示的に書くことです:
def Reader.bind {ρ : Type} {α : Type} {β : Type} (result : ρ → α) (next : α → ρ → β) : ρ → β := _
Leanはスコープ内でどの変数が使用可能か、そしてその結果に期待される型を記述したメッセージを提示します。地下鉄の入り口に似ていることから ターンスタイル と呼ばれる記号
⊢
は、このメッセージではρ → β
であるようなローカル変数と期待される型を分離しています:don't know how to synthesize placeholder context: ρ α β : Type result : ρ → α next : α → ρ → β ⊢ ρ → β
戻り値の型が関数であるため、最初のステップとしてアンダースコアの前に
fun
を付けるのが良いでしょう:def Reader.bind {ρ : Type} {α : Type} {β : Type} (result : ρ → α) (next : α → ρ → β) : ρ → β := fun env => _
この結果で得られるメッセージには、関数の引数がローカル変数として表示されるようになります:
don't know how to synthesize placeholder context: ρ α β : Type result : ρ → α next : α → ρ → β env : ρ ⊢ β
コンテキスト内で
β
を生成できるのはnext
のみですが、これを得るには引数が2つ必要です。それぞれの引数にもアンダースコアを設定してみます:def Reader.bind {ρ : Type} {α : Type} {β : Type} (result : ρ → α) (next : α → ρ → β) : ρ → β := fun env => next _ _
2つのアンダースコアは以下に示している、それぞれに関連したメッセージを持ちます:
don't know how to synthesize placeholder context: ρ α β : Type result : ρ → α next : α → ρ → β env : ρ ⊢ α
don't know how to synthesize placeholder context: ρ α β : Type result : ρ → α next : α → ρ → β env : ρ ⊢ ρ
1つ目のアンダースコアに取り掛かると、コンテキストで
α
を生み出すことができるのはただ1つだけ、すなわちresult
だけです:def Reader.bind {ρ : Type} {α : Type} {β : Type} (result : ρ → α) (next : α → ρ → β) : ρ → β := fun env => next (result _) _
ここで両方のアンダースコアは同じエラーになります:
don't know how to synthesize placeholder context: ρ α β : Type result : ρ → α next : α → ρ → β env : ρ ⊢ ρ
嬉しいことに、どちらのアンダースコアも
env
で置き換えることができ、以下を得ます:def Reader.bind {ρ : Type} {α : Type} {β : Type} (result : ρ → α) (next : α → ρ → β) : ρ → β := fun env => next (result env) env
最終的なバージョンは
Reader
の展開を元に戻し、明示的な詳細を掃除することで得られます:def Reader.bind (result : Reader ρ α) (next : α → Reader ρ β) : Reader ρ β := fun env => next (result env) env
このような「型に従う」だけで正しい関数が書けるとは限らず、出来上がったプログラムを理解できないリスクもあります。しかし、書いていないプログラムよりも出来上がったプログラムの方が理解しやすいこともあり得ますし、アンダースコアを埋めていく過程で気づきを得ることもあります。今回の場合、
Reader.bind
はId
に対するbind
と同じように動作しますが、追加の引数を受け取り、それを引数に渡すという点が異なり、この直観はこれがどう動くかということへの理解を助けます。定数関数を生成する
Reader.pure
とReader.bind
はモナドの約定に従います。Reader.bind (Reader.pure v) f
がf v
に等しいことを確認するには、最後のステップまで定義を置き換えれば十分です:Reader.bind (Reader.pure v) f ===> fun env => f ((Reader.pure v) env) env ===> fun env => f ((fun _ => v) env) env ===> fun env => f v env ===> f v
すべての関数
f
について、fun x => f x
はf
と同じであるため、約定の最初の部分が満たされます。Reader.bind r Reader.pure
がr
と同じであることを確認する場合にも同じテクニックが有効です:Reader.bind r Reader.pure ===> fun env => Reader.pure (r env) env ===> fun env => (fun _ => (r env)) env ===> fun env => r env
リーダのアクション
r
はそれ自体が関数であるため、これはr
と等しくなります。結合性をチェックするには、Reader.bind (Reader.bind r f) g
とReader.bind r (fun x => Reader.bind (f x) g)
の両方について同じことを行います:Reader.bind (Reader.bind r f) g ===> fun env => g ((Reader.bind r f) env) env ===> fun env => g ((fun env' => f (r env') env') env) env ===> fun env => g (f (r env) env) env
Reader.bind r (fun x => Reader.bind (f x) g) ===> Reader.bind r (fun x => fun env => g (f x env) env) ===> fun env => (fun x => fun env' => g (f x env') env') (r env) env ===> fun env => (fun env' => g (f (r env) env') env') env ===> fun env => g (f (r env) env) env
以上より、
Monad (Reader ρ)
インスタンスが成立します:instance : Monad (Reader ρ) where pure x := fun _ => x bind x f := fun env => f (x env) env
式評価器に渡されるカスタム環境はペアのリストとして表すことができます:
abbrev Env : Type := List (String × (Int → Int → Int))
例えば、
exampleEnv
は最大値と剰余関数を保持します:def exampleEnv : Env := [("max", max), ("mod", (· % ·))]
Leanには、ペアのリストのキーに関連付けられた値を見つける関数
List.lookup
がすでに存在するため、applyPrimReader
はカスタム関数が環境に存在するかどうかをチェックするだけでよくなります。関数が不明な場合は0
を返します:def applyPrimReader (op : String) (x : Int) (y : Int) : Reader Env Int := read >>= fun env => match env.lookup op with | none => pure 0 | some f => pure (f x y)
applyPrimReader
と式と一緒にevaluateM
を使うと、環境が必要な関数になります。幸運なことに、exampleEnv
が利用できます:open Expr Prim in #eval evaluateM applyPrimReader (prim (other "max") (prim plus (const 5) (const 4)) (prim times (const 3) (const 2))) exampleEnv
9
Many
と同様に、Reader
はほとんどの言語でエンコードすることが難しい作用の例ですが、型クラスとモナドによって他の作用と同じような利便性が提供されます。Common LispやClojure、Emacs Lispにある動的変数や特殊変数はReader
のように使うことができます。同様に、SchemeやRacketのパラメータオブジェクトはReader
に対応する作用です。Kotlinのイディオムであるコンテキストオブジェクトは似たような問題を解決することができますが、基本的には関数の引数を自動的に渡すための手段であるため、このイディオムは言語内の作用というよりはリーダモナドとしてエンコードされたものです。演習問題
約定のチェック
State σ
とExcept ε
のモナドの約定をチェックしてください。失敗付きのリーダ
リーダモナドの例を修正して、カスタム演算子が定義されていない場合に単に0を返すのではなく、失敗を示すことができるようにしてください。つまり、以下の定義に対して:
def ReaderOption (ρ : Type) (α : Type) : Type := ρ → Option α def ReaderExcept (ε : Type) (ρ : Type) (α : Type) : Type := ρ → Except ε α
以下を行ってください:
- 適切な
pure
とbind
関数を書いてください。 - これらの関数が
Monad
の約定を満たすことをチェックしてください。 ReaderOption
とReaderExcept
に対してのMonad
インスタンスを書いてください。- 適切な
applyPrim
演算子を定義し、いくつかの式に対してevaluateM
を使ってテストしてください。
評価器の追跡
WithLog
型を評価器と一緒に用いることで、いくつかの演算の追跡をオプションで追加することができます。特に、ToTrace
型は与えられた演算子を追跡するためのシグナルを提供します:inductive ToTrace (α : Type) : Type where | trace : α → ToTrace α
追跡付きの評価器では、式は
Expr (Prim (ToTrace (Prim Empty)))
型でなければなりません。これは、式の演算子が加算と減算、乗算で構成され、それぞれを追跡したもので補強していることを示します。一番内側のEmpty
によって、trace
の内部には特別な演算子は無く、基本的な3つの演算子だけであることを示しています。以下を行ってください:
Monad (WithLog logged)
インスタンスを実装してください。- 追跡される演算子をその引数に適用し、演算子とその引数をログ出力するための
applyTraced
関数を書いてください。型はToTrace (Prim Empty) → Int → Int → WithLog (Prim Empty × Int × Int) Int
です。
もし演習問題を正しく解けたのなら、
open Expr Prim ToTrace in #eval evaluateM applyTraced (prim (other (trace times)) (prim (other (trace plus)) (const 1) (const 2)) (prim (other (trace minus)) (const 3) (const 4)))
は以下の結果になるはずです。
{ log := [(Prim.plus, 1, 2), (Prim.minus, 3, 4), (Prim.times, 3, -1)], val := -3 }
ヒント:
Prim Empty
型の値は結果のログに表示されます。それらを#eval
の結果として表示するには、以下のインスタンスが必要です:deriving instance Repr for WithLog deriving instance Repr for Empty deriving instance Repr for Prim
1原文は
operators
だがオペランドのミス?モナドのための
do
記法モナドに基づくAPIは非常に強力ですが、匿名関数を伴った
>>=
の明示的な使用は多少煩雑さがあります。HAdd.hAdd
を呼び出す代わりに中置演算子を使うように、Leanはモナドを使うプログラムの読み書きを簡単にすることができるdo
記法 と呼ばれる構文を提供しています。これはIO
でプログラムを書くときに使われるdo
記法とまったく同じもので、実際IO
もモナドです。ハローワールド! では、
do
記法はIO
アクションの組み合わせのために用いられていましたが、これらのプログラムの意味はIO
に基づいて直接説明されていました。モナドを使ったプログラミングを理解することは、do
がどのようなモナド演算子の使い方に変換されるかという観点から説明できるようになることを意味します。do
の翻訳規則の1つ目は、do
の中の文がただ1つの式E
である場合です。この場合、do
は削除されます。do E
上記は以下のように訳されます。
E
2つ目の翻訳規則は、
do
の最初の文がローカル変数を束縛する矢印付きのlet
である場合に使用されます。これは、その変数名を束縛する関数と一緒に>>=
を使うように訳されます。したがって、do let x ← E1 Stmt ... En
は以下のように訳されます。
E1 >>= fun x => do Stmt ... En
do
ブロックの最初の文が式の場合、その式はUnit
を返すモナドのアクションであるとみなされ、脱糖後の後続関数はUnit
コンストラクタにパターンマッチするものとなるため、do E1 Stmt ... En
は以下のように訳されます。
E1 >>= fun () => do Stmt ... En
最後に、
do
ブロックの最初の文が:=
を使ったlet
の場合、翻訳された形は普通のlet式になります。したがって、do let x := E1 Stmt ... En
は以下のように訳されます。
let x := E1 do Stmt ... En
Monad
クラスを用いたfirstThirdFifthSeventh
の定義は以下のようなものでした:def firstThirdFifthSeventh [Monad m] (lookup : List α → Nat → m α) (xs : List α) : m (α × α × α × α) := lookup xs 0 >>= fun first => lookup xs 2 >>= fun third => lookup xs 4 >>= fun fifth => lookup xs 6 >>= fun seventh => pure (first, third, fifth, seventh)
do
記法を使うことで、劇的に読みやすくなります:def firstThirdFifthSeventh [Monad m] (lookup : List α → Nat → m α) (xs : List α) : m (α × α × α × α) := do let first ← lookup xs 0 let third ← lookup xs 2 let fifth ← lookup xs 4 let seventh ← lookup xs 6 pure (first, third, fifth, seventh)
Monad
型クラスを用いずに、木のノードに採番する関数number
は次のように記述しました:def number (t : BinTree α) : BinTree (Nat × α) := let rec helper : BinTree α → State Nat (BinTree (Nat × α)) | BinTree.leaf => ok BinTree.leaf | BinTree.branch left x right => helper left ~~> fun numberedLeft => get ~~> fun n => set (n + 1) ~~> fun () => helper right ~~> fun numberedRight => ok (BinTree.branch numberedLeft (n, x) numberedRight) (helper t 0).snd
Monad
とdo
を用いると、定義からわずらわしさが軽減されます:def number (t : BinTree α) : BinTree (Nat × α) := let rec helper : BinTree α → State Nat (BinTree (Nat × α)) | BinTree.leaf => pure BinTree.leaf | BinTree.branch left x right => do let numberedLeft ← helper left let n ← get set (n + 1) let numberedRight ← helper right ok (BinTree.branch numberedLeft (n, x) numberedRight) (helper t 0).snd
IO
についてdo
で得られた恩恵は、ほかのモナドでも享受できます。例えば、ネストされたアクションはどのモナドでも動作します。もともとのmapM
の定義は以下の通りです:def mapM [Monad m] (f : α → m β) : List α → m (List β) | [] => pure [] | x :: xs => f x >>= fun hd => mapM f xs >>= fun tl => pure (hd :: tl)
do
記法によって、これは以下のように書くことができます:def mapM [Monad m] (f : α → m β) : List α → m (List β) | [] => pure [] | x :: xs => do let hd ← f x let tl ← mapM f xs pure (hd :: tl)
ネストされたアクションを使うことで、もとの非モナドな
map
と同じくらい短い記述にすることができます:def mapM [Monad m] (f : α → m β) : List α → m (List β) | [] => pure [] | x :: xs => do pure ((← f x) :: (← mapM f xs))
ネストされたアクションを使うことで、
number
をより簡潔にすることができます:def increment : State Nat Nat := do let n ← get set (n + 1) pure n def number (t : BinTree α) : BinTree (Nat × α) := let rec helper : BinTree α → State Nat (BinTree (Nat × α)) | BinTree.leaf => pure BinTree.leaf | BinTree.branch left x right => do pure (BinTree.branch (← helper left) ((← increment), x) (← helper right)) (helper t 0).snd
演習問題
evaluateM
とそのヘルパー、そしていくつかの異なるユースケース例を、>>=
を明示的に呼び出す代わりにdo
記法を使用して書き直してください。firstThirdFifthSeventh
をネストされたアクションを使って書き直してください。
IOモナド
モナドとしての
IO
は2つの観点から理解することができます。これは プログラムの実行 の節で説明したとおりです。それぞれの観点から、IO
におけるpure
とbind
の意味の理解が促進されます。最初の観点では、
IO
アクションはLeanのランタイムシステム(RTS)に対する命令として捉えられていました。その命令は、例えば「このファイル記述子から文字列を読み込み、その文字列で純粋なLeanコードを再度呼んでください」などのようなものです。この観点は 外部的な もので、オペレーティングシステム側からプログラムを見ています。この場合、pure
はIO
アクションであり、RTSにいかなる作用も要求しません。また、bind
はRTSに対して、まず潜在的に作用を持つ操作を実行し、その結果得られた値で残りのプログラムを呼び出すように指示します。2つ目の観点では、
IO
アクションは世界全体を変換すると考えられます。IO
アクション自体は純粋です。というのもこれは一意な世界を引数として受け取り、変更後の世界を返すからです。この観点は 内部的な もので、Leanの内部でのIO
の表現方法に一致します。Leanにおいて世界はトークンとして表現され、IO
モナドは各トークンが正確に一度だけ利用されるように構造化されています。これがどのように動作するかを知るためには、定義を1つずつはがしていくことが有用です。
#print
コマンドはLeanのデータ型と定義の内部を明らかにします。例えば、#print Nat
は以下の結果となります。
inductive Nat : Type number of parameters: 0 constructors: Nat.zero : Nat Nat.succ : Nat → Nat
また、
#print Char.isAlpha
は以下の結果となります。
def Char.isAlpha : Char → Bool := fun c => Char.isUpper c || Char.isLower c
#print
の出力にはこの本でまだ紹介されていないLeanの特徴が含まれることが時折あります。例えば、#print List.isEmpty
は以下を出力します。
def List.isEmpty.{u} : {α : Type u} → List α → Bool := fun {α} x => match x with | [] => true | head :: tail => false
ここで、上記の定義名の後ろに
.{u}
が含まれ、また型に対してただのType
ではなくType u
と注釈されています。これについては今のところ無視して問題ありません。IO
の定義を表示すると、思ったより単純な構造で定義されていることがわかります:#print IO
@[reducible] def IO : Type → Type := EIO IO.Error
IO.Error
はIO
アクションが投げる可能性のあるすべてのエラーを表します:#print IO.Error
inductive IO.Error : Type number of parameters: 0 constructors: IO.Error.alreadyExists : Option String → UInt32 → String → IO.Error IO.Error.otherError : UInt32 → String → IO.Error IO.Error.resourceBusy : UInt32 → String → IO.Error IO.Error.resourceVanished : UInt32 → String → IO.Error IO.Error.unsupportedOperation : UInt32 → String → IO.Error IO.Error.hardwareFault : UInt32 → String → IO.Error IO.Error.unsatisfiedConstraints : UInt32 → String → IO.Error IO.Error.illegalOperation : UInt32 → String → IO.Error IO.Error.protocolError : UInt32 → String → IO.Error IO.Error.timeExpired : UInt32 → String → IO.Error IO.Error.interrupted : String → UInt32 → String → IO.Error IO.Error.noFileOrDirectory : String → UInt32 → String → IO.Error IO.Error.invalidArgument : Option String → UInt32 → String → IO.Error IO.Error.permissionDenied : Option String → UInt32 → String → IO.Error IO.Error.resourceExhausted : Option String → UInt32 → String → IO.Error IO.Error.inappropriateType : Option String → UInt32 → String → IO.Error IO.Error.noSuchThing : Option String → UInt32 → String → IO.Error IO.Error.unexpectedEof : IO.Error IO.Error.userError : String → IO.Error
EIO ε α
は、ε
型のエラーで終了するか、α
型の値で成功するかのどちらかになるIO
アクションを表します。つまり、Except ε
と同じようにIO
モナドにもエラー処理と例外を定義することができます。さらに展開をはがすと、
EIO
もまたとてもシンプルな構造で定義されています:#print EIO
def EIO : Type → Type → Type := fun ε => EStateM ε IO.RealWorld
EStateM
モナドはエラーと状態の両方を保持しています。つまりExcept
とState
を組み合わせたものです。このモナドはEStateM.Result
という別の型を使って定義されています:#print EStateM
def EStateM.{u} : Type u → Type u → Type u → Type u := fun ε σ α => σ → EStateM.Result ε σ α
言い換えると、
EStateM ε σ α
型を持つプログラムは初期状態としてσ
を受け取り、戻り値としてEStateM.Result ε σ α
を返す関数です。EStateM.Result
の定義はExcept
とよく似ており、正常終了とエラーそれぞれに対して1つずつコンストラクタがあります:#print EStateM.Result
inductive EStateM.Result.{u} : Type u → Type u → Type u → Type u number of parameters: 3 constructors: EStateM.Result.ok : {ε σ α : Type u} → α → σ → EStateM.Result ε σ α EStateM.Result.error : {ε σ α : Type u} → ε → σ → EStateM.Result ε σ α
Except ε α
と同様に、ok
コンストラクタにはα
型の結果が、error
コンストラクタにはε
型の例外が含まれます。Except
とは異なり、どちらのコンストラクタにも計算の最終状態を示す状態のフィールドが追加されています。EStateM ε σ
のMonad
インスタンスを定義するにはpure
とbind
が必要です。State
と同様に、EStateM
のpure
の実装は初期状態を受け取り、それを変更せずに返却します。これもExcept
と同様に、ok
コンストラクタに引数を入れて返却します:#print EStateM.pure
protected def EStateM.pure.{u} : {ε σ α : Type u} → α → EStateM ε σ α := fun {ε σ α} a s => EStateM.Result.ok a s
protected
はEStateM
名前空間がオープンされていても、呼び出す際にはEStateM.pure
とフルネームを使う必要があることを意味します。同様に、
EStateM
のbind
は初期状態を引数に取ります。この初期状態は最初のアクションに渡されます。そしてExcept
のbind
と同様に、結果がエラーかどうかのチェックを行います。もしエラーであれば、そのエラーがそのまま返却され、bind
の第2引数は未使用のままとなります。結果が成功だった場合は、2番目の引数は戻り値と結果の状態の両方に適用されます。#print EStateM.bind
protected def EStateM.bind.{u} : {ε σ α β : Type u} → EStateM ε σ α → (α → EStateM ε σ β) → EStateM ε σ β := fun {ε σ α β} x f s => match x s with | EStateM.Result.ok a s => f a s | EStateM.Result.error e s => EStateM.Result.error e s
これらをすべてまとめると、
IO
は状態とエラーを同時に追跡するモナドということになります。利用可能なエラーのあつまりは、IO.Error
というデータ型によって与えられ、これはプログラム中に起こりうる様々なエラーを記述するコンストラクタを持ちます。状態はIO.RealWorld
という実世界を表すデータ型です。それぞれの基本的なIO
アクションはこの実世界を受け取り、エラーまたは結果と対になった別の実世界を返します。IO
では、pure
は世界を変更せずに返しますが、bind
はアクションから次のアクションに変更された世界を渡します。全宇宙はコンピュータのメモリに収まらないため、アクション間で取りまわされる世界は単なる表現にすぎません。世界のトークンが再利用されない限り、表現は安全です。このことは、世界のトークンはデータを一切含む必要がないことを意味します:
#print IO.RealWorld
def IO.RealWorld : Type := Unit
その他の便利な機能
引数の型の共有
同じ型の引数を複数受け取る関数を定義する際に、両者を同じコロンの前に書くことができます。例えば、
def equal? [BEq α] (x : α) (y : α) : Option α := if x == y then some x else none
は以下のように書くことができます。
def equal? [BEq α] (x y : α) : Option α := if x == y then some x else none
これは型シグネチャが大きい際には特に役立ちます。
ドット始まり記法
帰納型のコンストラクタはその型の名前空間の中に配置されます。これにより、異なる同種の帰納型に対して同じコンストラクタ名を使用することができますが、プログラムが冗長になる可能性があります。問題の帰納型が明確なコンテキストでは、コンストラクタ名の前にドットを付けることで、名前空間を省略することができます。Leanはこのコンストラクタ名を解決するために期待された型を使ってくれます。例えば、二分木をそっくりコピーする関数は次のように書くことができます:
def BinTree.mirror : BinTree α → BinTree α | BinTree.leaf => BinTree.leaf | BinTree.branch l x r => BinTree.branch (mirror r) x (mirror l)
名前空間を省略することでプログラムが大幅に短くなりますが、その代償としてLeanのコンパイラが無いコードのレビューツール上などの状況下ではプログラムが読みにくくなります:
def BinTree.mirror : BinTree α → BinTree α | .leaf => .leaf | .branch l x r => .branch (mirror r) x (mirror l)
どの名前空間を使うかが曖昧にしないために式の予想される型を使用することは、コンストラクタ以外の名前にも適用できます。
BinTree.empty
がBinTree
を作成する代替方法として定義されている場合、ドット記法を用いることもできます:def BinTree.empty : BinTree α := .leaf #check (.empty : BinTree Nat)
BinTree.empty : BinTree Nat
orパターン
match
式のような複数のパターンを許容するコンテキストでは、複数のパターンで結果の式を共有することができます。曜日を表すWeekday
データ型は以下のようになります:inductive Weekday where | monday | tuesday | wednesday | thursday | friday | saturday | sunday deriving Repr
ある日が週末かどうかをチェックするにはパターンマッチを使うことで実現できます:
def Weekday.isWeekend (day : Weekday) : Bool := match day with | Weekday.saturday => true | Weekday.sunday => true | _ => false
これについてすでに見たようにコンストラクタのドット記法で単純化できます:
def Weekday.isWeekend (day : Weekday) : Bool := match day with | .saturday => true | .sunday => true | _ => false
週末のパターンはどちらも同じ式 (
true
) を持つため、これらを1つにまとめることができます:def Weekday.isWeekend (day : Weekday) : Bool := match day with | .saturday | .sunday => true | _ => false
引数に名前をつけないバージョンにすることでさらに単純化できます:
def Weekday.isWeekend : Weekday → Bool | .saturday | .sunday => true | _ => false
この裏側では、結果の式は各パターンに対してただ複製されます。このことはパターンで変数を束縛できることを意味します。次の例では、
inl
とinr
のコンストラクタを両方とも同じ型の値を含む直和型から取り除いています:def condense : α ⊕ α → α | .inl x | .inr x => x
結果の式は複製されるため、パターンによって束縛される変数が同じ型である必要はありません。複数の型に対して動作するオーバーロードされた関数を使用すると、異なる型の変数をバインドするパターンに対して動作する唯一の結果式を記述できます:
def stringy : Nat ⊕ Weekday → String | .inl x | .inr x => s!"It is {repr x}"
実用的には、すべてのパターンで共有されている変数のみが結果式で参照されます。というのも、その結果はすべてのパターンに対して成立しなければならないからです。
getTheNat
では、n
だけがアクセス可能で、x
やy
を使おうとするとエラーになります。def getTheNat : (Nat × α) ⊕ (Nat × β) → Nat | .inl (n, x) | .inr (n, y) => n
同様の定義で
x
にアクセスしようとすると、2つ目のパターンには利用可能なx
が無いためエラーとなります:def getTheAlpha : (Nat × α) ⊕ (Nat × α) → α | .inl (n, x) | .inr (n, y) => x
unknown identifier 'x'
結果の式は本質的にはパターンマッチの各分岐にコピペされるため、意外な動作をすることがあります。例えば、結果式の
inr
バージョンはstr
のグローバル定義を参照するため、以下の定義が通ります:def str := "Some string" def getTheString : (Nat × String) ⊕ (Nat × β) → String | .inl (n, str) | .inr (n, y) => str
この関数を両方のコンストラクタについて呼び出すと混乱するような動作が行われます。最初のケースでは、
β
がどの型であるべきかLeanに伝えるために型注釈が必要です:#eval getTheString (.inl (20, "twenty") : (Nat × String) ⊕ (Nat × String))
"twenty"
2番目のケースでは、グローバル定義が使われます:
#eval getTheString (.inr (20, "twenty"))
"Some string"
orパターンを使うと、
Weekday.isWeekend
のように定義が大幅に簡略化され、わかりやすくなる定義も存在します。しかし混乱を招く挙動をする可能性があるため、特に複数の型の変数や変数の素集合からなる場合は慎重に使用することをお勧めします。まとめ
副作用のエンコード
Leanは純粋関数型言語です。これは可変変数やロギング、例外等の副作用を言語として含んでいないことを意味します。しかし、ほとんどの副作用は関数と帰納型か構造体を組み合わせることで エンコード できます。例えば、可変状態は初期状態から最終状態と結果のペアへの関数としてエンコードされ、例外は正常終了と異常終了のそれぞれへのコンストラクタを持つ帰納型としてエンコードできます。
エンコードされた作用それぞれは型です。その結果、これらのエンコードされた作用を使うプログラムは、作用を使っていることが型に表れます。関数型プログラミングは作用が使えないことを意味せず、シンプルに使用する作用について 正直であること が求められます。Leanの型シグネチャは関数が受け取る引数の型と関数の戻り値だけでなく、そこで使われる作用についても記述します。
モナド型クラス
作用をどこでも使えるような言語でも純粋で関数型のプログラムを書くことは可能です。例えば、
2 + 3
はPythonにおいて一切作用を持たないプログラムとして成立します。同様に、作用を持つプログラムの結合には作用がどの順番で行われるかを定める方法が求められます。つまり、変数を変更する前に例外が投げられるか、変更した後に例外が投げられるかが重要なのです。Monad
型クラスはこれら2つの重要な性質を兼ね備えています。このクラスには2つのメソッドがあります:pure
はプログラムが作用を持たないことを表し、bind
は作用を含むプログラムを結合します。Monad
インスタンスの約定はbind
とpure
が本当に純粋な計算と連結を体現することを保証します。モナドのための
do
記法IO
に限ることなく、do
記法はどんなモナドでも使うことができます。これにより、プログラム中で文を順々に並べるというあたかも文指向(statement-oriented)言語を思わせるようなスタイルでモナドを使うことができます。さらに、do
記法はネストしたアクションなどの追加の便利な短縮記法を可能にします。do
で書かれたプログラムはその裏で>>=
の適用に翻訳されます。モナドの自作
異なる言語では異なる副作用が提供されます。ほとんどの言語では可変変数とファイルのI/Oを兼ね備えている一方、例外を持たない言語も存在します。また一部の言語では珍しく独特な作用を用意しています。例えばIconの検索に基づくプログラムの実行やSchemeとRubyの継続、Common Lispの再開可能な例外などです。モナドで作用をエンコードすることには、言語から提供される作用だけに限定されなくなるというアドバンテージがあります。Leanはどんなモナドでも快適にプログラミングできるように設計されているため、プログラマは任意のアプリケーションにぴったりな副作用を自由に選ぶことができます。
IO
モナドLeanでは、実世界に影響を及ぼすプログラムは
IO
アクションとして記述されます。IO
は数多くあるモナドの中の1つです。IO
モナドは状態と例外をエンコードしており、このうち状態は世界の状態を追跡するために、例外は失敗と回復をモデル化するためにそれぞれ用いられます。関手・アプリカティブ関手・モナド
Functor
とMonad
はどちらもさらに型引数を待ち受ける型の演算を記述します。このことを理解するにあたって、Functor
は変換対象のデータを保持するコンテナを記述し、Monad
は副作用のあるプログラムのエンコードを記述するものと考えるのも1つの手でしょう。しかし、この理解は不完全です。というのも、Option
はFunctor
とMonad
の両方のインスタンスを持ち、オプショナルな値と値の返却に失敗するかもしれない計算を 同時に 表現するからです。データ構造の観点から、
Option
はnullable型や高々1個の要素しか保持できないリストのようなものに少し似ています。制御構造の観点からは、Option
は結果を伴わずに早期リターンするかもしれない計算を表現します。通常、Functor
インスタンスを使うプログラムはOption
の使い道をデータ構造としてみなす場合が最も簡単であり、一方でMonad
インスタンスを使うプログラムもOption
の使い道を早期の失敗を許可するものとみなす場合が最も簡単ですが、これらの観点両方を流暢に使えるようになることは関数型プログラミングの達人になるためには重要なポイントです。関手とモナドの間には深い関係があります。実は すべてのモナドは関手になります 。言い換えると、すべての関手がモナドにはならないため、モナドの抽象化は関手の抽象化よりも強力であるということです。さらに、両者の間には アプリカティブ関手 (applicative functor)と呼ばれる抽象化が存在します。これもまた多くの興味深いプログラムを書くにあたって十分な力を持ち、
Monad
のインタフェースを使えないライブラリでも使うことができます。型クラスApplicative
はアプリカティブ関手のオーバーロードされた演算を提供します。すべてのモナドはアプリカティブ関手であり、またすべてのアプリカティブ関手は関手ですが、これらの逆は成り立ちません。構造体と継承
Functor
とApplicative
、Monad
の完全な定義を理解するためには、構造体の継承というLeanの機能が必要になります。構造体の継承は、ある構造体型にフィールドを追加した別の構造体のインタフェースを提供することを可能にします。これは明確な分類学的な関係を持つ概念をモデル化するときに便利です。例えば、神話上の生き物のモデルを考えてみましょう。その中には大きいものも居れば、小さいものも居ます:structure MythicalCreature where large : Bool deriving Repr
裏側では、
MythicalCreature
構造体を定義するとmk
というコンストラクタを1つだけ持つ帰納型が作られます:#check MythicalCreature.mk
MythicalCreature.mk (large : Bool) : MythicalCreature
同様に、コンストラクタから実際のフィールドを取り出す関数
MythicalCreature.large
が作られます:#check MythicalCreature.large
MythicalCreature.large (self : MythicalCreature) : Bool
ほとんどの昔話において、それぞれの怪物は何らかの方法で倒すことができます。怪物の説明文には、大型かどうかと共に、この情報を含めるべきでしょう:
structure Monster extends MythicalCreature where vulnerability : String deriving Repr
先頭行に含まれる
extends MythicalCreature
はすべての怪物もまた神話的であると述べています。Monster
を定義するには、MythicalCreature
のフィールドとMonster
のフィールドの両方を提供しなければなりません。トロールは大型の怪物で、その弱点は日光です:def troll : Monster where large := true vulnerability := "sunlight"
裏側では、継承は合成を用いて実装されています。コンストラクタ
Monster.mk
はMythicalCreature
を引数に取ります:#check Monster.mk
Monster.mk (toMythicalCreature : MythicalCreature) (vulnerability : String) : Monster
各新しいフィールドの値を抽出する関数の定義に加えて、
Monster → MythicalCreature
型の関数Monster.toMythicalCreature
が定義されています。これはベースになっている生き物を抽出するために使用できます。Leanにおける継承の階層の移動はオブジェクト指向言語におけるアップキャストとは異なります。アップキャスト演算子は派生クラスの値を親クラスのインスタンスとして扱いますが、値自体は中身も構造もそのままです。しかしLeanでは、継承階層を上がることで実際にその中に含まれる情報を削除します。このことを実際に見るために、
troll.toMythicalCreature
の評価結果を考察してみましょう:#eval troll.toMythicalCreature
{ large := true }
MythicalCreature
のフィールドだけが残ります。where
構文と同様に、フィールド名を伴った波括弧記法でも構造体の継承が機能します:def troll : Monster := {large := true, vulnerability := "sunlight"}
その一方で、基礎となるコンストラクタに委譲する無名の角括弧記法では内部構造があらわになります:
def troll : Monster := ⟨true, "sunlight"⟩
application type mismatch Monster.mk true argument true has type Bool : Type but is expected to have type MythicalCreature : Type
ここでは
true
でMythicalCreature.mk
が起動するようにもう1つ角括弧が必要になります:def troll : Monster := ⟨⟨true⟩, "sunlight"⟩
Leanのドット記法は継承を考慮にいれることを許容しています。言い換えると、既存の
MythicalCreature.large
はMonster
で使用することができ、その際にはLeanが自動的にMythicalCreature.large
の呼び出し前にMonster.toMythicalCreature
の呼び出しを挿入します。しかしこれはドット記法を使った場合のみに発生し、通常の関数呼び出し構文を使用してフィールド検索関数を適用すると型エラーが発生します:#eval MythicalCreature.large troll
application type mismatch troll.large argument troll has type Monster : Type but is expected to have type MythicalCreature : Type
ドット記法はユーザ定義関数に対しても継承を考慮にいれることができます。小さい生き物とは、大きくない生き物のことです:
def MythicalCreature.small (c : MythicalCreature) : Bool := !c.large
troll.small
を評価するとfalse
が出力される一方で、MythicalCreature.small troll
は評価しようとすると以下の結果になります:application type mismatch MythicalCreature.small troll argument troll has type Monster : Type but is expected to have type MythicalCreature : Type
多重継承
ヘルパーとは神話上の生き物で、適切な報酬が与えられれば援助を提供してくれます:
structure Helper extends MythicalCreature where assistance : String payment : String deriving Repr
例えば、ニッセ は小さな妖精の一種で、おいしいおかゆをあげると家の手伝いをしてくれると言われています:
def nisse : Helper where large := false assistance := "household tasks" payment := "porridge"
もし従えることができれば、トロールは優秀なヘルパーになります。トロールは畑一面をすべて耕すのに1晩でできるほどパワフルですが、彼らの生活を満足させるためにはヤギの模型が必要です。怪物のような助っ人は、ヘルパーでもある怪物のことです:
structure MonstrousAssistant extends Monster, Helper where deriving Repr
この構造体型の値は、親である両方の構造体のフィールドをすべて埋めなければなりません:
def domesticatedTroll : MonstrousAssistant where large := false assistance := "heavy labor" payment := "toy goats" vulnerability := "sunlight"
どちらの親構造体型も
MythicalCreature
を継承しています。もし単純に多重継承を実装しようとすると、large
をMonstrousAssistant
から取得する際の経路が不明確になるという「菱形継承問題」が発生する可能性があります。large
は含まれているMonster
と、それともHelper
のどちらから取るべきしょうか?Leanでは、新しい構造体が両方の親を直接含むのではなく、最初に指定された親の親構造体へのパスが取得され、そののちに親構造で追加されたフィールドがコピーされるという解決策を取っています。これは
MonstrousAssistant
のコンストラクタのシグネチャを調べればわかります:#check MonstrousAssistant.mk
MonstrousAssistant.mk (toMonster : Monster) (assistance payment : String) : MonstrousAssistant
このコンストラクタは
Monster
と、MythicalCreature
にHelper
で導入されている2つのフィールドを引数に取ります。同じように、MonstrousAssistant.toMonster
はコンストラクタからただMonster
を取り出すだけですが、MonstrousAssistant.toHelper
には取り出せるHelper
がありません。#print
コマンドからこの実装を明らかにすることができます:#print MonstrousAssistant.toHelper
@[reducible] def MonstrousAssistant.toHelper : MonstrousAssistant → Helper := fun self => { toMythicalCreature := self.toMonster.toMythicalCreature, assistance := self.assistance, payment := self.payment }
この関数は
MonstrousAssistant
のフィールドからHelper
を作成しています。@[reducible]
属性はabbrev
を同じ効果を持ちます。デフォルト宣言
ある構造体が別の構造体を継承する場合、デフォルトのフィールド定義は親構造体のフィールドを子構造体のフィールドをもとにインスタンス化することができます。クリーチャーが大きいかどうかよりもより詳細なサイズが必要な場合、サイズを記述する専用のデータ型を継承と一緒に使用することができ、
large
フィールドをsize
フィールドの内容から計算するような構造体を生み出します:inductive Size where | small | medium | large deriving BEq structure SizedCreature extends MythicalCreature where size : Size large := size == Size.large
しかし、このデフォルト定義はあくまでデフォルトで与えられる定義にすぎません。C#やScalaのような言語でのプロパティ継承とは異なり、この子構造体の定義は
large
に特定の値が与えられない場合にのみ使用されるため、無意味な結果が生じることもあります:def nonsenseCreature : SizedCreature where large := false size := .large
子構造が親構造から逸脱してはならない場合、いくつかの手段があります:
- 関係性を文書化すること、これは
BEq
とHashable
で行われているような感じです - それらのフィールドが適切に関係していることを示す命題を定義し、APIをその命題が真であるという根拠を重要な部分として要求するように設計すること
- 継承を全く用いない
2番目の選択肢は以下のような感じです:
abbrev SizesMatch (sc : SizedCreature) : Prop := sc.large = (sc.size == Size.large)
ここで等号1つは 命題の 同値を示すために使用され、等号2つは同値をチェックしてから
Bool
を返す関数を示すために使用されることに注意してください。SizesMatch
はabbrev
として定義されていますが、これは証明の際に自動的に展開され、simp
で証明したい同値を示すことができるようにするためです。ハルダー は中くらいの大きさの神話上の生き物です。もっと言うと人間と同じ大きさの生き物です。
huldre
の2つの大きさについてのフィールドは互いに一致します:def huldre : SizedCreature where size := .medium example : SizesMatch huldre := by simp
型クラスの継承
型クラスは、裏側では構造体だったのでした。新しい型クラスの定義は新しい構造体を定義し、インスタンスの定義はその構造体の値が作成されます。そしてこの値がLeanの内部テーブルに追加され、リクエストに応じてインスタンスを見つけることができるようになります。この結果、型クラスはほかの型クラスを継承することができます。
型クラスの継承は構造体のそれとまったく同じ言語機能を使用するため、型クラスの継承は多重継承、親の型のメソッドのデフォルト実装、菱形の自動的な折り畳みなどの構造体継承のすべての機能をサポートしています。これは、Java・C#・Kotlinのような言語で多重インターフェース継承が有用であることと多くの同じような状況で有用です。型クラスの継承階層を注意深く設計することで、プログラマは両方からいいとこ取りをすることができます:すなわち、独立に実装可能な抽象化をきめ細やかに行うこと、そしてより広く一般的な抽象化から特定の抽象化を自動的に構築することです。
アプリカティブ関手
アプリカティブ関手 (applicative functor)とは関手に
pure
とseq
の2つの追加の操作を持ったもののことです。pure
はMonad
でも同じ演算子が用いられています。それもそのはずで、実はMonad
はApplicative
を継承しているからです。seq
はmap
とよく似た演算子です:これによって、ある関数をデータ型の中身を変換するために使用することができます。しかし、seq
では、関数自体がデータ型に含まれています:f (α → β) → (Unit → f α) → f β
。Functor.map
が無条件に関数適用するのに対して、関数をf
型の下に持つことで、Applicative
インスタンスは関数の適用方法を制御することができます。2番目の引数にはUnit →
で始まる型を指定することで、関数が適用されない場合にseq
の定義をショートカットできるようにしています。この短絡的なふるまいの真価は、
Applicative Option
のインスタンスで見ることができます:instance : Applicative Option where pure x := .some x seq f x := match f with | none => none | some g => g <$> x ()
このケースにおいて、
seq
に適用する関数が無ければ、引数を計算する必要もないのでx
が呼ばれることはありません。同じことがExcept
のApplicative
インスタンスにも当てはまります:instance : Applicative (Except ε) where pure x := .ok x seq f x := match f with | .error e => .error e | .ok g => g <$> x ()
この短絡的な挙動は関数自体というよりも、関数を 取り囲む
Option
やExcept
の構造にのみ依存します。モナドは文を順次実行するという概念を純粋関数型言語に取り込むための方法とみなすことができます。ある文の結果はそれ以降の文の実行に影響を及ぼすことができます。これは
bind
の型:m α → (α → m β) → m β
に見ることができます。最初の文の結果の値は、次に実行する文を計算する関数への入力となります。bind
の連続した使用は命令型プログラミング言語における文の列のようなものです。bind
は強力であり、条件分岐やループのような制御構造を実装することも十分可能です。このアナロジーに従うと、
Applicative
は副作用を持つ言語での関数適用を捉えたものと言えます。KotlinやC#のような言語では、関数の引数は左から右に評価されます。先に評価された引数によって実行される副作用は、その後の引数によって実行される副作用よりも先に発生します。しかし、関数は引数の特定の 値 に依存するカスタムの短絡演算子を実装するほど強力ではありません。通常、
seq
は直接呼び出されません。その代わりに<*>
という演算子が使われます。この演算子は第二引数をfun () => ...
で包み、seq
の呼び出しを単純化します。つまり、E1 <*> E2
はSeq.seq E1 (fun () => E2)
の構文糖衣です。seq
を複数の引数で使えるようにするにあたって、Leanでの複数の引数を持つ関数が、実際には「残りの引数を待つ別の関数」を返す「単一の引数を持つ関数」であるということは重要な特徴です。言い換えると、seq
の最初の引数が複数の引数を持っている場合、seq
の結果は2個目以降の残りの引数を待っていることになります。例えば、some Plus.plus
はOption (Nat → Nat → Nat)
型を持ちます。ここで引数を一つ与えると、some Plus.plus <*> some 4
となり、型はOption (Nat → Nat)
となります。これ自身もseq
と一緒に使えるため、some Plus.plus <*> some 4 <*> some 7
はOption Nat
型を持ちます。すべての関手がアプリカティブにはなりません。
Pair
は組み込みの直積型Prod
のようなものです:structure Pair (α β : Type) : Type where first : α second : β
Except
と同じように、Pair
はType → Type → Type
型を持ちます。これはPair α
がType → Type
型を持ち、Functor
インスタンスの定義が可能であることを意味します:instance : Functor (Pair α) where map f x := ⟨x.first, f x.second⟩
これは
Functor
の約定に従います。チェックする2つの特性は
id <$> Pair.mk x y = Pair.mk x y
とf <$> g <$> Pair.mk x y = (f ∘ g) <$> Pair.mk x y
です。最初の特性は、左辺の評価を逐一評価し、それが右辺の形に評価されることが確認できればOKです:id <$> Pair.mk x y ===> Pair.mk x (id y) ===> Pair.mk x y
2つ目は、両辺を逐一評価し、同じ結果が得られることを確認します:
f <$> g <$> Pair.mk x y ===> f <$> Pair.mk x (g y) ===> Pair.mk x (f (g y)) (f ∘ g) <$> Pair.mk x y ===> Pair.mk x ((f ∘ g) y) ===> Pair.mk x (f (g y))
しかし
Applicative
インスタンスを定義しようとしてもうまくいきません。pure
の定義が必要になります:def Pair.pure (x : β) : Pair α β := _
don't know how to synthesize placeholder context: β α : Type x : β ⊢ Pair α β
スコープ内に
β
型の値(つまりx
)があり、アンダースコアからのエラーメッセージは、次のステップとしてコンストラクタPair.mk
を使用することを示唆しています:def Pair.pure (x : β) : Pair α β := Pair.mk _ x
don't know how to synthesize placeholder for argument 'first' context: β α : Type x : β ⊢ α
残念ながら、
α
型を利用する余地はありません。なぜなら、pure
はApplicative (Pair α)
のインスタンスを定義するために、αとして 可能なすべての型 に対して機能する必要がありますが、これは不可能です。極端な話、呼び出し元はα
を値を全く持たないEmpty
にすることもあるかもしれないのです。非モナドなアプリカティブ関手
フォームへのユーザ入力のバリデーションにあたっては、いちいち1つずつエラーを報告するのではなく、まとめて一度にエラーを出すことが一般的に最善と考えられています。これによってユーザはフィールドごとにエラーをうんざりしながら修正することなく、コンピュータに受け入れられるために何が必要かを概観することができます。
理想的には、ユーザ入力のバリデーションは、それを行う関数の型に現れます。この関数はチェックが行われたことを体現するデータ型を返却すべきです。例えば、テキストボックスに数値が含まれているかどうかをチェックする関数は、実際の数値型を返すべきです。バリデーションのルーチンは、入力がバリデーションを通過しなかったことを例外を投げることで表現できます。しかし、例外には大きな欠点があります:最初のエラーでプログラムが終了してしまうため、エラーのリストを蓄積することができないのです。
一方で、エラーのリストを蓄積し、リストが空でなければ失敗にする一般的な設計パターンにも問題があります。入力データの各部分をバリデーションする
if
文の長くネストされた列はメンテナンス性に欠け、出てきたエラーメッセージから何個かのエラーをいともたやすく見落とすでしょう。理想的には、バリデーションは新しい値を返しつつエラーメッセージを自動的に追跡して蓄積するようなAPIを使って動作するものであってほしいでしょう。Validate
というアプリカティブ関手はまさにそんなAPIを実装する1つの方法を提供します。Except
モナドのように、Validate
によってバリデーションされたデータを正確に特徴づける新しい値を構築することができます。しかし一方でExcept
と異なり、リストが空かどうかをチェックし忘れるリスク無しに複数のエラーを蓄積することができます。ユーザ入力
ユーザ入力の例として、次のような構造を考えてみましょう:
structure RawInput where name : String birthYear : String
実装ビジネスロジックは以下の通りです:
- 名前は空であってはならない
- 誕生年は非負の数値でなければならない
- 誕生年は1900より大きく、かつバリデーションを行った年以下でなければならない
これらをデータ型として表現するには、部分型 と呼ばれる新しい機能が必要になります。このツールがあれば、バリデーションのフレームワークをエラーを追跡するアプリカティブ関手を使って書くことができ、バリデーションルールもこのフレームワークで実装することができます。
部分型
これらの条件を表現するにあたって最も簡単なのは、
Subtype
と呼ばれるLeanの型を1つ追加することです:structure Subtype {α : Type} (p : α → Prop) where val : α property : p val
この構造体には2つの型パラメータがあります:1つはデータの型を表す暗黙パラメータ
α
で、もう1つはα
に対する述語を表す明示的なパラメータp
です。述語 (predicate)とは実際の文を生成するために値に置き換えることができる変数を持つ論理的な文であり、GetElem
のパラメータ が検索に用いる添え字が範囲内であることの意味を記述するのはその一例です。Subtype
の場合、述語はα
の値の中で述語が成り立つ部分集合を切り出します。この構造体の2つのフィールドはそれぞれα
の値と、その値が述語p
を満たす根拠です。LeanはSubtype
に対して特別な構文を持っています。p
がα → Prop
型を持つ場合、Subtype p
型は{x : α // p x}
と書くこともでき、型が自動的に推論される場合は{x // p x}
と書くことさえもできます。正の整数を帰納的な型として表現すること は明快で、プログラムしやすいです。しかし、これには重要な欠点があります。
Nat
とInt
はLeanプログラムにおいては普通の帰納型の構造を持っていますが、コンパイラはこれらを特別に扱い、高速な任意精度の数値ライブラリを使用して実装します。これは後から追加されるユーサ定義型には当てはまりません。しかし、Nat
の部分型を0以外の数に制限することで、コンパイル時に0を除きながら効率的な表現を使用することができます:def FastPos : Type := {x : Nat // x > 0}
最も小さい正の整数はもちろん1です。さて、これは帰納型のコンストラクタではなく、角括弧で構成される構造体のインスタンスです。第1引数は基礎となる
Nat
で、第2引数はそのNat
が0より大きいという根拠です:def one : FastPos := ⟨1, by simp⟩
OfNat
のインスタンスはPos
のインスタンスと非常によく似ていますが、n + 1 > 0
という根拠を提供するために短いタクティクによる証明を使う点が異なります:instance : OfNat FastPos (n + 1) where ofNat := ⟨n + 1, by simp_arith⟩
simp_arith
タクティクはsimp
に算術的な等式を追加したバージョンです。部分型は諸刃の剣です。これによってバリデーションルールの効率的な表現を可能にしますが、これらのルールを維持する負担をライブラリのユーザに移し、ユーザは重要な不変量に違反していないことを 証明 しなければなりません。一般的には、ライブラリの内部で使用し、すべての不変量が満たされていることを自動的に保障するAPIをユーザに提供し、必要な証明はライブラリの内部で行うのが良いでしょう。
ある
α
型の値が{x : α // p x}
の部分型に含まれるかどうかを調べるには、通常p x
という命題が決定可能である必要があります。等式と順序クラスについての節 では、決定可能な命題をif
と一緒に使用する方法について説明しました。if
を決定可能な命題で使用する場合、名前を指定することができます。then
ブランチでは、その名前は命題が真であることの根拠に束縛され、else
ブランチでは命題が偽であることの根拠に束縛されます。これは、与えられたNat
が正であるかどうかをチェックする時に便利です:def Nat.asFastPos? (n : Nat) : Option FastPos := if h : n > 0 then some ⟨n, h⟩ else none
then
ブランチでは、h
はn > 0
という根拠に束縛され、この根拠はSubtype
のコンストラクタの第2引数として使うことができます。入力のバリデーション
バリデーションされたユーザ入力は、以下の技術を駆使してビジネスロジックを表現した構造体です:
- 構造体の型自体はバリデーションチェックが行われた年をエンコード。そのため
CheckedInput 2019
はCheckedInput 2020
と同じ型ではありません。 - 誕生年は
String
ではなくNat
で表現 - 名前と誕生年のフィールドの許容される値を制約するために部分型を使用
structure CheckedInput (thisYear : Nat) : Type where name : {n : String // n ≠ ""} birthYear : {y : Nat // y > 1900 ∧ y ≤ thisYear}
入力のバリデータは現在の年と
RawInput
を引数に取り、チェック済みの入力か、少なくとも1つのバリデーション失敗のどちらかを返すべきです。これはValidate
型で表されます:inductive Validate (ε α : Type) : Type where | ok : α → Validate ε α | errors : NonEmptyList ε → Validate ε α
見た目は
Except
によく似ています。唯一の違いは、error
コンストラクタに複数の失敗を含めることができる点です。Validateは関手です。これに関数をマッピングすることで、
Except
のFunctor
インスタンスと同じように、成功した値の場合はその値が変換されます:instance : Functor (Validate ε) where map f | .ok x => .ok (f x) | .errors errs => .errors errs
Validate
のApplicative
インスタンスにはExcept
のインスタンスと重要な違いがあります:Except
のインスタンスは最初に遭遇したエラーで終了するのに対して、Validate
のインスタンスは関数と引数のブランチの 両方 からのすべてのエラーを蓄積することに注意を払っています:instance : Applicative (Validate ε) where pure := .ok seq f x := match f with | .ok g => g <$> (x ()) | .errors errs => match x () with | .ok _ => .errors errs | .errors errs' => .errors (errs ++ errs')
.errors
をNonEmptyList
のコンストラクタと一緒に使うと少し冗長になります。reportError
のような補助関数によって可読性が上がります。このアプリケーションでは、エラーのレポートはフィールド名とメッセージの組み合わせで構成されます:def Field := String def reportError (f : Field) (msg : String) : Validate (Field × String) α := .errors { head := (f, msg), tail := [] }
Validate
のApplicative
インスタンスでは、各フィールドのチェック手順を個別に記述し、組み合わせることができます。名前のチェックでは、文字列が空でないことを確認し、その根拠をSubtype
という形で返します。これはif
の根拠による束縛のバージョンを使用しています:def checkName (name : String) : Validate (Field × String) {n : String // n ≠ ""} := if h : name = "" then reportError "name" "Required" else pure ⟨name, h⟩
then
ブランチでは、h
はname = ""
という根拠に束縛され、else
ブランチでは¬name = ""
という根拠に束縛されます。バリデーションエラーによってはほかのチェックが不可能になってしまうケースも存在します。例えば、混乱したユーザが誕生年として数字の代わりに
"syzygy"
と書いた場合、誕生年のフィールドが1900年より大きいかどうかをチェックしても無意味です。数値の許容範囲をチェックすることは、そもそもフィールドに実際に数値が含まれていることを確認した後でのみ意味があります。これは関数andThen
を使って表現することができます:def Validate.andThen (val : Validate ε α) (next : α → Validate ε β) : Validate ε β := match val with | .errors errs => .errors errs | .ok x => next x
この関数の型シグネチャは
Monad
インスタンスのbind
として使用するのに適していますが、そうしないのにはれっきとした理由があります。それについてはApplicative
の約定を説明する節 で説明します。誕生年が数字であることを確認するには、
String.toNat? : String → Option Nat
という組み込み関数が便利です。先にString.trim
を使って先頭と末尾の空白を除去するととてもユーザフレンドリになります:def checkYearIsNat (year : String) : Validate (Field × String) Nat := match year.trim.toNat? with | none => reportError "birth year" "Must be digits" | some n => pure n
提供された年が予想される範囲内であることをチェックするには、
if
の根拠提供の形式をネストして使用します:def checkBirthYear (thisYear year : Nat) : Validate (Field × String) {y : Nat // y > 1900 ∧ y ≤ thisYear} := if h : year > 1900 then if h' : year ≤ thisYear then pure ⟨year, by simp [*]⟩ else reportError "birth year" s!"Must be no later than {thisYear}" else reportError "birth year" "Must be after 1900"
最後に、これら3つの要素を
seq
を使って結合します:def checkInput (year : Nat) (input : RawInput) : Validate (Field × String) (CheckedInput year) := pure CheckedInput.mk <*> checkName input.name <*> (checkYearIsNat input.birthYear).andThen fun birthYearAsNat => checkBirthYear year birthYearAsNat
checkInput
をテストしてみると、実際に複数のフィードバックを返してくれることがわかるでしょう:#eval checkInput 2023 {name := "David", birthYear := "1984"}
Validate.ok { name := "David", birthYear := 1984 }
#eval checkInput 2023 {name := "", birthYear := "2045"}
Validate.errors { head := ("name", "Required"), tail := [("birth year", "Must be no later than 2023")] }
#eval checkInput 2023 {name := "David", birthYear := "syzygy"}
Validate.errors { head := ("birth year", "Must be digits"), tail := [] }
checkInput
による入力のバリデーションは、Monad
に対するApplicative
の重要な利点を示しています。なぜなら、>>=
は最初のステップの値に基づいて残りのプログラムを変更するのに十分なパワーを提供するため、最初のステップから渡される値を 受け取らなくてはならないからです 。もし値を受け取らなかった場合(例えばエラーが発生した場合)、>>=
はプログラムの残りの部分を実行することができません。Validate
はどんな時でもプログラムの残りの部分を実行することが有用である理由を示しています:それ以前のデータが不要なケースでは、プログラムの残りの部分を実行することで有用な情報(この場合はより多くのバリデーションエラー)を得ることができます。Applicative
の<*>
は結果を再結合する前に両方の引数を実行することができます。同様に、>>=
は逐次実行を強制します。各ステップは、次のステップを実行する前に完了しなければなりません。一般的にはこれは便利ですが、プログラムの実際のデータ依存関係から自然に生まれる異なるスレッドの並列実行を不可能にしてしまいます。Monad
のようなより強力な抽象化によって、API利用者が利用できる柔軟性は向上しますが、API実装者が利用できる柔軟性は低下します。アプリカティブ関手の約定
Functor
やMonad
、BEq
とHashable
を実装した型と同じように、Applicative
にはすべてのインスタンスで順守すべきルールがあります。アプリカティブ関手は以下の4つのルールに従わなければなりません:
- 恒等性に配慮すべき、つまり
pure id <*> v = v
- 合成に配慮すべき、つまり
pure (· ∘ ·) <*> u <*> v <*> w = u <*> (v <*> w)
- pure演算を連ねても何も起こらないこと、つまり
pure f <*> pure x = pure (f x)
- pure演算は実行順序によらないこと、つまり
u <*> pure x = pure (fun f => f x) <*> u
これらを
Applicative Option
のインスタンスでチェックするには、まずpure
をsome
に展開します。最初のルールは
some id <*> v = v
です。Option
のseq
の定義によると、これはid <$> v = v
と同じであり、この式はすでにチェック済みのFunctor
についてのルールのひとつです。2つ目のルールは
some (· ∘ ·) <*> u <*> v <*> w = u <*> (v <*> w)
です。もしu
、v
、w
のいずれかがnone
であれば、両辺はnone
になり、式が成り立ちます。そこでu
はsome f
、v
はsome g
、w
はsome x
であることを仮定すると、この式はsome (· ∘ ·) <*> some f <*> some g <*> some x = some f <*> (some g <*> some x)
であることと等しくなります。両辺を評価すると、同じ結果が得られます:some (· ∘ ·) <*> some f <*> some g <*> some x ===> some (f ∘ ·) <*> some g <*> some x ===> some (f ∘ g) <*> some x ===> some ((f ∘ g) x) ===> some (f (g x)) some f <*> (some g <*> some x) ===> some f <*> (some (g x)) ===> some (f (g x))
3つ目のルールは、
seq
の定義から直接導かれます:some f <*> some x ===> f <$> some x ===> some (f x)
4つ目のケースでは
u
をsome f
と仮定します。というのも、もしnone
であれば、等式の両辺はnone
になるからです。some f <*> some x
はsome (f x)
へ直接評価され、some (fun g => g x) <*> some f
も同じ式へ評価されます。すべてのアプリカティブ関手は関手
map
を定義するには、Applicative
の2つの演算子で事足ります:def map [Applicative f] (g : α → β) (x : f α) : f β := pure g <*> x
ただし、これは
Applicative
の約定がFunctor
の約定を保証している場合にのみFunctor
の実装に使うことができます。Functor
の最初のルールはid <$> x = x
で、これはApplicative
の最初のルールから直接導かれます。Functor
の2番目のルールはmap (f ∘ g) x = map f (map g x)
です。ここでmap
の定義を展開するとpure (f ∘ g) <*> x = pure f <*> (pure g <*> x)
となります。pure演算の連続は何も起こらないというルールを使えば、左辺はpure (· ∘ ·) <*> pure f <*> pure g <*> x
と書き換えることができます。これはアプリカティブ関手が関数の合成に配慮するというルールの一例です。以上から
Applicative
の定義がFunctor
を継承したものであることが正当化され、pure
とseq
の観点からmap
のデフォルト定義が与えられます:class Applicative (f : Type → Type) extends Functor f where pure : α → f α seq : f (α → β) → (Unit → f α) → f β map g x := seq (pure g) (fun () => x)
すべてのモナドはアプリカティブ関手
Monad
のインスタンスにはもうすでにpure
の実装が必須でした。これにbind
を用いれば、seq
の定義には十分足ります:def seq [Monad m] (f : m (α → β)) (x : Unit → m α) : m β := do let g ← f let y ← x () pure (g y)
繰り返しになりますが、
Monad
の約定からApplicative
の約定が導かれることをチェックすることで、Monad
がApplicative
を継承していれば上記をseq
のデフォルト定義として使うことができます。この節の残りは、この
bind
に基づくseq
の実装が実際にApplicative
の約定を満たすという議論からなります。関数型プログラミングの素晴らしい点の1つは、このような論証が 式の評価に関する最初の節 にある評価ルールなどを使って、鉛筆で紙の上に書くことができることです。こうした議論を読みながら操作の意味を考えることは理解の助けになり得ます。do
記法の代わりに、>>=
を明示的に使うことでMonad
ルールを適用しやすくなります:def seq [Monad m] (f : m (α → β)) (x : Unit → m α) : m β := do f >>= fun g => x () >>= fun y => pure (g y)
この定義が恒等性に配慮していることを確認するには
seq (pure id) (fun () => v) = v
をチェックします。左辺はpure id >>= fun g => (fun () => v) () >>= fun y => pure (g y)
と等価です。真ん中の単位関数はすぐに取り除くことができ、pure id >>= fun g => v >>= fun y => pure (g y)
となります。pure
が>>=
の左単位であることを利用すると、これはv >>= fun y => pure (id y)
と同じであり、v >>= fun y => pure y
となります。fun x => f x
はf
と同じであるため、これはv >>= pure
と等しく、pure
が>>=
の右単位であることを利用してv
を得ることができます。このような非形式な推論は、少し書式を変えれば読みやすくなります。例えば、次の表に従うと「EXPR1 ={ REASON }= EXPR2」を「EXPR1はEXPR2と同じである」と読めます:
pure id >>= fun g => v >>= fun y => pure (g y)
={pure
is a left identity of>>=
}=v >>= fun y => pure (id y)
={ Reduce the call toid
}=v >>= fun y => pure y
={fun x => f x
is the same asf
}=v >>= pure
={pure
is a right identity of>>=
}=v
関数の合成に配慮することを確認するには、
pure (· ∘ ·) <*> u <*> v <*> w = u <*> (v <*> w)
をチェックします。最初のステップは<*>
をこのseq
の定義に置き換えることです。その後はMonad
の約定による恒等性と結合性のルールによる一連のステップを踏むだけです(いくぶん長いですが):seq (seq (seq (pure (· ∘ ·)) (fun _ => u)) (fun _ => v)) (fun _ => w)
={ Definition ofseq
}=((pure (· ∘ ·) >>= fun f => u >>= fun x => pure (f x)) >>= fun g => v >>= fun y => pure (g y)) >>= fun h => w >>= fun z => pure (h z)
={pure
is a left identity of>>=
}=((u >>= fun x => pure (x ∘ ·)) >>= fun g => v >>= fun y => pure (g y)) >>= fun h => w >>= fun z => pure (h z)
={ Insertion of parentheses for clarity }=((u >>= fun x => pure (x ∘ ·)) >>= (fun g => v >>= fun y => pure (g y))) >>= fun h => w >>= fun z => pure (h z)
={ Associativity of>>=
}=(u >>= fun x => pure (x ∘ ·) >>= fun g => v >>= fun y => pure (g y)) >>= fun h => w >>= fun z => pure (h z)
={pure
is a left identity of>>=
}=(u >>= fun x => v >>= fun y => pure (x ∘ y)) >>= fun h => w >>= fun z => pure (h z)
={ Associativity of>>=
}=u >>= fun x => v >>= fun y => pure (x ∘ y) >>= fun h => w >>= fun z => pure (h z)
={pure
is a left identity of>>=
}=u >>= fun x => v >>= fun y => w >>= fun z => pure ((x ∘ y) z)
={ Definition of function composition }=u >>= fun x => v >>= fun y => w >>= fun z => pure (x (y z))
={ Time to start moving backwards!pure
is a left identity of>>=
}=u >>= fun x => v >>= fun y => w >>= fun z => pure (y z) >>= fun q => pure (x q)
={ Associativity of>>=
}=u >>= fun x => v >>= fun y => (w >>= fun p => pure (y p)) >>= fun q => pure (x q)
={ Associativity of>>=
}=u >>= fun x => (v >>= fun y => w >>= fun q => pure (y q)) >>= fun z => pure (x z)
={ This includes the definition ofseq
}=u >>= fun x => seq v (fun () => w) >>= fun q => pure (x q)
={ This also includes the definition ofseq
}=seq u (fun () => seq v (fun () => w))
pure演算の列が何もしないことのチェックは次のようになります:
seq (pure f) (fun () => pure x)
={ Replacingseq
with its definition }=pure f >>= fun g => pure x >>= fun y => pure (g y)
={pure
is a left identity of>>=
}=pure f >>= fun g => pure (g x)
={pure
is a left identity of>>=
}=pure (f x)
そして最後に、pure演算の順序が重要でないことを確認します:
seq u (fun () => pure x)
={ Definition ofseq
}=u >>= fun f => pure x >>= fun y => pure (f y)
={pure
is a left identity of>>=
}=u >>= fun f => pure (f x)
={ Clever replacement of one expression by an equivalent one that makes the rule match }=u >>= fun f => pure ((fun g => g x) f)
={pure
is a left identity of>>=
}=pure (fun g => g x) >>= fun h => u >>= fun f => pure (h f)
={ Definition ofseq
}=seq (pure (fun f => f x)) (fun () => u)
以上から
Monad
の定義がApplicative
を継承していることが正当化され、seq
のデフォルト定義を持ちます:class Monad (m : Type → Type) extends Applicative m where bind : m α → (α → m β) → m β seq f x := bind f fun g => bind (x ()) fun y => pure (g y)
Applicative
の固有のデフォルト定義のmap
はすべてのMonad
インスタンスが自動的にApplicative
とFunctor
のインスタンスを生成することを意味します。追加の条項
それぞれの型クラスに関連付けられた個々の約定を順守することに加えて、
Functor
とApplicative
、Monad
を組み合わせた実装はこれらのデフォルトの実装と同等に動作する必要があります。言い換えると、Applicative
とMonad
インスタンスの両方を提供する型は、Monad
インスタンスがデフォルトの実装として生成するバージョンと異なる動作をするseq
の実装を持つべきではありません。これは多相関数をリファクタリングして>>=
を等価な<*>
に置き換えたり、<*>
を等価な>>=
に置き換えたりする場合があるため重要です。このリファクタリングによって、このコードを使用するプログラムの意味が変わることはあってはなりません。このルールは、前節においてなぜ
Validate.andThen
をMonad
インスタンスのbind
の実装に使ってはいけないのかを説明します。この関数自体はモナドの約定に従います。しかし、seq
を実装するために使用した場合、その動作はseq
自体と同等になりません。両者の違いを見るために、エラーを返す2つの計算を例にとってみましょう。2つのエラーを返すべきケースの例から始めると、1つは関数をバリデーションした結果(これは関数に先に渡された引数によるものでも同様に発生します)、もう1つは引数のバリデーションによるものです:def notFun : Validate String (Nat → String) := .errors { head := "First error", tail := [] } def notArg : Validate String Nat := .errors { head := "Second error", tail := [] }
これらを
Validate
のApplicative
インスタンスの<*>
のバージョンと組み合わせると、両方のエラーがユーザに報告されます:notFun <*> notArg ===> match notFun with | .ok g => g <$> notArg | .errors errs => match notArg with | .ok _ => .errors errs | .errors errs' => .errors (errs ++ errs') ===> match notArg with | .ok _ => .errors { head := "First error", tail := [] } | .errors errs' => .errors ({ head := "First error", tail := [] } ++ errs') ===> .errors ({ head := "First error", tail := [] } ++ { head := "Second error", tail := []}) ===> .errors { head := "First error", tail := ["Second error"]}
一方で
>>=
で実装されていたseq
のバージョンを、ここではandThen
に書き換えて使用すると、最初のエラーしか利用できません:seq notFun (fun () => notArg) ===> notFun.andThen fun g => notArg.andThen fun y => pure (g y) ===> match notFun with | .errors errs => .errors errs | .ok val => (fun g => notArg.andThen fun y => pure (g y)) val ===> .errors { head := "First error", tail := [] }
オルタナティブ
失敗からの復帰
Validate
は入力を受け付ける方法が複数ある場合にも使用することができます。RawInput
の形式の入力に対して前節の方法の代わりに、レガシーなシステムからの慣習に則ったビジネスルールは以下のようになります:- すべての人間ユーザは、4桁の誕生年を記入しなければならない。
- 1970年より前に生まれたユーザは古い記録が不完全なため氏名を記入する必要はない。
- 1970年より後に生まれたユーザは氏名を記入しなければならない。
- 企業は誕生年を
"FIRM"
とし、会社名を記入すること。
1970年生まれの利用者に該当する規定はありません。これに該当する場合は入力を諦めるか、誕生年を偽るか、このサービスの会社に電話をかけるかのいずれかになるでしょう。この会社は、これはビジネス上許容できるコストだと考えています。
以下の帰納型は、これらの記述されたルールに基づいた値を表現しています:
abbrev NonEmptyString := {s : String // s ≠ ""} inductive LegacyCheckedInput where | humanBefore1970 : (birthYear : {y : Nat // y > 999 ∧ y < 1970}) → String → LegacyCheckedInput | humanAfter1970 : (birthYear : {y : Nat // y > 1970}) → NonEmptyString → LegacyCheckedInput | company : NonEmptyString → LegacyCheckedInput deriving Repr
しかし、このルールに対応するバリデータは3つのケースすべてに対応しなければならないため、前節のものより複雑になります。入れ子になった一連の
if
式として書くこともできますが、3つのケースを個別に設計しそれらを組み合わせる方が簡単です。そのためには、エラーメッセージを保持しながら失敗から回復する手段が必要になります:def Validate.orElse (a : Validate ε α) (b : Unit → Validate ε α) : Validate ε α := match a with | .ok x => .ok x | .errors errs1 => match b () with | .ok x => .ok x | .errors errs2 => .errors (errs1 ++ errs2)
この失敗からの復帰パターンは、Leanに
OrElese
という名前の型クラスとそれに伴う組み込みの構文があるほど一般的です:class OrElse (α : Type) where orElse : α → (Unit → α) → α
式
E1 <|> E2
はOrElse.orElse E1 (fun () => E2)
の省略形です。Validate
のOrElse
インスタンスを使うことで、この構文をエラー復帰に使用することができます:instance : OrElse (Validate ε α) where orElse := Validate.orElse
LegacyCheckedInput
のバリデータは各コンストラクタのバリデータから構築することができます。会社用のルールでは誕生年は文字列"FIRM"
でなければならず、名前は空であってはなりません。しかし、コンストラクタLegacyCheckedInput.company
は誕生年をまったく表現しないため、単に<*>
を使ってバリデーションすることはできません。ポイントは引数を無視する<*>
関数を使うことです。型にこの根拠を付与することなく、論理条件が成立することをチェックするには
checkThat
を使用します:def checkThat (condition : Bool) (field : Field) (msg : String) : Validate (Field × String) Unit := if condition then pure () else reportError field msg
この
checkCompany
の定義はcheckThat
を使用し、その結果のUnit
値を捨てています:def checkCompany (input : RawInput) : Validate (Field × String) LegacyCheckedInput := pure (fun () name => .company name) <*> checkThat (input.birthYear == "FIRM") "birth year" "FIRM if a company" <*> checkName input.name
しかし、この定義はかなり煩雑です。これをシンプルにするにあたって2つの方法があります。1つ目は、最初の
<*>
の使用を、最初の引数が返す値を自動的に無視する*>
という特殊なバージョンに置き換えることです。この演算子もSeqRight
と呼ばれる型クラスによって制御され、E1 *> E2
はSeqRight.seqRight E1 (fun () => E2)
の糖衣構文です:class SeqRight (f : Type → Type) where seqRight : f α → (Unit → f β) → f β
seqRight
の実装にはseq
によるデフォルト実装:seqRight (a : f α) (b : Unit → f β) : f β := pure (fun _ x => x) <*> a <*> b ()
が存在します。seqRight
を使うことで、checkCompany
はもっとシンプルになります:def checkCompany (input : RawInput) : Validate (Field × String) LegacyCheckedInput := checkThat (input.birthYear == "FIRM") "birth year" "FIRM if a company" *> pure .company <*> checkName input.name
さらなる簡略化も可能です。すべての
Applicative
に対して、pure F <*> E
はf <$> E
と等価です。言い換えると、pure
でApplicative
内に置かれた関数をseq
を使って適用するのはやりすぎであり、Functor.map
を使って適用すればよかったのです。この簡略化は以下のようになります:def checkCompany (input : RawInput) : Validate (Field × String) LegacyCheckedInput := checkThat (input.birthYear == "FIRM") "birth year" "FIRM if a company" *> .company <$> checkName input.name
LegacyCheckedInput
の残り2つのコンストラクタは、フィールドに部分型を使用しています。部分型をチェックする汎用的なツールがあれば、さらに読みやすくなるでしょう:def checkSubtype {α : Type} (v : α) (p : α → Prop) [Decidable (p v)] (err : ε) : Validate ε {x : α // p x} := if h : p v then pure ⟨v, h⟩ else .errors { head := err, tail := [] }
関数の引数リストにて、引数
v
とp
を指定した後に[Decidable (p v)]
という型クラスが来ていることがミソです。そうでなければ手動で指定した値ではなく、自動的な暗黙の引数を追加で参照することになります。Decidable
インスタンスは、命題p v
をif
を使ってチェックできるようにするものです。人間についての2つのケースでは追加の道具は必要ありません:
def checkHumanBefore1970 (input : RawInput) : Validate (Field × String) LegacyCheckedInput := (checkYearIsNat input.birthYear).andThen fun y => .humanBefore1970 <$> checkSubtype y (fun x => x > 999 ∧ x < 1970) ("birth year", "less than 1970") <*> pure input.name def checkHumanAfter1970 (input : RawInput) : Validate (Field × String) LegacyCheckedInput := (checkYearIsNat input.birthYear).andThen fun y => .humanAfter1970 <$> checkSubtype y (· > 1970) ("birth year", "greater than 1970") <*> checkName input.name
以上の3つのケースのバリデータは
<|>
を使って組み合わせることができます:def checkLegacyInput (input : RawInput) : Validate (Field × String) LegacyCheckedInput := checkCompany input <|> checkHumanBefore1970 input <|> checkHumanAfter1970 input
成功したケースでは、期待通りに
LegacyCheckedInput
のコンストラクタが返されます:#eval checkLegacyInput ⟨"Johnny's Troll Groomers", "FIRM"⟩
Validate.ok (LegacyCheckedInput.company "Johnny's Troll Groomers")
#eval checkLegacyInput ⟨"Johnny", "1963"⟩
Validate.ok (LegacyCheckedInput.humanBefore1970 1963 "Johnny")
#eval checkLegacyInput ⟨"", "1963"⟩
Validate.ok (LegacyCheckedInput.humanBefore1970 1963 "")
最悪の入力においては、起こりうるすべての失敗が返されます:
#eval checkLegacyInput ⟨"", "1970"⟩
Validate.errors { head := ("birth year", "FIRM if a company"), tail := [("name", "Required"), ("birth year", "less than 1970"), ("birth year", "greater than 1970"), ("name", "Required")] }
Alternative
クラス多くの型が失敗と復帰の概念をサポートしています。様々なモナドでの算術式の評価 についての節で定義した
Many
モナドや、Option
型はそのような型の1つです。どちらも失敗に対して理由を付与しません(一方でExcept
とValidate
では何が間違っていたのかを示す必要があります)。Alternative
クラスはアプリカティブ関手に失敗と復帰のための演算子を追加したものとして記述されます:class Alternative (f : Type → Type) extends Applicative f where failure : f α orElse : f α → (Unit → f α) → f α
Add α
の実装者がHAdd α α α
のインスタンスをタダで取得できるように、Alternative
の実装者はOrElse
インスタンスを何もせずに手に入れることができます:instance [Alternative f] : OrElse (f α) where orElse := Alternative.orElse
Option
に対するAlternative
は引数のうち一番初めの非none
を保持するように実装されます:instance : Alternative Option where failure := none orElse | some x, _ => some x | none, y => y ()
同様に、
Many
の実装はMany.union
の一般的な構造に従っていますが、遅延評価のためのUnit
パラメータの配置が異なるため若干の違いがあります:def Many.orElse : Many α → (Unit → Many α) → Many α | .none, ys => ys () | .more x xs, ys => .more x (fun () => orElse (xs ()) ys) instance : Alternative Many where failure := .none orElse := Many.orElse
他の型クラスと同様に、
Alternative
はAlternative
を実装したアプリカティブ関手 すべて に対して機能する様々な操作を定義することができます。最も重要なもののの1つはguard
で、これは決定可能な命題が偽の場合にfailure
を実行します:def guard [Alternative f] (p : Prop) [Decidable p] : f Unit := if p then pure () else failure
これはモナドを使ったプログラムの実行を早期に終了させるのに非常に便利です。
Many
においては、ある自然数に対するすべての偶数の約数を計算する以下のプログラムのように、検索の分岐全体をフィルタリングするために使うことができます:def Many.countdown : Nat → Many Nat | 0 => .none | n + 1 => .more n (fun () => countdown n) def evenDivisors (n : Nat) : Many Nat := do let k ← Many.countdown (n + 1) guard (k % 2 = 0) guard (n % k = 0) pure k
20
に対して実行すると予想通りの結果を得ます:#eval (evenDivisors 20).takeAll
[20, 10, 4, 2]
演習問題
バリデータの利便性向上
<|>
を使用したValidate
プログラムから返されるエラーは読みにくいものになります。というのも、あるエラーがエラーのリストに含まれているということは、ただ単にそのエラーがバリデーション中の経路の どこか で発生したということを意味します。より構造化されたエラーレポートがあれば、ユーザをより正確に導くことができます:Validate.error
内のNonEmptyList
をただの型変数に置き換え、Applicative (Validate ε)
とOrElse (Validate ε α)
インスタンスの定義を更新してAppend ε
インスタンスが利用可能であることだけを要求するようにしてください。- バリデーション中に発生するすべてのエラーを変換する関数
Validate.mapErrors : Validate ε α → (ε → ε') → Validate ε' α
を定義してください。 - エラーを表すデータ型
TypeError
を使って、レガシーなバリデーションシステムを書き換えることで3つの選択肢を通してその経路を追跡できるようにしてください。 TypeError
に蓄積された警告とエラーをユーザフレンドリに出力する関数report : TreeError → String
を書いてください。
inductive TreeError where | field : Field → String → TreeError | path : String → TreeError → TreeError | both : TreeError → TreeError → TreeError instance : Append TreeError where append := .both
宇宙
議論をシンプルにするために、本書ではこれまでLeanの重要な特徴である 宇宙 (universe)について触れてきませんでした。宇宙とは型を分類する型のことです。宇宙の例としては
Type
とProp
の2つが良く知られています。Type
は通常の型、例えばNat
・String
・Int → String × Char
・IO Unit
などを分類します。Prop
は"nisse" = "elf"
や3 > 2
のような真偽を表す命題を分類します。Prop
の型はType
です:#check Prop
Prop : Type
技術的な理由から、これら2つより上の宇宙が必要です。というのも
Type
はそれ自身をType
とすることができないからです。仮に出来てしまうと、論理的なパラドックスが構築できてしまい、Leanの定理証明器としての有用性を損なうことになります。これに対する形式的な議論は ジラールのパラドックス (Girard's Paradox)として知られています。これは初期の集合論の矛盾を示すために用いられた ラッセルのパラドックス (Russell's Paradox)としてよく知られているパラドックスと関連しています。初期の集合論では集合はある性質によって定義することができます。例えば、全ての赤いものの集合、全ての果物の集合、全ての自然数の集合や、さらに全ての集合の集合なんてものまでが定義されます。ある集合が与えられると、ある要素がその集合に含まれているかどうかを問うことができます。例えば、「青い鳥」は「全ての赤いものの集合」には含まれませんが、「全ての赤いものの集合」は「全ての集合の集合」に含まれます。実は「全ての集合の集合」はそれ自身をも含んでいます。
では「全ての『自分自身を含まない集合』の集合」は何を含むでしょうか?「全ての赤いものの集合」はそれ自身は赤くないため、先ほどの集合に含まれます。「全ての集合の集合」はそれ自身を含むので、先ほどの集合に含まれません。しかし、「全ての『自分自身を含まない集合』の集合」はそれ自身を含むでしょうか?もし自分自身を含んでいるのなら、「全ての『自分自身を含まない集合』の集合」には含まれないことになります。しかし、もし自分自身を含んでいないのなら、「全ての『自分自身を含まない集合』の集合」に含まれなければなりません。
これは矛盾であるため、最初の仮定のうち何かがが間違っていたことを示しています。特に、任意の性質を与えることで集合を構成できるようにするのは強力すぎます。これ以降の集合論では、このパラドックスを取り除くために集合の形成を制限しています。
依存型理論において
Type
にType
を割り当てるようにすると、関連したパラドックスを構築することができます。Leanが一貫した論理的基礎を持ち、数学の道具として使えることを保証するためには、Type
はほかの型を持つ必要があります。この型をType 1
と呼びます:#check Type
Type : Type 1
同じように、
Type 1
はType 2
型を、Type 2
はType 3
型を、Type 3
はType 4
型をそれぞれ持ち、これが続いていきます。関数型は、引数の型と戻り値の型の両方を含むことができる最小の宇宙になります。つまり、
Nat → Nat
はType
型に、Type → Type
はType 1
型に、Type 1 → Type 2
はType 3
型になります。このルールには1つ例外があります。関数の戻り値の型が
Prop
である場合、引数がType
やType 1
のような大きな宇宙であってもこれらの関数の型はすべてProp
に含まれます。特に、通常の型の値に対する述語もProp
に含まれることになります。例えば、(n : Nat) → n = n + 0
はNat
からその値自体と0を足したものが等しいという根拠への関数を表します。Nat
はType
に含まれていますが、このルールによりこの関数の型はProp
に含まれます。同様に、Type
はType 1
に含まれているにも関わらず、関数型Type → 2 + 2 = 4
もまたProp
に含まれます。ユーザ定義型
構造体と帰納的データ型は属する宇宙を指定して宣言することができます。そして、それぞれのデータ型がそれ自身の型を含まないほど十分な大きさの宇宙に存在することでパラドックスを回避しているかどうかをLeanはチェックします。例えば、以下の宣言では
MyList
はType
に属すると宣言されており、その型引数α
もType
に属すると宣言されています:inductive MyList (α : Type) : Type where | nil : MyList α | cons : α → MyList α → MyList α
MyList
自体はType → Type
型です。これは引数がType
であることからこの関数型はType 1
となり、型自体を格納するために使用することができないことを意味します:def myListOfNat : MyList Type := .cons Nat .nil
application type mismatch MyList Type argument Type has type Type 1 : Type 2 but is expected to have type Type : Type 1
MyList
を更新して、その引数をType 1
にすると、今度はLeanによって定義が拒否されます:inductive MyList (α : Type 1) : Type where | nil : MyList α | cons : α → MyList α → MyList α
invalid universe level in constructor 'MyList.cons', parameter has type α at universe level 2 it must be smaller than or equal to the inductive datatype universe level 1
このエラーは
α
型を持つcons
の引数がMyList
よりも大きな宇宙であるために発生します。MyList
自身をType 1
に配置することでこの問題は解決されますが、その代償としてMyList
自身がType
であることを期待するコンテキストで使用する場合には不便になります。あるデータ型が許容されるかどうかを規定する具体的な少々複雑です。一般的に言えば、データ型を引数の中で最大の宇宙と同じところから始めることが最も簡単です。もしLeanがその定義を拒否したら、宇宙のレベルを1つあげます。これで大体はうまくいきます。
宇宙多相
データ型を特定の宇宙で定義すると、コードが重複する可能性があります。
MyList
をType → Type
と定めることは、型自体のリストには使えないことを意味します。一方でType 1 → Type 1
と定めることは型のリストのリストには使えないことを意味します。この調子でデータ型をコピペしてType
・Type 1
・Type 2
……のそれぞれの定義を作成するのではなく、宇宙多相 (universe polymorphism)と呼ばれる機能を使用することで、これらの宇宙のいずれにでもインスタンス化できる単一の定義を記述することができます。通常の多相型は、定義中の型を表すために変数を使用します。これによって、Leanはこの変数に異なる値を埋め込めるようになり、これらの定義が様々な型で使用できるようになります。同様に、宇宙多相は定義内の宇宙を変数で表すことができ、Leanはこの変数に異なる宇宙を埋め込めるようになり、様々な宇宙で使用できるようにします。型の変数が慣例的にギリシャ文字で命名されるのに対して、宇宙の引数は
u
・v
・w
と命名されます。この
MyList
の定義では、特定の宇宙レベルを指定するのではなく、任意のレベルを表す変数u
を使用します。Type
を使用したデータ型の場合、u
は0
となり、Type 3
を使用した場合、u
は3
となります:inductive MyList (α : Type u) : Type u where | nil : MyList α | cons : α → MyList α → MyList α
この定義により、
MyList
の同じ定義を使って、実際の自然数と自然数型そのもののの両方をそれぞれ格納できます:def myListOfNumbers : MyList Nat := .cons 0 (.cons 1 .nil) def myListOfNat : MyList Type := .cons Nat .nil
さらに
MyList
自体も格納できます:def myListOfList : MyList (Type → Type) := .cons MyList .nil
これは論理的なパラドックスの記述を可能にしているように思えます。というのも宇宙システムの要点は自己言及的な型の除外だったからです。しかしこの裏では、
MyList
の宇宙レベルの引数が出現しているMyList
のそれぞれの個所で与えられています。要するに、MyList
の宇宙多相な定義は各レベルでデータ型の コピー を作成し、レベルの引数はどのコピーを使用するかを選択しているのです。これらのレベルの引数はドットと波括弧で記述します。したがって、MyList.{0} : Type → Type
と、MyList.{1} : Type 1 → Type 1
、MyList.{2} : Type 2 → Type 2
となります。レベルの明示的に書くと、先ほどの例は以下のようになります:
def myListOfNumbers : MyList.{0} Nat := .cons 0 (.cons 1 .nil) def myListOfNat : MyList.{1} Type := .cons Nat .nil def myListOfList : MyList.{1} (Type → Type) := .cons MyList.{0} .nil
宇宙多相の定義が引数として複数の型を取る場合、最大限に柔軟性を上げるために各引数に独自のレベル変数を与えると良いでしょう。例えば、1つのレベル引数を持つバージョンの
Sum
は次のように書くことができます:inductive Sum (α : Type u) (β : Type u) : Type u where | inl : α → Sum α β | inr : β → Sum α β
この定義は複数のレベルで使うことができます:
def stringOrNat : Sum String Nat := .inl "hello" def typeOrType : Sum Type Type := .inr Nat
しかし、この場合両方の引数が同じ宇宙に存在している必要があります:
def stringOrType : Sum String Type := .inr Nat
application type mismatch Sum String Type argument Type has type Type 1 : Type 2 but is expected to have type Type : Type 1
このデータ型は、2つの型引数の宇宙レベルに異なる変数を割り当て、出来上がるデータ型がこの2つの型引数のうち大きいほうのものになるように宣言することで、より柔軟なものにすることができます:
inductive Sum (α : Type u) (β : Type v) : Type (max u v) where | inl : α → Sum α β | inr : β → Sum α β
これにより、
Sum
を異なる宇宙からの引数で使うことができます:def stringOrType : Sum String Type := .inr Nat
Leanにおいて、宇宙レベルが期待される位置では以下すべての指定が認められています:
0
や1
などの具体的なレベルu
やv
などのレベルを表す変数- 2つのレベルに
max
を適用することで記述される大きい方のレベル + 1
で記述されるレベルの増加
宇宙多相的な定義の記述
ここまで本書で定義されたデータ型はすべて
Type
であり、データとして最小の宇宙でした。List
やSum
などのLeanの標準ライブラリの多相データ型を紹介する場合も、本書ではそれらの宇宙多相ではないバージョンを作成しました。実際には型レベルと型レベルでないプログラムの間でコードを再利用できるように宇宙多相を使用しています。宇宙多相型を書く際には一般的なガイドラインがあります。まず、独立した型の引数は異なる宇宙変数を持つべきです。これにより多相定義をより多様な引数で使用できるようになり、コードの再利用の可能性が高まります。第二に、型全体は通常すべての宇宙変数の最大値以上になります。まずは小さい方から試してみてください。最後に、新しい型をできるだけ小さな宇宙に置くことでコンテキストによってはより柔軟に使用できるようになります。
Nat
やString
のような多相でない型はType 0
に直接置くと良いでしょう。Prop
と多相Type
やType 1
などがプログラムやデータを分類する型を記述しているように、Prop
は論理命題を分類します。Prop
の型はある文が真であることの説得力のある根拠となるものを記述します。命題は多くの点で通常の型と似ています:どちらも帰納的に宣言でき、コンストラクタを持つことができ、関数を引数として取ることができます。しかし、データ型とは異なり、文が真であることを証明するためには どの 根拠が提供されるかは一般的には重要ではなく、根拠が提供されているという こと だけが重要です。一方でプログラムにおいては、例えばそれがNat
を返すという事実だけでなく、それが 正しいNat
であることが非常に重要です。Prop
は宇宙の階層の一番下にあり、Prop
の型はType
です。これはProp
がNat
と同じ理由でList
に引数として与えられることを意味します。命題のリストはList Prop
型を持ちます:def someTruePropositions : List Prop := [ 1 + 1 = 2, "Hello, " ++ "world!" = "Hello, world!" ]
宇宙の引数を埋めることで、
Prop
がType
であることが明示的に示されます:def someTruePropositions : List.{0} Prop := [ 1 + 1 = 2, "Hello, " ++ "world!" = "Hello, world!" ]
裏側では、
Prop
とType
はSort
という1つの階層に統合されています。Prop
はSort 0
と同じであり、Type 0
はSort 1
、Type 1
はSort 2
です。実は、Type u
はSort (u+1)
と同じです。通常Leanでプログラムを書く際には関係ありませんが、ときどきエラーメッセージで出てくることがあります。そしてこれがCoeSort
クラスの名前を説明します。さらに、Prop
をSort 0
とすることで、新たな宇宙演算子が使えるようになります。imax u v
と書かれる宇宙レベルは、v
が0
の場合は0
となり、それ以外ではu
とv
の大きい方となります。これはSort
と合わせることで、Prop
を返す関数の特別なルールを、Prop
とType
の宇宙間で可能な限り取りまわせるコードを書くときに使用することができます。多相の実践
本書の残りの部分では、多相データ型、構造体、クラスの定義はLeanの標準ライブラリと整合性をとるために、宇宙多相を使用します。これにより、
Functor
とApplicative
、Monad
の各クラスは実際の定義と完全に一致するようになります。完全な定義
ようやく関連する言語機能をすべて紹介したので、本節ではLeanの標準ライブラリにある
Functor
とApplicative
、Monad
の正真正銘完全な定義について説明します。理解促進のために細部に至るまで省略しません。関手
Functor
クラスの完全な定義では、宇宙多相とデフォルトメソッドの実装が用いられています:class Functor (f : Type u → Type v) : Type (max (u+1) v) where map : {α β : Type u} → (α → β) → f α → f β mapConst : {α β : Type u} → α → f β → f α := Function.comp map (Function.const _)
この定義において、
Function.comp
は関数合成のことで、よく∘
という演算子として記述されます。Function.const
は 定数関数 (constant function)で、引数を2つ取り、2つ目を無視する関数です。この関数に1つだけ引数を適用することで常に同じ値を返す関数が出来上がります。これは関数を要求するAPIに対して、プログラムとしては引数ごとに異なる計算結果を返す必要がない場合に有用です。Function.const
の定義を簡潔にすると以下のようになります:def simpleConst (x : α) (_ : β) : α := x
この関数に1つ引数を適用した上で
List.map
の関数の引数として使うとその便利さがよくわかるでしょう:#eval [1, 2, 3].map (simpleConst "same")
["same", "same", "same"]
この関数は実際には以下のシグネチャを持ちます:
Function.const.{u, v} {α : Sort u} (β : Sort v) (a : α) (a✝ : β) : α
ここでは型引数
β
は明示的な引数であるため、Functor.mapConst
のデフォルトの定義ではこの引数に_
を与えることでプログラムの型チェックが通るようなFunction.const
に渡される一意な型を見つけるようにLeanに指示します。(Function.comp map (Function.const _) : α → f β → f α)
はfun (x : α) (y : f β) => map (fun _ => x) y
と等価です。Functor
クラスはu+1
とv
のうち大きい方の宇宙に存在します。ここで、u
はf
の引数として受け入れられる宇宙レベルで、v
はf
が返す宇宙です。Functor
型クラスを実装する構造体が何故u
よりも大きな宇宙に存在しなければならないかということを説明するために、まず簡略化したクラス定義から始めます:class Functor (f : Type u → Type v) : Type (max (u+1) v) where map : {α β : Type u} → (α → β) → f α → f β
この型クラスの構造体型は以下の帰納型と等価です:
inductive Functor (f : Type u → Type v) : Type (max (u+1) v) where | mk : ({α β : Type u} → (α → β) → f α → f β) → Functor f
Functor.mk
の引数として渡されるmap
メソッドの実装にはType u
上の2つの型を引数に取る関数を含みます。これは関数自体の型がType (u+1)
上にあることを意味するため、Functor
も少なくともu+1
のレベルでなければなりません。同様に、関数の他の引数はf
を適用して作られた型を持つことから、Functor
も少なくともv
のレベルでなければなりません。本節のすべての型クラスはこの性質を共有しています。アプリカティブ関手
Applicative
型クラスは実際にはいくつかの小さなクラスから構成されており、それぞれに関連するメソッドが含まれています。最初はPure
とSeq
で、それぞれpure
とseq
を含んでいます:class Pure (f : Type u → Type v) : Type (max (u+1) v) where pure {α : Type u} : α → f α class Seq (f : Type u → Type v) : Type (max (u+1) v) where seq : {α β : Type u} → f (α → β) → (Unit → f α) → f β
これらに加えて、
Applicative
はSeqRight
とこれに似たSeqLeft
クラスにも依存しています:class SeqRight (f : Type u → Type v) : Type (max (u+1) v) where seqRight : {α β : Type u} → f α → (Unit → f β) → f β class SeqLeft (f : Type u → Type v) : Type (max (u+1) v) where seqLeft : {α β : Type u} → f α → (Unit → f β) → f α
オルタナティブとバリデーションについての節 で紹介した
seqRight
関数は作用の観点から理解するのが最も簡単です。E1 *> E2
は脱糖するとSeqRight.seqRight E1 (fun () => E2)
となります。これは最初にE1
、次にE2
を実行し、E2
の結果のみを返却するものとして理解できます。E1
から発生する作用によってE2
が実行されなかったり、複数回実行されることがあり得ます。実際、f
がMonad
インスタンスを持つ場合、E1 *> E2
はdo let _ ← E1; E2
と等価ですが、seqRight
はValidate
のようなモナドではない型でも使用できます。これのいとこである
seqLeft
は一番左の式の値を返すという点を除けば非常に似ています。E1 <* E2
はSeqLeft.seqLeft E1 (fun () => E2)
に脱糖されます。SeqLeft.seqLeft
はf α → (Unit → f β) → f α
型を持ち、これがf α
を返すことを除けばseqRight
とそっくりです。E1 <* E2
は最初にE1
を、次にE2
を実行し、最初のE1
の結果を返すプログラムだと理解できます。もしf
がMonad
インスタンスを持つ場合、E1 <* E2
はdo let x ← E1; _ ← E2; pure x
と等価です。一般的に、seqLeft
はバリデーションやパーサのような処理にて、値そのものを変化させずに値に対する追加の条件を指定するのに便利です。Applicative
の定義はFunctor
に加えてこれらすべてのクラスを継承しています:class Applicative (f : Type u → Type v) extends Functor f, Pure f, Seq f, SeqLeft f, SeqRight f where map := fun x y => Seq.seq (pure x) fun _ => y seqLeft := fun a b => Seq.seq (Functor.map (Function.const _) a) b seqRight := fun a b => Seq.seq (Functor.map (Function.const _ id) a) b
Applicative
の完全な定義にはpure
とseq
の定義だけが必要です。というのもFunctor
・SeqLeft
・SeqRight
のすべてのメソッドにデフォルトの定義があるからです。Functor
のmapConst
メソッドにはFunctor.map
によるデフォルトの実装があります。これらのデフォルト実装をオーバーライドして良いのは、挙動が同じなのにより効率的な新しい関数がある場合のみとすべきです。デフォルト実装はその実装について、自動生成コードと同じ正しさを持つことへの仕様とみなすべきでしょう。seqLeft
のデフォルト実装はとてもコンパクトです。いくつかの名前を糖衣構文や定義に置き換えると、別の視点が見えてきます。以下のデフォルト実装:fun a b => Seq.seq (Functor.map (Function.const _) a) b
は以下になります:
fun a b => Seq.seq ((fun x _ => x) <$> a) b
(fun x _ => x) <$> a
はどう解釈したらよいでしょうか?ここで、a
はf α
型で、f
は関手です。もしf
がList
の場合、(fun x _ => x) <$> [1, 2, 3]
は[fun _ => 1, fun _ => 2, fun _ => 3]
に評価されます。もしf
がOption
の場合、(fun x _ => x) <$> some "hello"
はsome (fun _ => "hello")
に評価されます。どちらの場合も、関手の中の値は引数を無視してもとの値を返す関数に置き換えられます。これをseq
と組み合わせると、この関数はseq
の第2引数を破棄します。seqRight
のデフォルト実装は、const
に追加の引数id
があることを除けばseqLeft
に非常によく似ています。この定義も、最初に標準的な糖衣構文を導入し、次にいくつかの名前をその定義に置き換えることで同じように理解できます:fun a b => Seq.seq (Functor.map (Function.const _ id) a) b ===> fun a b => Seq.seq ((fun _ => id) <$> a) b ===> fun a b => Seq.seq ((fun _ => fun x => x) <$> a) b ===> fun a b => Seq.seq ((fun _ x => x) <$> a) b
(fun _ x => x) <$> a
はどう解釈したらよいでしょうか?ここでも例が役に立ちます。(fun _ x => x) <$> [1, 2, 3]
は[fun x => x, fun x => x, fun x => x]
に評価され、(fun _ x => x) <$> some "hello"
はsome (fun x => x)
に評価されます。言い換えると、(fun _ x => x) <$> a
はa
の全体的な形を保ちつつ、しかし各値を恒等関数に置き換えます。作用の観点からは、a
の副作用は発生しますが、seq
と一緒に使われることで、値が捨てられます。モナド
Applicative
を構成する操作がそれぞれの型クラスに分かれているように、Bind
も独自のクラスを持ちます:class Bind (m : Type u → Type v) where bind : {α β : Type u} → m α → (α → m β) → m β
Monad
はBind
と共にApplicative
を継承しています:class Monad (m : Type u → Type v) extends Applicative m, Bind m : Type (max (u+1) v) where map f x := bind x (Function.comp pure f) seq f x := bind f fun y => Functor.map y (x ()) seqLeft x y := bind x fun a => bind (y ()) (fun _ => pure a) seqRight x y := bind x fun _ => y ()
クラスの階層全体から継承したメソッドとデフォルトのメソッドを走査すると、
Monad
インスタンスに必要な実装はbind
とpure
だけであることがわかります。つまり、Monad
インスタンスは自動的にseq
・seqLeft
・seqRight
・map
・mapConst
の実装を生成します。APIの教会から見ると、Monad
インスタンスを持つ型はBind
・Pure
・Seq
・Functor
・SeqLeft
・SeqRight
のインスタンスを持つことになります。演習問題
Option
やExcept
などを例にして、Monad
のmap
・seq
・seqLeft
・seqRight
のデフォルト実装を解釈してください。つまり、bind
とpure
の定義をデフォルトの定義に置き換えて、map
・seq
・seqLeft
・seqRight
を手で書けるように単純化してください。map
とseq
のデフォルト実装がFunctor
とApplicative
の約定を満たすことを手書き、もしくはテキストファイルで証明してください。この際には、通常の式の評価と同様にMonad
の約定のルールを使用しても構いません。
まとめ
型クラスと構造体
型クラスは内部的には構造体として表現されます。クラスを定義することで対応する構造体が定義され、さらにインスタンスデータベースに空のテーブルが作成されます。インスタンスの定義によって構造体を型として持つか、その構造体を返す関数のどちらかの値が作成され、さらにテーブルにレコードが追加されます。インスタンス検索はインスタンステーブルを参照してインスタンスを構築します。構造体のクラスどちらでもフィールドのデフォルト値(メソッドの場合はデフォルト実装)を提供することができます。
構造体と継承
構造体はほかの構造体を継承することができます。その裏側では、他の構造体を継承した構造体は、元の構造体のインスタンスをフィールドとして含んでいます。言い換えると、継承は合成で実装されています。多重継承の場合、菱形継承問題を回避するために、親構造体に対して継承時に追加されたユニークなフィールドのみがその構造体のフィールドとして使用され、通常であれば親構造体の値を抽出する関数が、代わりに親の値を構築するために組み込まれます。レコードのドット記法は構造体の継承にも対応しています。
型クラスは単に構造体にいくつかの自動化が施されたものであるため、これらのすべての機能は型クラスで利用できます。デフォルトメソッドと組み合わせることで、きめ細かいインタフェースの階層を作ることができます。これはクライアントに実装にあたっての負荷を軽減します。というのも、大きなクラスが継承するデフォルトメソッドを含むような小さなクラスは自動的に実装されるからです。
アプリカティブ関手
アプリカティブ関手は関手に以下の2つの演算子を追加したものです:
pure
、これはMonad
の演算子と同じものですseq
は関手のコンテキストのもとで関数をできるようにします
モナドが制御フローを持つ任意のプログラムを表現できるのに対し、アプリカティブ関手は関数の引数を左から右にしか実行できません。アプリカティブ関手はモナドほど強力ではないためインタフェースに反して書かれたプログラムをそこまで制御できませんが、その代わりにメソッドの実装の自由度が増します。いくつかの便利な型では
Applicative
を実装できる一方でMonad
を実装することができません。実は、型クラス
Functor
・Applicative
・Monad
は階層を構成しています。Functor
からMonad
へと階層が上がるにつれてより強力なプログラムを書けるようになりますが、より強力なクラスを実装できる型は限られてきます。多相なプログラムはできるだけ弱い抽象を使うように書くべきであり、データ型はできるだけ強力なインスタンスを与えるべきです。これはコードの再利用を最大化します。より強力な空クラスはより強力でない型を継承します。つまり、Monad
の実装はFunctor
とApplicative
の実装をタダで提供します。各クラスには実装すべきメソッドの集合と、それに対応するメソッドに関する追加のルールを規定する約定があります。これらのインタフェースに対して書かれたプログラムは追加のルールが満たされていることを期待し、さもなければバグが発生する可能性があります。
Functor
のメソッドをApplicative
のメソッドとして実装する場合、またはApplicative
のメソッドをMonad
のメソッドとして実装する場合、デフォルトではこれらのルールに従います。宇宙
Leanをプログラミング言語と定理証明器のどちらとしても使えるようにするためには、言語に対していくつかの制限が必要になります。これにはすべての再帰関数がちゃんと終了するか、
partial
とマークされ、空ではない型を返すことを保証するかのどちらかでなければならないという制限が含まれます。さらに、ある種の論理的なパラドックスを型として表現することは不可能でなければなりません。このようなパラドックスを排除するための制約の1つに、すべての型が 宇宙 に割り当てられているというものがあります。宇宙とは
Prop
・Type
・Type 1
・Type 2
などの型のことです。これらの型はほかの型を記述します。ちょうど0
と17
がNat
によって記述されるように、Nat
自身がType
に、Type
がType 1
によって記述されます。型を引数に取る関数の型は引数の型よりも大きな宇宙でなければなりません。宣言されたデータ型はそれぞれ宇宙を持つため、データ型をデータのように使うコードを書くとそれぞれの多相型を
Type 1
から引数を取るようにコピペする必要があり、すぐに面倒なことになります。宇宙多相 と呼ばれる機能により、通常の多相でプログラムが型を引数としてとることができるように、Leanのプログラムとデータ型が宇宙レベルを引数として取ることができるようになります。一般的に、Leanにて多相的な操作のライブラリを実装する場合は宇宙多相を使うべきです。モナド変換子
モナドは純粋言語においてなんらかの副作用を実装するための手段です。状態やエラー処理など、異なるモナドは異なる作用を提供します。非決定論的検索やリーダ、さらには継続など、ほとんどの言語では利用できない便利な作用を提供するモナドも多く存在します。
典型的なアプリケーションはモナドを使わないテストの容易な関数を中心とし、それをモナドで必要なアプリケーションロジックを実装したラッパーで覆うことで成り立っています。これらのモナドは良く知られたコンポーネントから構成されます。例えば:
- 可変状態はパラメータと戻り値の型が同じ関数としてエンコードされます
- エラー処理は
Except
に似た成功と失敗から構成される型を戻り値に持つものとしてエンコードされます - ロギングは戻り値とログのペアとしてエンコードされます
しかし、それぞれのモナドを手作業で書くのはさまざまな型クラスの定型的な定義が必要になるため面倒です。これらの各コンポーネントは、ほかのモナドを修正して追加作用を加える定義に抽出することもできます。このような定義は モナド変換子 (monad transformer)と呼ばれます。具体的なモナドはモナド変換子を組み合わせることで構築することができ。コードの再利用がより可能になります。
IOとReaderを組み合わせる
リーダモナドが有用なケースの1つに、深い再帰呼び出しを通して渡されるアプリケーションの「現在の設定値」の概念があります。このようなプログラムの例は
tree
で、これはカレントディレクトリとサブディレクトリにあるファイルを再帰的に表示し、その木構造を文字で示します。この章で登場するtree
は、北米の西海岸を彩る強大な米松(Douglas Fir)にちなんでdoug
と呼ぶことにし、ディレクトリ構造を示すときにUnicodeの罫線素片、もしくはそれに相当するASCII文字のどちらかを選べるオプションを提供します。例として、以下のコマンドは
doug-demo
というディレクトリにディレクトリ構造といくつかの空のファイルを作成しています:$ cd doug-demo $ mkdir -p a/b/c $ mkdir -p a/d $ mkdir -p a/e/f $ touch a/b/hello $ touch a/d/another-file $ touch a/e/still-another-file-again
ここで
doug
を実行すると以下の結果となります:$ doug ├── doug-demo/ │ ├── a/ │ │ ├── b/ │ │ │ ├── c/ │ │ │ ├── hello │ │ ├── e/ │ │ │ ├── still-another-file-again │ │ │ ├── f/ │ │ ├── d/ │ │ │ ├── another-file
実装
内部的には、
doug
はディレクトリ構造を再帰的に走査しながら設定値を下方に渡しています。この設定は2つのフィールドを含みます:useASCII
はUnicodeの罫線素片とASCIIの縦棒とダッシュ文字のどちらを構造の表示に用いるかを決定し、currentPrefix
は出力の各行の先頭につける文字列を保持します。カレントディレクトリが深くなるにつれて、そのディレクトリを接頭辞文字列に蓄積していきます。この設定値は以下の構造体です:structure Config where useASCII : Bool := false currentPrefix : String := ""
この構造体は両方のフィールドにデフォルト定義を持ちます。デフォルトの
Config
はUnicodeによる表示を行い、接頭辞を付けません。doug
の利用者はコマンドライン引数を与えられる必要があるでしょう。この使い方は以下の通りです:def usage : String := "Usage: doug [--ascii] Options: \t--ascii\tUse ASCII characters to display the directory structure"
したがって、設定値はコマンドライン引数のリストを調べることで構築できます:
def configFromArgs : List String → Option Config | [] => some {} -- both fields default | ["--ascii"] => some {useASCII := true} | _ => none
main
関数はdirTree
と呼ばれる内部ワーカーのラッパーです。この関数は設定値をもとにディレクトリの内容を表示します。dirTree
を呼び出す前に、main
はコマンドライン引数を処理しなければなりません。またこの関数はOSに適切な終了コードを返さなければなりません:def main (args : List String) : IO UInt32 := do match configFromArgs args with | some config => dirTree config (← IO.currentDir) pure 0 | none => IO.eprintln s!"Didn't understand argument(s) {" ".separate args}\n" IO.eprintln usage pure 1
すべてのパスがディレクトリツリーに表示されるべきではありません。特に、
.
や..
という名前のファイルはスキップされるべきです。これらはファイル そのもの というより実際にはナビゲーションのための機能であるからです。表示すべきファイルは2種類です:通常のファイルとディレクトリです:
inductive Entry where | file : String → Entry | dir : String → Entry
あるファイルを表示すべきかどうかとそのファイルの種類を決定するために、
doug
はtoEntry
を使用します:def toEntry (path : System.FilePath) : IO (Option Entry) := do match path.components.getLast? with | none => pure (some (.dir "")) | some "." | some ".." => pure none | some name => pure (some (if (← path.isDir) then .dir name else .file name))
System.FilePath.components
はパスを、パスの区切り文字で分割した要素のリストに変換します。リストに最後の要素が無い(訳注:リストが空である)場合は、そのパスはルートディレクトリとなります。リストの最後の要素が特別なナビゲーションファイル(.
や..
)である場合、このファイルは除外するべきです。それ以外の場合、ディレクトリとファイルは対応するコンストラクタでラップされます。Leanのロジックにはディレクトリツリーが有限であることを知るすべはありません。実際に、システムによっては循環的なディレクトリ構造を構築することができます。したがって、
dirTree
はpartial
として宣言されています:partial def dirTree (cfg : Config) (path : System.FilePath) : IO Unit := do match ← toEntry path with | none => pure () | some (.file name) => showFileName cfg name | some (.dir name) => showDirName cfg name let contents ← path.readDir let newConfig := cfg.inDirectory doList contents.toList fun d => dirTree newConfig d.path
toEntry
の呼び出しは 入れ子になったアクション です。match
のように矢印が他の意味を持ちえない位置では括弧を省略することができます。ファイル名がツリーの要素に対応していない場合(例えば..
の場合)、dirTree
は何もしません。ファイル名が通常のファイルを指している場合、dirTree
は補助関数を呼び出して現在の設定値をもとにファイルを表示します。ファイル名がディレクトリを指している場合、補助関数を呼び出してディレクトリ名を表示し、その中身についてはディレクトリに入ったことを考慮して接頭辞を拡張した新しい設定のもとで再帰的に表示を行います。ファイルとディレクトリの名前を表示するには、
showFileName
とshowDirName
を使います:def showFileName (cfg : Config) (file : String) : IO Unit := do IO.println (cfg.fileName file) def showDirName (cfg : Config) (dir : String) : IO Unit := do IO.println (cfg.dirName dir)
これらの補助関数はどちらもASCIIかUnicodeの設定を考慮する
Config
上の関数に委譲しています:def Config.preFile (cfg : Config) := if cfg.useASCII then "|--" else "├──" def Config.preDir (cfg : Config) := if cfg.useASCII then "| " else "│ " def Config.fileName (cfg : Config) (file : String) : String := s!"{cfg.currentPrefix}{cfg.preFile} {file}" def Config.dirName (cfg : Config) (dir : String) : String := s!"{cfg.currentPrefix}{cfg.preFile} {dir}/"
同じように、
Config.inDirectory
は接頭辞をディレクトリを示す印で拡張します:def Config.inDirectory (cfg : Config) : Config := {cfg with currentPrefix := cfg.preDir ++ " " ++ cfg.currentPrefix}
IOアクションをディレクトリの内容のリストに対して繰り返し実行するには、
doList
を使用します。doList
はリスト内のすべてのアクションを実行しますが、アクションが返す値に基づいて制御の流れを決定するわけではないのでMonad
のフルパワーは必要なく、任意のApplicative
で動作します:def doList [Applicative f] : List α → (α → f Unit) → f Unit | [], _ => pure () | x :: xs, action => action x *> doList xs action
カスタムのモナドを使う
この実装で
doug
は動作してくれますが、手動で設定値を渡すのは冗長でエラーになりやすいです。例えば、間違った設定値がdoug
に渡され、ディレクトリを掘りながら設定値が伝播してしまうことを型システムは捕捉してくれません。リーダモナドの作用によって、手動で上書きしない限り同じ設定値がすべての再帰呼び出しに渡されることが保証されます。これによってコードの冗長性が緩和されます。Config
についてのリーダでもあるIO
モナドを作成するには、まず 評価器の例 のレシピに従って型とそのMonad
インスタンスを定義します:def ConfigIO (α : Type) : Type := Config → IO α instance : Monad ConfigIO where pure x := fun _ => pure x bind result next := fun cfg => do let v ← result cfg next v cfg
この
Monad
インスタンスとReader
のインスタンスの違いは、上記のインスタンスではresult
から返された値に直接next
を適用するのではなく、bind
が返す関数のボディとしてIO
モナドのdo
記法を使用する点です。results
によって発生する任意のIO
の作用はnext
が呼び出される前に発生しなければなりませんが、これはIO
モナドのbind
演算子によって保証されています。Config IO
は宇宙多相ではありませんが、これはベースにしたIO
型も宇宙多相ではないからです。ConfigIO
アクションを実行するには設定値を与えてこのアクションをIO
アクションに変換します:def ConfigIO.run (action : ConfigIO α) (cfg : Config) : IO α := action cfg
呼び出し元が直接設定値を提供すればよいため、実際にはこの関数は必要ありません。しかし、操作に名前をつけることでコードのどの部分がどのモナドで実行しようとしているかがわかりやすくなります。
次のステップは
ConfigIO
に含まれている現在の設定値にアクセスする手段を定義することです:def currentConfig : ConfigIO Config := fun cfg => pure cfg
これは 評価器の例 での
read
とよく似ていますが、ここでは値を返すにあたって直接ではなくIO
のpure
を使用しています。ディレクトリに入ると再帰呼び出しのスコープで現在の設定が変更されるため、設定を上書きする方法が必要になります:def locally (change : Config → Config) (action : ConfigIO α) : ConfigIO α := fun cfg => action (change cfg)
doug
で使用されるコードではほとんど設定値を必要としません。事実、doug
はConfig
を必要としない標準ライブラリから普通のIO
アクションを呼び出します。通常のIO
アクションはrunIO
を使用して実行することができ、このアクションは設定値の引数を無視します:def runIO (action : IO α) : ConfigIO α := fun _ => action
これらのコンポーネントを使うことで、
showFileName
とshowDirName
はConfigIO
モナドを通じて暗黙の設定引数を受け取るように更新することができます。設定を取得するには ネストされたアクション を使用し、IO.println
の呼び出しを実際に実行するにはrunIO
を使用します。def showFileName (file : String) : ConfigIO Unit := do runIO (IO.println ((← currentConfig).fileName file)) def showDirName (dir : String) : ConfigIO Unit := do runIO (IO.println ((← currentConfig).dirName dir))
新しい
dirTree
ではtoEntry
とSystem.FilePath.readDir
の呼び出しがrunIO
でラップされています。さらに、新しい設定を構成してプログラマが再帰呼び出しに渡す設定を追跡する代わりに、locally
を使用して変更された設定をそれが有効な設定 でしかない ようにプログラムの小さな領域のみに自然に限定します:partial def dirTree (path : System.FilePath) : ConfigIO Unit := do match ← runIO (toEntry path) with | none => pure () | some (.file name) => showFileName name | some (.dir name) => showDirName name let contents ← runIO path.readDir locally (·.inDirectory) (doList contents.toList fun d => dirTree d.path)
新しい
main
ではConfigIO.run
を使い、初期設定値をもとにdirTree
を実行します:def main (args : List String) : IO UInt32 := do match configFromArgs args with | some config => (dirTree (← IO.currentDir)).run config pure 0 | none => IO.eprintln s!"Didn't understand argument(s) {" ".separate args}\n" IO.eprintln usage pure 1
このカスタムのモナドは設定値を手動で渡す場合に比べて多くの利点があります:
- 変更が必要な場合を除き、設定が変更されずに受け渡されることを保証するのが容易
- 設定値を引き継ぐことと、ディレクトリの内容を表示することの関心事がより明確に分離される
- プログラムが巨大化するにつれて、設定値を伝播する以外は何もしない中間層がどんどん増えていくが、設定値のロジックの変更があってもこれらのレイヤに対して修正を行う必要がない
しかし、一方で明確なマイナス面もあります:
- プログラムを改良し、モナドがより多くの機能を必要とするようになると、
locally
やcurrentConfig
などの基本的な演算子それぞれを更新する必要がある - 通常の
IO
アクションをrunIO
でラップするのは視認性が悪く、プログラムの流れを乱してしまう - 手動でモナドのインスタンスを書くのは同じことの繰り返しであり、リーダの作用を別のモナドに追加するテクニックは余分な文書化とコミュニケーションを要するデザインパターンである
モナド変換子 (monad transformer)と呼ばれるテクニックを使えば、これらすべての欠点を解決することができます。モナド変換子はモナドを引数に取り、新しいモナドを返します。モナド変換子の構成は以下の通りです:
- このモナド変換子自体の定義、これは通常型から型への関数
- 内部の型がすでにモナドであると仮定した
Monad
インスタンス - 内側のモナドから変換後のモナドにアクションを「持ち上げる」演算子、これは
runIO
に似ている
リーダを任意のモナドに付与する
ConfigIO
にてリーダの作用をIO
に追加するのはIO α
を関数型で包むことで実現しました。Leanの標準ライブラリにはこれを どんな 多相型に対しても行うことのできる関数を備えており、ReaderT
と呼ばれています:def ReaderT (ρ : Type u) (m : Type u → Type v) (α : Type u) : Type (max u v) := ρ → m α
これの引数は以下です:
ρ
はリーダから参照される環境m
は変換対象のモナドで、例えばIO
などが入るα
はモナドの計算で返される値の型
α
とρ
のどちらも同じ宇宙に属します。というのも、このモナドで環境を引っ張ってくる演算子はm ρ
型を持つからです。ReaderT
を使うことで、ConfigIO
は以下のようになります:abbrev ConfigIO (α : Type) : Type := ReaderT Config IO α
ここでは
abbrev
を使います。なぜなら、ReaderT
には簡約できない定義では隠されてしまうような多くの便利な機能が標準ライブラリで定義されているからです。これらをConfigIO
のために直接動作させる責任を負うよりも、ConfigIO
にReaderT Config IO
と同じ動作をさせる方が簡単です。手動で書いた
currentConfig
はリーダから環境を取得していました。この作用はReaderT
のあらゆる用途に対してread
という名前で汎用的に定義することができます:def read [Monad m] : ReaderT ρ m ρ := fun env => pure env
しかし、リーダの作用を提供するすべてのモナドが
ReaderT
で構築されているわけではありません。型クラスMonadReader
を使えばどのモナドでもread
演算子を使えるようになります:class MonadReader (ρ : outParam (Type u)) (m : Type u → Type v) : Type (max (u + 1) v) where read : m ρ instance [Monad m] : MonadReader ρ (ReaderT ρ m) where read := fun env => pure env export MonadReader (read)
型
ρ
は出力パラメータです。なぜなら、与えられた任意のモナドは通常リーダを通して単一の型の環境しか提供しないため、モナドがわかっている際に自動的に型ρ
を選択することでプログラムをより書きやすくなるからです。ReaderT
のMonad
インスタンスはConfigIO
のMonad
インスタンスと本質的に同じではありますが、IO
が任意のモナドm
に置き換えられています:instance [Monad m] : Monad (ReaderT ρ m) where pure x := fun _ => pure x bind result next := fun env => do let v ← result env next v env
次のステップは
runIO
の使用を無くすことです。Leanはモナドの型の不一致に遭遇すると、対象のモナドに対してMonadLift
と呼ばれる型クラスを自動的に使用して期待されるモナドに変換しようとします。このプロセスは型強制と似ています。MonadLift
は以下のように定義されています:class MonadLift (m : Type u → Type v) (n : Type u → Type w) where monadLift : {α : Type u} → m α → n α
monadLift
メソッドはモナドm
をモナドn
へ変換します。このプロセスは埋め込まれたモナドのアクションをその外側のモナドのアクションに変換することから「持ち上げ(lifting)」と呼ばれます。今回の場合、IO
からReaderT Config IO
への「持ち上げ」に用いられますが、このインスタンスは どんな 内部のモナドm
に対して機能します:instance : MonadLift m (ReaderT ρ m) where monadLift action := fun _ => action
monadLift
の実装はrunIO
のそれと非常に似ています。実は、runIO
を使わなくてもshowFileName
とshowDirName
を定義することができます:def showFileName (file : String) : ConfigIO Unit := do IO.println s!"{(← read).currentPrefix} {file}" def showDirName (dir : String) : ConfigIO Unit := do IO.println s!"{(← read).currentPrefix} {dir}/"
もとの
ConfigIO
からReaderT
を使用したバージョンに変換するにあたってまだもう一つ操作が残っています:locally
です。この定義はReaderT
に直接翻訳することもできますが、Leanの標準ライブラリではより一般的なものを提供しています。標準のものはwithReader
と呼ばれ、MonadWithReader
という型クラスの一部になっています:class MonadWithReader (ρ : outParam (Type u)) (m : Type u → Type v) where withReader {α : Type u} : (ρ → ρ) → m α → m α
MonadReader
の同様に、環境ρ
はoutParam
です。withReader
操作はエクスポートされているため、前に型クラス名を書く必要はありません:export MonadWithReader (withReader)
ReaderT
のインスタンスは本質的にはlocally
の定義と同じです:instance : MonadWithReader ρ (ReaderT ρ m) where withReader change action := fun cfg => action (change cfg)
以上の定義から、新しいバージョンの
dirTree
を書くことができます:partial def dirTree (path : System.FilePath) : ConfigIO Unit := do match ← toEntry path with | none => pure () | some (.file name) => showFileName name | some (.dir name) => showDirName name let contents ← path.readDir withReader (·.inDirectory) (doList contents.toList fun d => dirTree d.path)
locally
をwithReader
に置き換えた以外は、以前のものと同じです。カスタムの
ConfigIO
型をReaderT
型に置き換えても、この節のコード量はそこまで大幅には減りませんでした。しかし、標準ライブラリのコンポーネントを使用してコードを書き直すことには長期的な利点があります。まずReaderT
について知っている読者はConfigIO
のMonad
インスタンスをモナドそのものの意味から逆算してじっくり時間をかけて理解する必要がありません。そのためConfigIO
への理解が容易になります。次に、モナドにさらなる作用(各ディレクトリ内のファイルをカウントして最後にカウントを表示する状態の作用など)を追加する場合、ライブラリで提供されているモナド変換子とMonadLift
インスタンスがうまく連携して動くため、コードの変更量がはるかに少なくなります。最後に、標準ライブラリにある型クラスたちを使うことで、モナド変換子の適用順などの細かいことを気にすることなく、様々なモナドで動作するように多相なコードを書くことができます。一部の関数がどのモナドでも動作するように、関数がある種の状態や例外などの任意のモナドに対して、それらの具体的なモナドが状態や例外を提供する 方法 を具体的に記述することなく動作することができます。演習問題
ドットのファイルの表示の制御
ファイル名がドット文字(
'.'
)で始まるファイルは通常、ソース管理のメタデータや設定ファイルなど、通常は隠すべきファイルを表します。doug
にドットで始まるファイル名を表示または非表示にするオプションを追加してください。このオプションは-a
コマンドラインオプションで制御します。開始するディレクトリを引数に
doug
を修正してコマンドライン引数に追加で開始ディレクトリを受け取るようにしてください。モナド組み立てキット
ReaderT
だけが便利なモナド変換子ではありません。本節では、さらに多くの変換子について説明します。各モナド変換子は以下のように構成されています:- モナドを引数に取る定義もしくはデータ型
T
。これは(Type u → Type v) → Type u → Type v
のような型を持ちますが、モナドの前に別で引数を受け取ることもできます。 Monad m
のインスタンスに依存したT m
のMonad
インスタンス。これにより変換されたモナドをモナドとして使うことができます。- 任意のモナド
m
に対してm α
型のアクションをT m α
型のアクションに変換するMonadLift
インスタンス。これにより、変換後のモナドでベースとなったモナドのアクションを使用できるようになります。
さらに、ベースとなった
Monad
インスタンスがモナドの約定に従うのであれば、変換子のMonad
インスタンスはMonad
の約定に従わなければなりません。加えて、monadLift (pure x)
は変換後のモナドではpure x
と等価になるべき、またmonadLift
はmonadLift (x >>= f)
がmonadLift x >>= fun y => monadLift (f y)
と同じになるようにbind
に対して分配されるべきです。多くのモナド変換子はさらに
MonadReader
というスタイルでモナドで利用可能な実際の作用を記述する型クラスを定義しています。これによりコードが柔軟さを増します:インタフェースにのみ依存するプログラムを書くことができるようになり、ベースのモナドに対して特定の変換子によって実装されることが要求されません。型クラスはプログラムが要求を表現するための方法であり、モナド変換子はこれらの要求を満たすための便利なツールです。OptionT
による失敗Option
モナドで表現される失敗とExcept
モナドで表現される例外はどちらもそれぞれ対応する変換子があります。Option
の場合、別のモナドに失敗を追加するには、そのモナドが通常なら含んでいるα
型ではなくOption α
型を保持するようにすることで可能です。例えば、IO (Option α)
はいつもα
型を返すとは限らないようなIO
アクションを表します。これよりモナド変換子OptionT
の定義が導かれます:def OptionT (m : Type u → Type v) (α : Type u) : Type v := m (Option α)
実際の
OptionT
の例として、ユーザに質問するプログラムを考えてみましょう。関数getSomeInput
は一行の入力を受け取り、その両端から空白を取り除きます。切り取られた入力が空でなければそれを返しますが、空白以外の文字がなければ関数は失敗します:def getSomeInput : OptionT IO String := do let input ← (← IO.getStdin).getLine let trimmed := input.trim if trimmed == "" then failure else pure trimmed
この関数を用いたアプリケーションとして、ユーザの名前と好きなカブトムシの種類を確認するものを作れます:
structure UserInfo where name : String favoriteBeetle : String
ユーザに入力を求めても、
IO
だけを使う関数のような冗長さはありません:def getUserInfo : OptionT IO UserInfo := do IO.println "What is your name?" let name ← getSomeInput IO.println "What is your favorite species of beetle?" let beetle ← getSomeInput pure ⟨name, beetle⟩
しかし、この関数は単なる
IO
ではなくOptionT IO
コンテキストで実行されるため、最初のgetSomeInput
の呼び出しに失敗すると、制御がカブトムシに関する質問に到達せずにgetUserInfo
全体も失敗します。メインの関数であるinteract
は純粋なIO
のコンテキストでgetUserInfo
を呼び出し、これによってその中のOption
をパターンマッチすることで呼び出しが成功したか失敗したかチェックすることができます。def interact : IO Unit := do match ← getUserInfo with | none => IO.eprintln "Missing info" | some ⟨name, beetle⟩ => IO.println s!"Hello {name}, whose favorite beetle is {beetle}."
OptionT
のモナドインスタンスモナドインスタンスを書いてみると難点が明らかになります。型に基づくと、
pure
はベースとなるモナドm
のpure
をsome
と共に使用する必要があります。ちょうどOption
のbind
が最初の引数で分岐し、none
を伝播するように、OptionT
のbind
は最初の引数を構成するモナドアクションを実行し、その結果で分岐してnone
を伝播しなければなりません。この構想に従うと以下の定義が得られますが、Leanはこれを受け入れません:instance [Monad m] : Monad (OptionT m) where pure x := pure (some x) bind action next := do match (← action) with | none => pure none | some v => next v
エラーメッセージには不可解な型の不一致が示されます:
application type mismatch pure (some x) argument some x has type Option α✝ : Type ?u.25 but is expected to have type α✝ : Type ?u.25
問題はLeanが
pure
を使用するにあたって間違ったMonad
インスタンスを選択していることです。同様のエラーはbind
の定義でも発生します。これに対する1つの解決策は、型注釈を使ってLeanに正しいMonad
インスタンスを教えてあげることです:instance [Monad m] : Monad (OptionT m) where pure x := (pure (some x) : m (Option _)) bind action next := (do match (← action) with | none => pure none | some v => next v : m (Option _))
この解決策は機能しますが、エレガントではないですし、コードも少々煩雑になります。
別の解決策は、型注釈がLeanを正しいインスタンスに導く関数を定義することです。実は
OptionT
は構造体として定義することもできます:structure OptionT (m : Type u → Type v) (α : Type u) : Type v where run : m (Option α)
これにより、コンストラクタ
OptionT.mk
とフィールドアクセサOptionT.run
が型クラス推論を正しいインスタンスに導くため問題は解決します。この解決策の欠点は、直接の定義はコンパイル時のみの特徴である一方で、構造体の値を使用するコードでは実行時に構造体の値のメモリ確保と解放を繰り返し行う必要があることです。両方の長所を生かすには、OptionT.mk
とOptionT.run
と同じ役割を果たしつつ、しかし直接の定義で動くような関数を定義することです:def OptionT.mk (x : m (Option α)) : OptionT m α := x def OptionT.run (x : OptionT m α) : m (Option α) := x
どちらの関数も入力を変更せずに返しますが、
OptionT
のインタフェースを提示するつもりのコードと、ベースのモナドm
のインタフェースを提示するつもりのコードとの境界をはっきりさせます。これらの補助関数を使うことで、Monad
インスタンスはより読みやすくなります:instance [Monad m] : Monad (OptionT m) where pure x := OptionT.mk (pure (some x)) bind action next := OptionT.mk do match ← action with | none => pure none | some v => next v
ここで、
OptionT.mk
を使うことによって、その引数がm
のインタフェースを使用するコードと見做されるべきことが示され、これによってLeanは正しいMonad
インスタンスを選択することができます。モナドインスタンスを定義した後、モナドの約定が満たされていることをチェックするのは良い考えです。最初のステップは
bind (pure v) f
がf v
と同じであることを示すことです。以下がその手順です:bind (pure v) f
={ Unfolding the definitions ofbind
andpure
}=OptionT.mk do match ← pure (some v) with | none => pure none | some x => f x
={ Desugaring nested action syntax }=OptionT.mk do let y ← pure (some v) match y with | none => pure none | some x => f x
={ Desugaringdo
-notation }=OptionT.mk (pure (some v) >>= fun y => match y with | none => pure none | some x => f x)
={ Using the first monad rule form
}=OptionT.mk (match some v with | none => pure none | some x => f x)
={ Reducematch
}=OptionT.mk (f v)
={ Definition ofOptionT.mk
}=f v
2つ目のルールは
bind w pure
がw
に等しいというものです。これを示すために、bind
とpure
の定義を展開してみましょう:OptionT.mk do match ← w with | none => pure none | some v => pure (some v)
このパターンマッチでは、どちらのケースでもマッチされたパターンと同じ結果になります。つまり、これは
w >>= fun y => pure y
と等価であり、m
の2番目のモナドルールのインスタンスです。最後のルールは
bind (bind v f) g
がbind v (fun x => bind (f x) g)
と同じというものです。これもbind
とpure
の定義を展開し、ベースのモナドm
に委譲することで同じようにチェックすることができます。Alternative
インスタンスOptionT
の便利な使用例として、Alternative
型クラスを使用するというものがあります。成功した結果はpure
によって既に示されており、Alternative
のfailure
メソッドとorElse
メソッドによって複数のサブプログラムから最初に成功した結果を返すプログラムを書くことができます:instance [Monad m] : Alternative (OptionT m) where failure := OptionT.mk (pure none) orElse x y := OptionT.mk do match ← x with | some result => pure (some result) | none => y ()
持ち上げ
アクションを
m
からOptionT m
に持ち上げるには計算結果をsome
で囲むだけで良いです:instance [Monad m] : MonadLift m (OptionT m) where monadLift action := OptionT.mk do pure (some (← action))
例外
モナド変換子版の
Except
は同じくモナド変換子版のOption
にとてもよく似ています。型m α
の何かしらのモナドのアクションに型ε
の例外を追加するにはα
に例外を追加してm (Except ε α)
型を生成することでできます:def ExceptT (ε : Type u) (m : Type u → Type v) (α : Type u) : Type v := m (Except ε α)
OptionT
ではmk
とrun
関数を使って型チェッカに正しいMonad
インスタンスを導いていました。この技法はExceptT
でも有効です:def ExceptT.mk {ε α : Type u} (x : m (Except ε α)) : ExceptT ε m α := x def ExceptT.run {ε α : Type u} (x : ExceptT ε m α) : m (Except ε α) := x
ExceptT
のMonad
インスタンスもOptionT
版のインスタンスにそっくりです。唯一の違いはnone
の代わりに特定のエラー値を伝播することです:instance {ε : Type u} {m : Type u → Type v} [Monad m] : Monad (ExceptT ε m) where pure x := ExceptT.mk (pure (Except.ok x)) bind result next := ExceptT.mk do match ← result with | .error e => pure (.error e) | .ok x => next x
ExceptT.mk
とExceptT.run
の型注釈にはひっそりとですが細かい指定があります:α
とε
の宇宙レベルが明示的にアノテーションされています。もしこれらが明示的にアノテーションされていない場合、Leanはα
とε
に異なる多相宇宙変数を割り当てたより一般的な型注釈を生成します。しかし、ExceptT
の定義ではどちらもm
の引数に指定できるため、同じ宇宙であることが期待されます。これによりMonad
インスタンスにて、宇宙レベルの解決に失敗してしまう事態を引き起こす可能性があります:def ExceptT.mk (x : m (Except ε α)) : ExceptT ε m α := x instance {ε : Type u} {m : Type u → Type v} [Monad m] : Monad (ExceptT ε m) where pure x := ExceptT.mk (pure (Except.ok x)) bind result next := ExceptT.mk do match (← result) with | .error e => pure (.error e) | .ok x => next x
stuck at solving universe constraint max ?u.12144 ?u.12145 =?= u while trying to unify ExceptT ε m α✝ with (ExceptT ε m α✝) ε m α✝
この手のエラーメッセージは通常、制約不足の宇宙変数が原因です。これを診断するのは難しいですが、解析の最初の一歩としてある定義で再利用され、ほかの定義では再利用されていない宇宙変数を探すと良いでしょう。
Option
とは異なり、Except
データ型は通常、データ構造として使用されることはありません。いつもMonad
インスタンスを持った制御構造として使用されます。このことから、Except ε
のアクションをExceptT ε m
に持ち上げてベースのモナドm
のアクションのようにすることは合理的でしょう。例外の作用しか持たないアクションはモナドm
からの作用を持つことができないため、Except
のアクションをExceptT
のアクションに持ち上げるにはm
のpure
で包みます:instance [Monad m] : MonadLift (Except ε) (ExceptT ε m) where monadLift action := ExceptT.mk (pure action)
m
のアクションはその中に例外を持たないため、その値をExcept.ok
で包む必要があります。これはFunctor
がMonad
のスーパークラスであることを利用して実現できます。つまり、関数を任意のモナドの計算結果に適用するためにFunctor.map
を利用します:instance [Monad m] : MonadLift m (ExceptT ε m) where monadLift action := ExceptT.mk (.ok <$> action)
例外の型クラス
例外処理は基本的に2つの操作から成り立ちます:例外を投げる機能と例外から回復する機能です。これまでは、この2つの機能はそれぞれ
Except
のコンストラクタとパターンマッチを使って実現してきました。しかし、これでは例外を使用するプログラムを例外処理作用に限定した実装になってしまいます。型クラスを使用してこれらの操作をキャプチャすることで、例外を使用するプログラムで例外のスローとキャッチをサポートする 任意の モナドを使うことができるようになります。例外を投げるには例外を引数として取り、モナドのアクションが要求される任意のコンテキストで可能であるべきです。ここで言う「任意のコンテキスト」は
m α
と書くことで型として記述できます。なぜなら任意の型を生成することはできないため、throw
操作はプログラム中のその部分から制御を離脱させるようなことをしなければならないからです。例外のキャッチは任意のモナドのアクションと一緒にハンドラを受け入れるべきです。このハンドラは例外からアクションの型に戻る方法を説明するべきです:class MonadExcept (ε : outParam (Type u)) (m : Type v → Type w) where throw : ε → m α tryCatch : m α → (ε → m α) → m α
MonadExcept
の宇宙レベルはExceptT
のそれとは異なります。ExceptT
ではε
とα
は同じレベルでしたが、MonadExcept
ではそのような制限は課しません。これはMonadExcept
では例外の値をm
の中に置くことがないからです。この最大限に一般的な宇宙シグネチャから、この定義においてε
とα
が完全に独立しているということが認識されます。より一般的であるということは、型クラスがより多様な型に対してインスタンス化できるということです。MonadExcept
を使ったプログラムの例として簡単な割り算サービスを考えてみましょう。プログラムは2つのパーツに分けられます:ユーザにエラーハンドリング付きで文字列ベースのフロントエンドと、実際に割り算を行うバックエンドです。フロントエンドとバックエンドはどちらも例外を投げます。前者は不正な入力に対して、後者はゼロ除算エラーに対してです。これらの例外は以下の帰納型で表されます:inductive Err where | divByZero | notANumber : String → Err
バックエンドはゼロであるかをチェックし、可能な場合は割り算を実行します:
def divBackend [Monad m] [MonadExcept Err m] (n k : Int) : m Int := if k == 0 then throw .divByZero else pure (n / k)
フロントエンドの補助関数
asNumber
は渡された文字列が数値でない場合に例外を発生させます。フロントエンド全体は入力をInt
に変換し、バックエンドを呼びつつ例外が発生した際には読みやすい文字列のエラーを返します:def asNumber [Monad m] [MonadExcept Err m] (s : String) : m Int := match s.toInt? with | none => throw (.notANumber s) | some i => pure i def divFrontend [Monad m] [MonadExcept Err m] (n k : String) : m String := tryCatch (do pure (toString (← divBackend (← asNumber n) (← asNumber k)))) fun | .divByZero => pure "Division by zero!" | .notANumber s => pure s!"Not a number: \"{s}\""
例外のスローとキャッチはよく使われる機能であるため、Leanでは
MonadExcept
を使った特別な記法を用意しています。+
がHAdd.hAdd
の省略形だったように、try
とcatch
はtryCatch
メソッドの省略形として使用できます:def divFrontend [Monad m] [MonadExcept Err m] (n k : String) : m String := try pure (toString (← divBackend (← asNumber n) (← asNumber k))) catch | .divByZero => pure "Division by zero!" | .notANumber s => pure s!"Not a number: \"{s}\""
Except
とExceptT
に加えて、MonadExcept
のインスタンスには一見すると例外とは思えないような型に対しての便利なインスタンスが存在します。例えば、Option
による失敗は何のデータも含まない例外を投げていると見ることができるため、Option
と一緒にtry ... catch ...
構文を使うためのMonadExcept Unit Option
インスタンスが存在します。状態
あるモナドにて可変状態のシミュレーションを行えるようにするには、そのモナドのアクションが引数として開始状態を受け取り、その結果と一緒に最終状態を返すことでできます。状態モナドの束縛演算子はあるアクションの最終状態を次のアクションへ引数として渡し、プログラム中の状態を縫い合わせます。このパターンはモナド変換子でも表現されます:
def StateT (σ : Type u) (m : Type u → Type v) (α : Type u) : Type (max u v) := σ → m (α × σ)
ここでもまた、このモナドのインスタンスは
State
のインスタンスに非常に似ています。唯一の違いは、入力と出力の状態が純粋なコードではなく、ベースのモナドの中で受け渡され、返される点です:instance [Monad m] : Monad (StateT σ m) where pure x := fun s => pure (x, s) bind result next := fun s => do let (v, s') ← result s next v s'
これに対応する型クラスは
get
とset
メソッドを持ちます。get
とset
の欠点の一つに、状態を更新する際に間違った状態をあまりにも容易にset
できるようにしてしまうというものがあります。これは状態を取得・更新し、更新された状態を保存するという流れがプログラムによっては自然な書き方だからです。例えば、以下のプログラムは文字列中の発音区別のない英語の母音と子音の数をかぞえます:structure LetterCounts where vowels : Nat consonants : Nat deriving Repr inductive Err where | notALetter : Char → Err deriving Repr def vowels := let lowerVowels := "aeiuoy" lowerVowels ++ lowerVowels.map (·.toUpper) def consonants := let lowerConsonants := "bcdfghjklmnpqrstvwxz" lowerConsonants ++ lowerConsonants.map (·.toUpper ) def countLetters (str : String) : StateT LetterCounts (Except Err) Unit := let rec loop (chars : List Char) := do match chars with | [] => pure () | c :: cs => let st ← get let st' ← if c.isAlpha then if vowels.contains c then pure {st with vowels := st.vowels + 1} else if consonants.contains c then pure {st with consonants := st.consonants + 1} else -- modified or non-English letter pure st else throw (.notALetter c) set st' loop cs loop str.toList
ここで
set st'
の代わりにset st
といとも簡単に書き間違えうるのです。大きなプログラムでは、このようなミスは解析の難しいバグにつながる可能性があります。get
の呼び出しにネストしたアクションを使えばこの問題は解決しますが、いつでもこのやり方が通用するわけではありません。例えば、構造体のあるフィールドを別の2つのフィールドをもとに更新する関数を考えます。この場合、get
に対するネストされたアクションを別々に2度呼び出す必要があります。Leanのコンパイラは値の参照が1つの場合にのみ有効な最適化を含むため、状態への参照が重複するとコードが著しく遅くなる可能性があります。潜在的なパフォーマンスの問題と潜在的なバグの両方を回避するには、状態の変換に関数を用いるmodify
を使用することで対処できます:def countLetters (str : String) : StateT LetterCounts (Except Err) Unit := let rec loop (chars : List Char) := do match chars with | [] => pure () | c :: cs => if c.isAlpha then if vowels.contains c then modify fun st => {st with vowels := st.vowels + 1} else if consonants.contains c then modify fun st => {st with consonants := st.consonants + 1} else -- modified or non-English letter pure () else throw (.notALetter c) loop cs loop str.toList
この型クラスは
modify
に似たmodifyGet
という関数を持っており、これは戻り値の計算と古い状態の変換の両方を一度に行うことができます。この関数は最初の要素が戻り値で2番目の要素が新しい状態であるペアを返します;modify
はUnit
のコンストラクタをmodifyGet
で使われているこのペアにただ追加しただけの関数です:def modify [MonadState σ m] (f : σ → σ) : m Unit := modifyGet fun s => ((), f s)
MonadState
の定義は以下の通りです:class MonadState (σ : outParam (Type u)) (m : Type u → Type v) : Type (max (u+1) v) where get : m σ set : σ → m PUnit modifyGet : (σ → α × σ) → m α
PUnit
はUnit
型を宇宙多相にしてType
の代わりにType u
にできるようにしたものです。modifyGet
にはget
とset
を用いたデフォルト実装を利用することも可能ですが、その場合はそもそもmodifyGet
を便利にするための最適化が働かないためデフォルト実装は役に立ちません。Of
クラスとThe
関数ここまで見てきた、
MonadExcept
の例外の型や、MonadState
の状態の型のように追加の情報を受け取るモナドの型クラスはすべてこの追加情報の型を出力パラメータとして保持していました。単純なプログラムであれば、StateT
・ReaderT
・ExceptT
からどれか1つを組み合わせたモナドは状態の型、環境の型、例外の型のうちどれか1つしか持たないため一般的に利用しやすいです。しかし、モナドが複雑になってくると複数の状態やエラーの型を含むようになります。この場合、出力パラメータを用いると同じdo
ブロックで両方の状態をターゲットにすることができなくなります。このような場合のために、追加の情報を出力パラメータとしない追加の型クラスがあります。これらの型クラスでは名前に
Of
という単語を使っています。例えば、MonadStateOf
はMonadState
に似ていますが、outParam
修飾子を持ちません:同じように、追加情報の型を暗黙の引数ではなく 明示的に 受け取る型クラスのメソッドがあります。
MonadStateOf
の場合、getThe
があり、型は次のようになります:(σ : Type u) → {m : Type u → Type v} → [MonadStateOf σ m] → m σ
また
modifyThe
は以下の型になります。(σ : Type u) → {m : Type u → Type v} → [MonadStateOf σ m] → (σ → σ) → m PUnit
setThe
が無いのは、新しい状態の型だけでそれを包む状態のモナド変換として何を使うかを決定できるからです。Leanの標準ライブラリには
Of
バージョンのインスタンスで定義されたOf
なしバージョンのインスタンスが存在します。言い換えるとOf
バージョンを実装すると、両方の実装が得られます。一般的には、Of
バージョンを実装し、それからそのクラスのOf
なしバージョンを使ってプログラムをまず書き、出力パラメータが煩わしくなってきたところでOf
バージョンに移行すると良いでしょう。変換子と
Id
恒等モナド
Id
は何の作用も持たないモナドで、何らかの理由でモナドが要求されるものの実際にはモナドとしての性質が不要な場合に用いられます。Id
のもう一つの使い方は、モナド変換子のスタックの一番下に置くことです。例えば、StateT σ Id
はState σ
と同じように動作します。演習問題
モナドの約定
紙と鉛筆を使って、本節の各モナド変換子についてモナド変換子のルールが満たされていることをチェックしてください。
ロギング変換子
WithLog
のモナド変換子を定義してください。また対応する型クラスMonadWithLog
を定義し、ロギングと例外を組み合わせたプログラムを書いてください。ファイル数のカウント
doug
のモナドをStateT
に変更して、確認したディレクトリとファイルの数をカウントするようにしてください。実行の最後には以下のようなレポートを表示してください:Viewed 38 files in 5 directories.
モナド変換子の順序
モナド変換子のスタックからモナドを構成する場合、モナド変換子を重ねる順番に注意が必要です。同じ変換子のあつまりでも異なる順番で並べると異なるモナドになります。
以下の
countLetters
は前節のものとほぼ同じですが、具体的なモナドを提示する代わりに利用可能な作用の組を記述するために型クラスを使用する点が異なります:def countLetters [Monad m] [MonadState LetterCounts m] [MonadExcept Err m] (str : String) : m Unit := let rec loop (chars : List Char) := do match chars with | [] => pure () | c :: cs => if c.isAlpha then if vowels.contains c then modify fun st => {st with vowels := st.vowels + 1} else if consonants.contains c then modify fun st => {st with consonants := st.consonants + 1} else -- modified or non-English letter pure () else throw (.notALetter c) loop cs loop str.toList
状態と例外についてのモナド変換子の組み合わせ方には2通りあり、それぞれ両方の型クラスのインスタンスを持つモナドになります:
abbrev M1 := StateT LetterCounts (ExceptT Err Id) abbrev M2 := ExceptT Err (StateT LetterCounts Id)
例外が発生しない入力に対してプログラムを実行した場合、どちらのモナドも似たような結果を出力します:
#eval countLetters (m := M1) "hello" ⟨0, 0⟩
Except.ok ((), { vowels := 2, consonants := 3 })
#eval countLetters (m := M2) "hello" ⟨0, 0⟩
(Except.ok (), { vowels := 2, consonants := 3 })
しかし、これらの戻り値には微妙な違いがあります。
M1
の場合、外側のコンストラクタはExcept.ok
で、ユニットのコンストラクタと最終状態のペアを含んでいます。M2
の場合、外側のコンストラクタはペアで、その中にはユニットのコンストラクタを適用したExcept.ok
が含まれています。最終状態はExcept.ok
の外側になっています。どちらの場合もプログラムは母音と子音の数を返します。一方、例外が出る文字列を入力にした場合、母音と子音のカウントを返すモナドは1つだけです。
M1
を使うと例外の値だけが返されます:#eval countLetters (m := M1) "hello!" ⟨0, 0⟩
Except.error (StEx.Err.notALetter '!')
M2
を使うと、例外の値は例外が投げられた時の状態とのペアからなります:#eval countLetters (m := M2) "hello!" ⟨0, 0⟩
(Except.error (StEx.Err.notALetter '!'), { vowels := 2, consonants := 3 })
M2
ではデバッグ時に役立つ情報が多く得られるため、M2
の方がM1
より優れていると考えたくなるかもしれません。しかし、同じプログラムでもM1
とM2
で 異なる 計算結果を返すかもしれず、これらの答えのうちどちらか一方が他方より必ずしも優れているという原理的な理由はありません。これは上記のプログラムに例外を処理するステップを追加するとよくわかります:def countWithFallback [Monad m] [MonadState LetterCounts m] [MonadExcept Err m] (str : String) : m Unit := try countLetters str catch _ => countLetters "Fallback"
このプログラムは常に成功しますが、実際の入力に対して異なる結果で成功することもあります。例外が投げられなければ出力は
countLetters
と同じです:#eval countWithFallback (m := M1) "hello" ⟨0, 0⟩
Except.ok ((), { vowels := 2, consonants := 3 })
#eval countWithFallback (m := M2) "hello" ⟨0, 0⟩
(Except.ok (), { vowels := 2, consonants := 3 })
しかし、例外が投げられキャッチされた場合、最終結果は大きく異なります。
M1
の場合、最終状態には"Fallback"
の文字数だけが含まれます:#eval countWithFallback (m := M1) "hello!" ⟨0, 0⟩
Except.ok ((), { vowels := 2, consonants := 6 })
M2
での最終状態には"hello"
と"Fallback"
の両方の文字数が含まれます。これは命令型言語で見られるような挙動です:#eval countWithFallback (m := M2) "hello!" ⟨0, 0⟩
(Except.ok (), { vowels := 4, consonants := 9 })
M1
では、例外を投げると例外がキャッチされた時点まで状態を「ロールバック」します。M2
では、例外のスローとキャッチにまたがって状態の変更が持続します。この違いはM1
とM2
の定義を展開することでわかります。M1 α
は展開するとLetterCounts → Except Err (α × LetterCounts)
となり、M2 α
は展開するとLetterCounts → Except Err α × LetterCounts
となります。これはつまり、M1 α
は文字数の初期値を受け取り、エラーか「α
と更新された文字数」のペアを返す関数を記述しています。M1
で例外が投げられると、最終的な状態は無くなります。M2 α
は文字数の初期値を受け取り、更新された文字数とエラーかα
のペアを返す関数を記述しています。M2
での例外送出には状態が伴います。可換なモナド
関数型プログラミングの専門用語では、2つのモナド変換子がプログラムの意味を変えることなく並べ替えられる場合 可換 (commute)と呼ばれます。
StateT
とExceptT
を並べ替えたときにプログラムの結果が異なる可能性があるということは、状態と例外が可換ではないということを意味します。一般的に、モナド変換子が可換であることを期待するべきではありません。全てのモナド変換子が可換ではありませんが、可換であるものもあります。例えば、
StateT
を2つ使用した場合、これは並べ替えることができます。StateT σ (StateT σ' Id) α
の定義を展開するとσ → σ' → ((α × σ) × σ')
型が得られ、StateT σ' (StateT σ Id) α
からはσ' → σ → ((α × σ') × σ)
が得られます。言い換えると、両者の違いはσ
型とσ'
型を戻り値内の異なる場所にてネストし、異なる順序で引数を受け取るということです。クライアントコードは同じ入力を提供する必要があり、同じ出力を受け取ることができます。可変状態と例外を持つプログラミング言語のうちほとんどは
M2
のように動作します。そのような言語では例外が投げられたときに状態をロールバック しなければならない ように記述するのは難しく、通常はM1
の明示的な状態値の受け渡しによく似た方法でシミュレーションする必要があります。モナド変換子は手元の問題に対して作用の順序の解釈を選択する自由を与え、そして両者の選択は同じくらい容易にプログラム可能です。しかし、変換子の順序の選択には注意が必要です。この素晴らしい表現力を使うには表現されているものが意図通りかをチェックする責任が伴い、countWithFallback
の型シグネチャはおそらく必要以上に多相的になります。演習問題
ReaderT
とStateT
の定義を展開し、その結果の型を推論してReaderT
とStateT
が可換であることを確認してください。ReaderT
とExceptT
は可換になるでしょうか?定義を展開し、結果の型を推論して答えを確認してください。- モナド変換子
ManyT
をMany
の定義と適切なAlternative
インスタンスに基づいて構築してください。そしてそれがMonad
の約定を満たすことを確認してください。 ManyT
はStateT
と可換になるでしょうか?もしそうなら、定義を展開し、結果の型を推論して答えを確認してください。もしそうでないなら、ManyT (StateT σ Id)
とStateT σ (ManyT Id)
のプログラムを書いてください。それぞれのプログラムはモナド変換子の順序に対してより理にかなったものにしてください。
さらなる
do
の機能Leanの
do
記法はモナドを使ったプログラムを書くにあたって命令型プログラミング言語と同じように書くための構文を提供しています。do
記法はモナドを使ったプログラムのための構文を提供するだけでなく、ある種のモナド変換子を使うための構文を提供しています。分岐1つの
if
モナドを扱うときによくあるパターンはある条件が真である場合にのみ副作用を実行することです。例えば、
countLetters
は母音か子音かのチェックを含んでおり、どちらでもない文字は状態に影響を与えません。これはelse
ブランチがpure ()
に評価されることで捕捉されます:def countLetters (str : String) : StateT LetterCounts (Except Err) Unit := let rec loop (chars : List Char) := do match chars with | [] => pure () | c :: cs => if c.isAlpha then if vowels.contains c then modify fun st => {st with vowels := st.vowels + 1} else if consonants.contains c then modify fun st => {st with consonants := st.consonants + 1} else -- modified or non-English letter pure () else throw (.notALetter c) loop cs loop str.toList
if
が式ではなく、do
ブロック内の文である場合、else pure ()
は単に省略することができ、Leanは自動的に補完します。以下のcountLetters
の定義は完全に等価です:def countLetters (str : String) : StateT LetterCounts (Except Err) Unit := let rec loop (chars : List Char) := do match chars with | [] => pure () | c :: cs => if c.isAlpha then if vowels.contains c then modify fun st => {st with vowels := st.vowels + 1} else if consonants.contains c then modify fun st => {st with consonants := st.consonants + 1} else throw (.notALetter c) loop cs loop str.toList
状態モナドを使ってあるモナド的なチェックを満たすリストの項目を数えるプログラムは次のように書くことができます:
def count [Monad m] [MonadState Nat m] (p : α → m Bool) : List α → m Unit | [] => pure () | x :: xs => do if ← p x then modify (· + 1) count p xs
同様に、
if not E1 then STMT...
は代わりにunless E1 do STMT...
と書くことができます。モナドチェックを満たさない要素をカウントするcount
の逆はif
をunless
に置き換えて書くことができます:def countNot [Monad m] [MonadState Nat m] (p : α → m Bool) : List α → m Unit | [] => pure () | x :: xs => do unless ← p x do modify (· + 1) countNot p xs
分岐1つの
if
とunless
を理解するのに、モナド変換子について考える必要はありません。これらは単に足りない分岐をpure ()
に置き換えるだけです。しかし本節の残りで紹介する拡張機能は、Leanがdo
ブロックを自動的に書き換えて、do
ブロックが書かれているモナドの上にローカルな変換子を追加する必要があります。早期リターン
標準ライブラリにはあるチェックを満たすリストの最初の要素を返す
List.find?
という関数があります。Option
がモナドであることを利用しないシンプルな実装では、再帰関数を使ってリスト上をループし、お望みの要素のところでif
でループを止めます。def List.find? (p : α → Bool) : List α → Option α | [] => none | x :: xs => if p x then some x else find? p xs
命令型言語では関数の実行を中断するのに通常
return
キーワードを用いて呼び出し元に値を返します。Leanでは、これをdo
記法で使用することができます。return
はdo
ブロックの実行を停止し、return
に渡された引数をそのモナドから返される値とします。つまり、List.find?
は以下のように書くことができます。def List.find? (p : α → Bool) : List α → Option α | [] => failure | x :: xs => do if p x then return x find? p xs
命令型言語の早期リターンは現在のスタックフレームを巻き戻すだけの例外に似ています。早期リターンと例外はどちらもコードブロックの実行を終了し、そのコードの結果を投げられた例外の値で効率的に置き換えます。裏側では、Leanにおいての早期リターンは
ExceptT
を用いて実装されています。早期リターンを用いる各do
ブロックは例外ハンドラ(関数tryCatch
において用いられる意味)でラップされています。早期リターンは例外として値を投げることに変換され、ハンドラは投げられた値をキャッチしてそのまま返します。言い換えると、do
ブロックの元の戻り値の型は例外の型としても使われます。これをより具体的にすると、補助関数
runCatch
は例外の型と戻り値の型が同じ時にモナド変換子のスタックの一番上からExceptT
の層を取り除きます:def runCatch [Monad m] (action : ExceptT α m α) : m α := do match ← action with | Except.ok x => pure x | Except.error x => pure x
List.find?
をdo
ブロックで早期リターンを使用する実装から使用しない実装に変換するには、do
ブロックをrunCatch
で包み、早期リターンをthrow
に置き換えます:def List.find? (p : α → Bool) : List α → Option α | [] => failure | x :: xs => runCatch do if p x then throw x else pure () monadLift (find? p xs)
早期リターンを用いた方が良い別のシチュエーションとして、引数や入力が正しくない場合に早期に終了するコマンドラインアプリがあります。多くのプログラムでは、プログラム本体に進む前に引数や入力を検証するセクションから始まります。次のバージョンの 挨拶プログラム
hello-name
はコマンドライン引数が与えられていないことをチェックします:def main (argv : List String) : IO UInt32 := do let stdin ← IO.getStdin let stdout ← IO.getStdout let stderr ← IO.getStderr unless argv == [] do stderr.putStrLn s!"Expected no arguments, but got {argv.length}" return 1 stdout.putStrLn "How would you like to be addressed?" stdout.flush let name := (← stdin.getLine).trim if name == "" then stderr.putStrLn s!"No name provided" return 1 stdout.putStrLn s!"Hello, {name}!" return 0
これを引数無しで実行し、
David
という名前を入力すると以前のものと同じ結果になります:$ lean --run EarlyReturn.lean How would you like to be addressed? David Hello, David!
しかし入力待ちへの回答の代わりにコマンドライン引数として名前を指定するとエラーになります:
$ lean --run EarlyReturn.lean David Expected no arguments, but got 1
そして名前を入力しない場合は別のエラーになります:
$ lean --run EarlyReturn.lean How would you like to be addressed? No name provided
上記で見たように早期リターンを使うプログラムでは制御の流れをネストする必要が無くなりますが、これを早期リターンを使わないように実装すると以下のようになります:
def main (argv : List String) : IO UInt32 := do let stdin ← IO.getStdin let stdout ← IO.getStdout let stderr ← IO.getStderr if argv != [] then stderr.putStrLn s!"Expected no arguments, but got {argv.length}" pure 1 else stdout.putStrLn "How would you like to be addressed?" stdout.flush let name := (← stdin.getLine).trim if name == "" then stderr.putStrLn s!"No name provided" pure 1 else stdout.putStrLn s!"Hello, {name}!" pure 0
早期リターンについてLeanと命令型言語でのそれの大きな違いは、Leanの早期リターンは現在の
do
ブロックのみに適用されるという点です。関数の定義全体が1つの同じdo
ブロック内にある場合、この違いは問題になりません。しかし、do
が他の構造体の中にある場合はその違いが明らかになります。例えば、greet
の以下の定義について:def greet (name : String) : String := "Hello, " ++ Id.run do return name
式
greet "David"
を評価するとDavid
ではなく"Hello, David"
になります。繰り返し処理
可変状態を扱うような全てのプログラムが状態を引数として受け取るプログラムとして書けるように、すべての繰り返しは再帰関数として書くことができます。その観点で言えば、
List.find?
はまさに再帰関数として見ることができます。結論から言うと、その場合の定義はリストの構造を反映したものになっています:もし先頭がチェックを通過すればその要素が返されます;さもなくば後続のリストを見に行きます。もしリストに要素が1つも無ければ、結果はnone
になります。一方で見方を変えれば、List.find?
は繰り返し処理の関数として見ることができます。この関数はとどのつまり、チェックを満たすものが見つかるまで順番に要素を調べ、見つかった時点で終了するものだからです。ループが戻らずに終了した場合、結果はnone
になります。ForM
による繰り返しLeanにはコンテナ型に対する繰り返しをモナド上で行うことを記述する型クラスがあります。このクラスは
ForM
と呼ばれます:class ForM (m : Type u → Type v) (γ : Type w₁) (α : outParam (Type w₂)) where forM [Monad m] : γ → (α → m PUnit) → m PUnit
この定義はとても汎用的です。パラメータ
m
は想定している作用を持つモナド、γ
は繰り返しを行うコレクション、そしてα
はコレクションの要素の型です。通常、m
はどんなモナドでも構いませんが、例えばIO
での繰り返し処理のみをサポートするようなデータ構造とすることも可能です。メソッドforM
はコレクションと各要素に作用を及ぼすモナドのアクションを受け取り、アクションの実行を担います。このクラスの
List
に対するインスタンスはm
としてどんなモナドでも取ることができます。γ
にはList α
を、クラスのα
にはリストに渡しているものと同じα
を設定します:def List.forM [Monad m] : List α → (α → m PUnit) → m PUnit | [], _ => pure () | x :: xs, action => do action x forM xs action instance : ForM m (List α) α where forM := List.forM
doug
で使った関数doList
はリスト用のforM
です。forM
はdo
ブロックで使われることを想定されているため、Applicative
ではなくMonad
を用います。forM
を使うことでcountLetters
はさらに短くなります:def countLetters (str : String) : StateT LetterCounts (Except Err) Unit := forM str.toList fun c => do if c.isAlpha then if vowels.contains c then modify fun st => {st with vowels := st.vowels + 1} else if consonants.contains c then modify fun st => {st with consonants := st.consonants + 1} else throw (.notALetter c)
これの
Many
に対するインスタンスも同じようなものになります:def Many.forM [Monad m] : Many α → (α → m PUnit) → m PUnit | Many.none, _ => pure () | Many.more first rest, action => do action first forM (rest ()) action instance : ForM m (Many α) α where forM := Many.forM
γ
はどんな型でもよいため、ForM
は多相的ではないコレクションもサポートしています。非常に単純なコレクションとして、与えられた数より小さい自然数を降順に並べたものを考えます:structure AllLessThan where num : Nat
これの
forM
演算子は指定されたアクションをそれぞれの小さいNat
に適用します:def AllLessThan.forM [Monad m] (coll : AllLessThan) (action : Nat → m Unit) : m Unit := let rec countdown : Nat → m Unit | 0 => pure () | n + 1 => do action n countdown n countdown coll.num instance : ForM m AllLessThan Nat where forM := AllLessThan.forM
IO.println
を5未満の各数値に適用することはforM
で以下のように実装できます:#eval forM { num := 5 : AllLessThan } IO.println
4 3 2 1 0
特定のモナドでのみ動作する
ForM
インスタンスの例として、標準入力のようなIOストリームから読み込まれた行に対してループするものがあります:structure LinesOf where stream : IO.FS.Stream partial def LinesOf.forM (readFrom : LinesOf) (action : String → IO Unit) : IO Unit := do let line ← readFrom.stream.getLine if line == "" then return () action line forM readFrom action instance : ForM IO LinesOf String where forM := LinesOf.forM
この
forM
の定義はストリームが有限である保証がないことからpartial
とマークされています。この場合、IO.FS.Stream.getLine
はIO
モナドのみで動作するため他のモナドをループに使用することができません。次のサンプルプログラムではこの繰り返し構造を使って、文字を含まない行をフィルタリングします:
def main (argv : List String) : IO UInt32 := do if argv != [] then IO.eprintln "Unexpected arguments" return 1 forM (LinesOf.mk (← IO.getStdin)) fun line => do if line.any (·.isAlpha) then IO.print line return 0
test-data
ファイルの中身が以下の場合:Hello! !!!!! 12345 abc123 Ok
ForMIO.lean
に格納されているこのプログラムを実行すると次のような出力が得られます:$ lean --run ForMIO.lean < test-data Hello! abc123 Ok
繰り返し処理の停止
forM
を使う場合、繰り返し処理の途中で早期に終了することは難しいです。AllLessThan
内のNat
を3
に達するまで繰り返し処理する関数を書くには、繰り返しを途中で止める手段が必要になります。これを実現する1つの方法は、OptionT
モナド変換子と一緒にforM
を使うことです。そのためにまずOptionT.exec
を定義して、戻り値と変換された計算が成功したかどうかの両方の情報を破棄します:def OptionT.exec [Applicative m] (action : OptionT m α) : m Unit := action *> pure ()
そして
OptionT
のAlternative
インスタンスで失敗させるようにすることで、繰り返し処理を早期に終了させることができます:def countToThree (n : Nat) : IO Unit := let nums : AllLessThan := ⟨n⟩ OptionT.exec (forM nums fun i => do if i < 3 then failure else IO.println i)
以下の簡易的なテストで、この解決策が機能することが確認できます:
#eval countToThree 7
6 5 4 3
しかし、このコードはあまり読みやすくありません。繰り返し処理を早期に終了することは一般的なタスクであることから、Leanはこれを容易に実現できるよう追加の糖衣構文を用意しています。上記の関数は以下のようにも書けます:
def countToThree (n : Nat) : IO Unit := do let nums : AllLessThan := ⟨n⟩ for i in nums do if i < 3 then break IO.println i
試しに動かしてみると前のものと同じように動作することがはっきりするでしょう:
#eval countToThree 7
6 5 4 3
この文章を書いている時点では、構文
for ... in ... do ...
はForIn
という型クラスを使用したものに脱糖されます。このクラスは状態と早期リターンを担保したForM
をより複雑にしたものです。しかし、for
ループをリファクタリングして、よりシンプルなForM
を使うようにする計画があります。それまでの間、ForM.forIn
というForM
インスタンスをForIn
インスタンスに変換するアダプタが用意されています。ForM
インスタンスに基づくfor
ループを使えるようにするには、以下のようにAllLessThan
とNat
を適切に置き換えて追加します:instance : ForIn m AllLessThan Nat where forIn := ForM.forIn
ただし、このアダプタは(ほとんどのものがそうですが)モナドの制約を受けない
ForM
インスタンスにしか使えないことに注意してください。これは、このアダプタがベースのモナドではなくStateT
とExceptT
を使用するためです。早期リターンは
for
ループでサポートされています。早期リターンを伴うdo
ブロックを例外のモナド変換子を使うものに変換する流れは、forM
の中でも以前のOptionT
による繰り返しの停止と同じように適用できます。このバージョンのList.find?
はどちらも用います:def List.find? (p : α → Bool) (xs : List α) : Option α := do for x in xs do if p x then return x failure
break
に加え、for
ループは繰り返し内でループ本体の残りをスキップするためのcontinue
をサポートしています。上記とは別の(しかしわかりづらい)List.find?
の形式化はチェックを満たさない要素をスキップします:def List.find? (p : α → Bool) (xs : List α) : Option α := do for x in xs do if not (p x) then continue return x failure
Range
は開始番号、終了番号、ステップからなる構造体です。これは開始番号から始まり終了番号までステップずつ増えていく整数の数列を表しています。Leanではこの範囲を構成するための特別な記法を用意しており、角括弧と数値、そしてコロンを用いた4つの組み合わせによって記述できます。終点は必ず指定しなければなりませんが、始点とステップは任意で、デフォルトではそれぞれ0
と1
になります:式 開始 終了 ステップ リストとしての値 [:10]
0
10
1
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[2:10]
2
10
1
[2, 3, 4, 5, 6, 7, 8, 9]
[:10:3]
0
10
3
[0, 3, 6, 9]
[2:10:3]
2
10
3
[2, 5, 8]
開始番号が範囲に含まれて いる 一方で終了番号は含まれていないことに注意してください。3つの引数はすべて
Nat
であり、これは範囲を逆向きに数えることができないことを意味します。つまり開始番号が終了番号以上であるような範囲では、単に何の数値も含まなくなります。範囲は
for
ループと一緒に使うことで範囲から数値を引き出すことができます。次のプログラムは4から8までの偶数を数えます:def fourToEight : IO Unit := do for i in [4:9:2] do IO.println i
実行すると次を得ます:
4 6 8
最後に、
for
ループはin
節をカンマで区切ることで複数のコレクションを並列に繰り返し処理することができます。繰り返しは最初のコレクションの要素がなくなると停止するので、次の宣言:def parallelLoop := do for x in ["currant", "gooseberry", "rowan"], y in [4:8] do IO.println (x, y)
は以下の3行を出力します:
#eval parallelLoop
(currant, 4) (gooseberry, 5) (rowan, 6)
可変変数
早期の
return
、else
の無いif
、for
ループに加えて、Leanはdo
ブロック内で使える局所可変変数をサポートしています。裏側では、これらの可変変数は、本当の意味での可変変数としての実装にではなく、StateT
を使うように脱糖されます。繰り返しになりますが、関数プログラミングは命令型言語をシミュレートすることができます。局所可変変数はただの
let
の代わりにlet mut
で導入します。以下の定義two
は恒等モナドId
を使って作用を持ち込まないdo
記法を利用しており、2
になります:def two : Nat := Id.run do let mut x := 0 x := x + 1 x := x + 1 return x
このコードは
StateT
を使って1
を2回加算する定義と同じです:def two : Nat := let block : StateT Nat Id Nat := do modify (· + 1) modify (· + 1) return (← get) let (result, _finalState) := block 0 result
局所可変変数はモナド変換子のための便利な記法を提供する
do
記法の他の全ての機能とうまく連動します。定義three
は要素数が3つの数値リストの数を数えます:def three : Nat := Id.run do let mut x := 0 for _ in [1, 2, 3] do x := x + 1 return x
同じように、
six
はリストの要素を足し合わせます:def six : Nat := Id.run do let mut x := 0 for y in [1, 2, 3] do x := x + y return x
List.count
はリスト内で何らかのチェックを満たす要素の数を数えます:def List.count (p : α → Bool) (xs : List α) : Nat := Id.run do let mut found := 0 for x in xs do if p x then found := found + 1 return found
局所可変変数は明示的にローカルで
StateT
を使用するよりも使いやすく、読みやすくなります。しかし、命令型言語での無制限な可変変数のような完全な力は持っていません。特に、これらは導入されたdo
ブロックの中でしか変更できません。これは、例えばfor
ループを同じ機能を持つ再帰的な補助関数に置き換えることができないことを意味します。このバージョンのList.count
:def List.count (p : α → Bool) (xs : List α) : Nat := Id.run do let mut found := 0 let rec go : List α → Id Unit | [] => pure () | y :: ys => do if p y then found := found + 1 go ys return found
は
found
を更新しようとした時に次のようなエラーを出します:`found` cannot be mutated, only variables declared using `let mut` can be mutated. If you did not intent to mutate but define `found`, consider using `let found` instead
これは再帰関数が恒等モナドで記述され、変数が導入された
do
ブロックのモナドだけがStateT
で変換されるからです。どこまでを
do
ブロックとみなすか?do
記法の多くの機能は1つのdo
ブロックにのみ適用されます。早期リターンは現在のブロックを終了させ、可変変数はそれが定義されたブロックの中でしか可変にできません。これらを効果的に使うには、何をもって「同じブロック」であるのかを知ることが重要です。一般的に言って、
do
キーワードにつづくインデントされたブロックはブロックとみなされ、その内部にある一連の文はブロックの一部となります。しかし、ブロックに含まれている独立したブロックの文は、含まれているにもかかわらず外側のブロックの一部とはみなされません。ただし、実際に何を同じブロックとみなすかはやや曖昧であるため、いくつかの例を挙げておきましょう。ルールの性質は可変変数を持つプログラムを用意し、どこで変数の更新が可能かをみることで正確にテストすることができます。以下のプログラムでは変数の更新が含まれており、このことからこの更新が可変変数と同じブロックの中にあることがはっきりします:example : Id Unit := do let mut x := 0 x := x + 1
:=
を使ったlet
文の名前定義の一部がdo
ブロックで、そのブロック中で更新が起こった場合はこの更新は外側のブロックの一部とはみなされません:example : Id Unit := do let mut x := 0 let other := do x := x + 1 other
`x` cannot be mutated, only variables declared using `let mut` can be mutated. If you did not intent to mutate but define `x`, consider using `let x` instead
しかし、
do
ブロックが←
を使ったlet
の名前定義内にある場合、これは外側のブロックの一部とみなされます。次のプログラムは正常に動きます:example : Id Unit := do let mut x := 0 let other ← do x := x + 1 pure other
同じように、
do
ブロックが関数の引数に置かれている場合も外側のブロックから独立します。次のプログラムは正常に動きます:example : Id Unit := do let mut x := 0 let addFour (y : Id Nat) := Id.run y + 4 addFour do x := 5
`x` cannot be mutated, only variables declared using `let mut` can be mutated. If you did not intent to mutate but define `x`, consider using `let x` instead
do
キーワードが完全に冗長である場合、新しいブロックは導入されません。次のプログラムは正常に動き、本節の最初のプログラムと等価です:example : Id Unit := do let mut x := 0 do x := x + 1
do
の下にある(match
やif
で導入されるような)分岐の内容は冗長なdo
の有無にかかわらず、外側のブロックの一部とみなされます。以下のプログラムはすべて正常に動きます:example : Id Unit := do let mut x := 0 if x > 2 then x := x + 1 example : Id Unit := do let mut x := 0 if x > 2 then do x := x + 1 example : Id Unit := do let mut x := 0 match true with | true => x := x + 1 | false => x := 17 example : Id Unit := do let mut x := 0 match true with | true => do x := x + 1 | false => do x := 17
同じように、
for
やunless
記法の一部として出てくるdo
は構文の一部に過ぎず、新しいdo
ブロックを導入するものではありません。以下のプログラムも正常に動きます:example : Id Unit := do let mut x := 0 for y in [1:5] do x := x + y example : Id Unit := do let mut x := 0 unless 1 < 5 do x := x + 1
命令型か関数型か?
Leanの
do
記法がもたらす命令的な機能によって、多くのプログラムはRustやJava、C#のような言語によるそれと同等なプログラムと非常によく似たものになります。この類似性は命令型のアルゴリズムをLeanに翻訳する際に非常に便利です。実際にいくつかのタスクでは命令的に考えるほうがとても自然です。モナドとモナド変換子の導入により、命令型プログラムを純粋関数型言語で書くことができるようになり、モナドに特化した構文であるdo
記法(局所的に変換される可能性あり)により関数型プログラマは2つの長所を得ることができます:不変性と型システムを通じて利用可能な作用を厳密に制御することによって得られる強力な推論原理と、作用を使用するプログラムを見慣れたものにし読みやすくする構文とライブラリのコンビです。モナドとモナド変換子によって関数型プログラミングと命令型プログラミングの違いは見方の問題に帰着します。演習問題
doug
をdoList
関数の代わりにfor
を使って書き直してください。本節で紹介した機能を使ってコードを改善できそうな箇所はありそうでしょうか?あったらやってみましょう!
その他の便利な機能
パイプ演算子
関数は通常、引数の前に書かれます。これはプログラムを左から右に読む際に、関数の 出力 が最重要だという見方を助長します。つまり、関数は達成すべきゴール(つまり計算すべき値)を持っており、そのプロセスをサポートするために引数を受け取るという視点です。しかし、プログラムの中には出力の生成のために入力が逐次的に改良されていく、という観点のほうが理解しやすいものも存在します。このような状況に対して、LeanはF#にあるものと似た パイプライン 演算子を提供しています。パイプライン演算子はClojureのスレッディングマクロと同じ状況化で有用です。
パイプライン
E1 |> E2
はE2 E1
の省略形です。例えば、以下を評価すると:#eval some 5 |> toString
以下の結果になります:
"(some 5)"
このように強調箇所を変えることでより読みやすくなるプログラムもありますが、多くのコンポーネントを含む場合にパイプラインはその本領を発揮します。
以下の定義に対して:
def times3 (n : Nat) : Nat := n * 3
以下のパイプラインは:
#eval 5 |> times3 |> toString |> ("It is " ++ ·)
以下を出力します:
"It is 15"
より一般的には、パイプラインの列
E1 |> E2 |> E3 |> E4
は関数適用のネストE4 (E3 (E2 E1))
の省略形です。パイプラインは反対向きに書くこともできます。この場合、変換する対象のデータを先に持ってきません;しかし、入れ子になった括弧が多くて読者が困るような場合にはパイプラインによって適用のステップを明確にすることができます。先ほどの例は以下の記述と等価です:
#eval ("It is " ++ ·) <| toString <| times3 <| 5
これは以下の短縮形です:
#eval ("It is " ++ ·) (toString (times3 5))
Leanのメソッドのドット記法は、ドットの前の型名を使ってドットの後ろの演算子の名前空間を解決するもので、パイプラインと似た目的を提供しています。仮にパイプライン演算子が無くとも、
[1, 2, 3].reverse
の代わりにList.reverse [1, 2, 3]
と書くことは可能です。しかし、パイプライン演算子はドットを付けた関数をたくさん使う場合でも便利です。([1, 2, 3].reverse.drop 1).reverse
は[1, 2, 3] |> List.reverse |> List.drop 1 |> List.reverse
と書くこともできます。この書き方では、ただ引数を受け取るという理由だけで式を括弧で囲む必要がなく、KotlinやC#のような言語での便利なメソッドチェーンを再現しています。ただし、この場合は名前空間を手動で指定する必要があります。そこで究極的な便利機能として、Leanは「パイプラインドット」演算子を提供しています。これは関数をパイプラインのようにグループ化しますが、名前空間を解決するために型名を使用します。「パイプラインドット」を使用すると、上記の例は[1, 2, 3] |>.reverse |>.drop 1 |>.reverse
と書き換えることができます。無限ループ
do
ブロックの中で、repeat
キーワードを使うと無限ループを作れます。例えば、"Spam!"
という文字列を連投するプログラムで以下のように使われます:def spam : IO Unit := do repeat IO.println "Spam!"
repeat
ループはfor
ループと同じようにbreak
とcontinue
をサポートしています。feline
の実装 におけるdump
関数は再帰関数を使って永遠に実行していました:partial def dump (stream : IO.FS.Stream) : IO Unit := do let buf ← stream.read bufsize if buf.isEmpty then pure () else let stdout ← IO.getStdout stdout.write buf dump stream
この関数は
repeat
を使うことで劇的に短縮できます:def dump (stream : IO.FS.Stream) : IO Unit := do let stdout ← IO.getStdout repeat do let buf ← stream.read bufsize if buf.isEmpty then break stdout.write buf
spam
もdump
も、それ自体が無限再帰ではないためpartial
として宣言する必要はありません。その代わりに、repeat
はForM
インスタンスがpartial
である型を使用します。関数の部分性は、関数の呼び出し時に「感染」することはありません。whileループ
局所的な可変性を使ってプログラムを書く場合、
while
ループはif
でbreak
をガードするようなrepeat
よりも便利です:def dump (stream : IO.FS.Stream) : IO Unit := do let stdout ← IO.getStdout let mut buf ← stream.read bufsize while not buf.isEmpty do stdout.write buf buf ← stream.read bufsize
裏では、
while
はrepeat
をよりシンプルにした表記にすぎません。まとめ
モナドを組み合わせる
モナドをイチから書く場合、デザインパターンとしてはモナドに各作用を追加する方法を記述しがちです。リーダ作用はモナドの型をリーダの環境からの関数にすること、状態作用は初期状態から最終状態と計算結果のペアへの関数を含めること、失敗や例外は戻り値の型に直和型を含めること、ロギングやその他の出力は戻り値の型に直積型を含めることで、それぞれの作用が追加されます。既存のモナドも同様に戻り値の型の一部にすることができ、それによってその作用を新しいモナドに含めることができます。
こうしたデザインパターンはベースのモナドに作用を追加する モナド変換子 を定義することで再利用可能なソフトウェアコンポーネントによるライブラリとなります。モナド変換子はより単純なモナド型を引数として取り、拡張されたモナド型を返します。モナド変換子は最低でも以下のインスタンスを提供しなければなりません:
- 内側の型がすでにモナドであると仮定する
Monad
インスタンス - 内側のモナドから変換後のモナドにアクションを変換する
MonadLift
インスタンス
モナド変換子は多相構造体や帰納的データ型として実装されることもありますが、ベースのモナド型から拡張されたモナド型への関数として実装されることが最も多いです。
作用ごとの型クラス
モナド変換子のデザインパターンは共通して、作用を持つモナドとその作用を別のモナドに追加するモナド変換子、作用へのジェネリックなインタフェースを提供する型クラスを定義して、特定の作用を実装します。これにより、必要な作用を指定するだけのプログラムを書くことができ、呼び出し側は適切な作用を持つ任意のモナドを提供することができます。
ある時は補助的な型情報(例えば、状態を提供するモナドにおける状態の型や、例外を提供するモナドにおける例外の型)が出力パラメータになることもありますが、ならない時もあります。出力パラメータはそれぞれの種類の作用を一度だけ使用するシンプルなプログラムにおいて最も有用ですが、同じ作用の複数のインスタンスが特定のプログラムで使用される場合、型チェッカが間違った型にせっかちにコミットしてしまう危険性があります。そのため、通常は両方のバージョンが提供されており、通常のパラメータのバージョンの型クラスは
-Of
で終わる名前を持ちます。モナド変換子は可換ではない
モナド内の変換子の順序を変えると、モナドを使用するプログラムの意味が変わってしまうことに注意することが重要です。例えば、
StateT
とExceptT
の順序を変更すると、例外が投げられた時に状態の変更が失われるプログラムか、変更が維持されるプログラムかのどちらかになります。ほとんどの命令型言語では後者しか提供しませんが、モナド変換子によって柔軟性が増すため、目の前のタスクに適した種類を選択するための思考と注意が必要になります。モナド変換子のための
do
記法Leanの
do
ブロックは、ブロックで何かしらの値で終了する早期リターン、局所的な可変変数、break
とcontinue
を使ったfor
ループ、単一分岐のif
文をサポートしています。これはLeanを使って証明を書く際には邪魔になるような命令的な機能を導入しているように見えるかもしれませんが、実際にはただモナド変換子のある一般的な使用法に対しての便利な構文に過ぎません。裏では、do
ブロックがどのようなモナドで書かれていたとしても、これらの追加作用をサポートするためにExceptT
とStateT
の適切な使用によって変換されます。依存型によるプログラミング
ほとんどの静的型付けプログラミングでは、型の世界とプログラミングの世界の間にはハーメチックシール(訳注:気密防水のためのシールのこと)が貼られています。型とプログラミングでは異なる文法が用いられ、異なるタイミングで使用されます。型は通常、コンパイル時に用いられ、プログラムが特定の不変量にしたがうかどうかをチェックします。プログラムは実行時に用いられ、実際に計算を実行します。この2つを相互に作用させる場合、通常は
instance-of
チェックのような型ケース演算子や、キャスティング演算子などを用いる形で行われます。これらは実行時に検証するために型チェッカに他の方法で得られなかった情報を提供してくれます。言い換えれば、この相互作用は型がプログラムの世界に挿入され、実行時に限定された意味を持つようになるということです。Leanにおいて、このような厳格な分離は行いません。Leanではプログラムは型を計算し、型はプログラムを含むことができます。プログラムを型の中に置くことで、コンパイル時にその計算能力をフルに使うことができ、関数から型を返す機能により型はプログラミングのプロセスにおいて第一級の参加者となります。
依存型 (dependent type)とは型以外の式を含む型のことです。依存型は関数に与えられる名前付きの引数においてよく見られます。例えば、関数
natOrStringThree
は渡されたBool
によって自然数か文字列のどちらかを返します:def natOrStringThree (b : Bool) : if b then Nat else String := match b with | true => (3 : Nat) | false => "three"
依存型について他にも以下のような例がありました:
- 多相性について導入した節 で定義した
posOrNegThree
はその引数の値に戻り値の型が依存します。 OfNat
型クラス はインスタンス化の際に使われた特定の数値リテラルに依存します。- バリデータの例にて用いられた
CheckedInput
構造体 はバリデーション時に渡される年の値に依存します。 - 部分型 は特定の値を参照する命題を含みます。
- 配列の添え字表記 の妥当性決定を含め、本質的に興味深い名地あはすべて値を含む型であり、したがって依存型です。
依存型は型システムの能力を飛躍的に向上させます。引数の値によって戻り値の型を分岐させられる柔軟性により、ほかのシステムでは簡単に与えられないようなプログラムを書くことができます。同時に、依存型は型シグネチャによって関数から返される値を制限することを可能にし、コンパイル時に強力な不変性を強制することを可能にします。
しかし、依存型を使ったプログラミングは非常に複雑であり、関数型プログラミング以上のスキルを必要とします。表現力豊かな仕様を満たすことは複雑であり、それにとらわれすぎてプログラムを完成させることができなくなる危険性があります。その一方で、このプロセスによって新たな理解につながることもあり、それは洗練された型として表現することができます。この章は依存型プログラミングの表層を掬うだけにとどまりますが、このトピックは実に奥が深く、それだけで1冊の本を出版するに値するものです。
添字族
多相的な帰納型は型引数を取ります。例えば、
List
はリストの要素の型を、Except
は例外や値の型をそれぞれ決定する引数を取ります。これらの型引数は対象のデータ型のすべてのコンストラクタで共有され、パラメータ と呼ばれます。しかし、帰納型の引数はすべてのコンストラクタで同じである必要はありません。コンストラクタの選択によって型への引数が変化するような帰納型は 添字族 (indexed family)と呼ばれ、変化する引数は 添字 (index)と呼ばれます。添字族における「hello world」的教材は、要素の型に加えてリストの長さを含むリスト型であり、慣習的に「ベクトル」と呼ばれています:
inductive Vect (α : Type u) : Nat → Type u where | nil : Vect α 0 | cons : α → Vect α n → Vect α (n + 1)
関数宣言ではコロンの前にいくつかの引数を取り、定義全体で使用可能であることを示します。そしていくつかの引数はコロンの後ろに置き、この引数をパターンマッチの利用や関数ごとに定義できるようにしていることを示します。帰納的データ型でも同じ原理が働きます:引数
α
がデータ型宣言の先頭かつコロンより前に来ている場合、これはパラメータでVect
中のすべての定義の第一引数として提供されなければならないことを意味します。一方で、Nat
引数はコロンの後ろにあり、これは変化する可能性のあるインデックスであることを示します。実際、nil
とcons
コンストラクタの宣言の中でVect
が3回出現すると、それらすべてに一貫してα
が第一引数として指定されますが、第二引数はそれぞれ異なります。nil
の宣言では、これがVect α 0
型のコンストラクタであることが示されています。これは、ちょうどList String
が期待されているコンテキストにおいて[1, 2, 3]
がエラーになるように、Vect String 3
型が期待されているコンテキストでVect.nil
を使うと型エラーになるということです。example : Vect String 3 := Vect.nil
type mismatch Vect.nil has type Vect String 0 : Type but is expected to have type Vect String 3 : Type
この例においての
0
と3
の食い違いはこれら自体が型でないにもかかわらず、一般的な型のミスマッチとまったく同じ役割を果たします。添字族は異なる添字の値によって使用できるコンストラクタを変えることができることから、型の 族 と呼ばれます。ある意味では、添字族は型ではありません;むしろ、関連した型のあつまりであり、添字の選択によってそのあつまりから型を選びます。
Vect
において添字5
を選ぶことによってコンストラクタはconst
のみ、また添字0
を選ぶことによってnil
のみがそれぞれ利用可能となります。添字が不明である場合(例えば変数である等)、それが明らかになるまではどのコンストラクタも利用できません。長さとして
n
を用いるとVect.nil
とVect.cons
のどちらも使用できません。というのもn
が0
とn + 1
のどちらにマッチするNat
を表すのかを知るすべがないからです:example : Vect String n := Vect.nil
type mismatch Vect.nil has type Vect String 0 : Type but is expected to have type Vect String n : Type
example : Vect String n := Vect.cons "Hello" (Vect.cons "world" Vect.nil)
type mismatch Vect.cons "Hello" (Vect.cons "world" Vect.nil) has type Vect String (0 + 1 + 1) : Type but is expected to have type Vect String n : Type
型の一部でリストの長さを保持することはその型がより有益になることを意味します。例えば、
Vect.replicate
は与えられた値のコピー回数をもとにVect
を作る関数です。これを正確に表す型は以下のようになります:def Vect.replicate (n : Nat) (x : α) : Vect α n := _
引数
n
が結果の長さとして現れています。アンダースコアによるプレースホルダに紐づいたメッセージでは現在のタスクが説明されています:don't know how to synthesize placeholder context: α : Type u_1 n : Nat x : α ⊢ Vect α n
添字族を扱う際に、コンストラクタはLeanがそのコンストラクタの添字が期待される型の添字と一致することを確認できた場合にのみ適用可能です。しかし、どちらのコンストラクタも
n
にマッチする添字を持っていません。nil
はNat.zero
に、cons
はNat.succ
にマッチします。型エラーの例のように、変数n
は関数に引数として渡されるNat
によってこのどちらかを表す可能性があります。そこで解決策としてパターンマッチを使用してありうる両方のケースを考慮することができます:def Vect.replicate (n : Nat) (x : α) : Vect α n := match n with | 0 => _ | k + 1 => _
n
は期待される型に含まれるため、n
のパターンマッチによってマッチする2つのケースでの期待される型が 絞り込まれます 。1つ目のアンダースコアでは、期待される型はVect α 0
になります:don't know how to synthesize placeholder context: α : Type u_1 n : Nat x : α ⊢ Vect α 0
2つ目のアンダースコアでは
Vect α (k + 1)
になります:don't know how to synthesize placeholder context: α : Type u_1 n : Nat x : α k : Nat ⊢ Vect α (k + 1)
パターンマッチが値の構造を解明することに加えてプログラムの型を絞り込む場合、これは 依存パターンマッチ (dependent pattern matching)と呼ばれます。
精練された型ではコンストラクタを適用することができます。1つ目のアンダースコアでは
Vect.nil
が、2つ目ではVect.cons
がそれぞれマッチします:def Vect.replicate (n : Nat) (x : α) : Vect α n := match n with | 0 => .nil | k + 1 => .cons _ _
.cons
の中にある1つ目のアンダースコアはα
型でなければなりません。ここで利用可能なα
は存在しており、まさにx
のことです:don't know how to synthesize placeholder context: α : Type u_1 n : Nat x : α k : Nat ⊢ α
2つ目のアンダースコアは
Vect α k
であるべきであり、これはreplicate
を再帰的に呼び出すことで生成できます:don't know how to synthesize placeholder context: α : Type u_1 n : Nat x : α k : Nat ⊢ Vect α k
以下が
replicate
の最終的な定義です:def Vect.replicate (n : Nat) (x : α) : Vect α n := match n with | 0 => .nil | k + 1 => .cons x (replicate k x)
関数を書く間の支援に加えて、
Vect.replicate
の情報に富んだ型によってクライアントコードはソースコードを読まなくても多くの予期しない関数である可能性を除外することができます。リスト用にしたreplicate
では、間違った長さのリストを生成する可能性があります:def List.replicate (n : Nat) (x : α) : List α := match n with | 0 => [] | k + 1 => x :: x :: replicate k x
しかし、このミスは
Vect.replicate
では型エラーになります:def Vect.replicate (n : Nat) (x : α) : Vect α n := match n with | 0 => .nil | k + 1 => .cons x (.cons x (replicate k x))
application type mismatch cons x (cons x (replicate k x)) argument cons x (replicate k x) has type Vect α (k + 1) : Type ?u.1998 but is expected to have type Vect α k : Type ?u.1998
List.zip
は2つのリストに対して、1つ目のリストの最初の要素と2つ目のリストの最初の要素をペアに、1つ目のリストの2番目の要素と2つ目のリストの2番目の要素をペアに、という具合に結合していく関数です。List.zip
はオレゴン州の3つの最高峰とデンマークの3つの最高峰をペアにすることができます:["Mount Hood", "Mount Jefferson", "South Sister"].zip ["Møllehøj", "Yding Skovhøj", "Ejer Bavnehøj"]
結果は3つのペアを含むリストです:
[("Mount Hood", "Møllehøj"), ("Mount Jefferson", "Yding Skovhøj"), ("South Sister", "Ejer Bavnehøj")]
リストの長さが異なる場合にどうすればいいかはやや不明確です。多くの言語のように、Leanは長い方のリストの余分な要素を無視する実装を選んでいます。例えば、オレゴン州の5つの最高峰の高度とデンマークの3つの最高峰の高度を組み合わせると3つのペアができます。具体的には:
[3428.8, 3201, 3158.5, 3075, 3064].zip [170.86, 170.77, 170.35]
は以下に評価されます。
[(3428.8, 170.86), (3201, 170.77), (3158.5, 170.35)]
このアプローチでは必ず答えが得られる点が便利である一方、意図せず長さの異なるリストを渡した際に情報が捨てられてしまう危険性があります。F#は別のアプローチをとっています:以下の
fsi
セッションで見られるように、F#でのList.zip
では長さが異なる場合は例外を投げます:> List.zip [3428.8; 3201.0; 3158.5; 3075.0; 3064.0] [170.86; 170.77; 170.35];;
System.ArgumentException: The lists had different lengths. list2 is 2 elements shorter than list1 (Parameter 'list2') at Microsoft.FSharp.Core.DetailedExceptions.invalidArgDifferentListLength[?](String arg1, String arg2, Int32 diff) in /builddir/build/BUILD/dotnet-v3.1.424-SDK/src/fsharp.3ef6f0b514198c0bfa6c2c09fefe41a740b024d5/src/fsharp/FSharp.Core/local.fs:line 24 at Microsoft.FSharp.Primitives.Basics.List.zipToFreshConsTail[a,b](FSharpList`1 cons, FSharpList`1 xs1, FSharpList`1 xs2) in /builddir/build/BUILD/dotnet-v3.1.424-SDK/src/fsharp.3ef6f0b514198c0bfa6c2c09fefe41a740b024d5/src/fsharp/FSharp.Core/local.fs:line 918 at Microsoft.FSharp.Primitives.Basics.List.zip[T1,T2](FSharpList`1 xs1, FSharpList`1 xs2) in /builddir/build/BUILD/dotnet-v3.1.424-SDK/src/fsharp.3ef6f0b514198c0bfa6c2c09fefe41a740b024d5/src/fsharp/FSharp.Core/local.fs:line 929 at Microsoft.FSharp.Collections.ListModule.Zip[T1,T2](FSharpList`1 list1, FSharpList`1 list2) in /builddir/build/BUILD/dotnet-v3.1.424-SDK/src/fsharp.3ef6f0b514198c0bfa6c2c09fefe41a740b024d5/src/fsharp/FSharp.Core/list.fs:line 466 at <StartupCode$FSI_0006>.$FSI_0006.main@() Stopped due to error
これによって誤って情報が破棄される事態を避けられますが、その代わりにプログラムをクラッシュさせてしまうことにはそれなりの困難が伴います。Leanにおいて
Option
やExcept
モナドを使うような同じアプローチをすると安全性に見合わない負担が発生します。しかし
Vect
を使えば、両方の引数が同じ長さであることを要求する型を持つバージョンのzip
を書くことができます:def Vect.zip : Vect α n → Vect β n → Vect (α × β) n | .nil, .nil => .nil | .cons x xs, .cons y ys => .cons (x, y) (zip xs ys)
この定義は両方の引数が
Vect.nil
であるか、Vect.cons
である場合のパターンを持っているだけであり、LeanはList
に対する以下の同様の定義の結果のようなmissing cases
エラーを出さずに定義を受け入れます:def List.zip : List α → List β → List (α × β) | [], [] => [] | x :: xs, y :: ys => (x, y) :: zip xs ys
missing cases: (List.cons _ _), [] [], (List.cons _ _)
これは最初のパターンで使用されているコンストラクタ
nil
またはcons
が長さn
に関する型チェッカが持つ情報を 洗練 させるからです。最初のパターンがnil
の場合、型チェッカは追加で長さが0
であることを判断でき、その結果2つ目のパターンはnil
以外ありえなくなります。同様に、最初のパターンがcons
の場合、型チェッカはNat
のあるk
に対してk + 1
の長さだったと判断することができ、2つ目のパターンはcons
以外ありえなくなります。実際、nil
とcons
を一緒に使うケースを追加すると、長さが一致しないために型エラーになります:def Vect.zip : Vect α n → Vect β n → Vect (α × β) n | .nil, .nil => .nil | .nil, .cons y ys => .nil | .cons x xs, .cons y ys => .cons (x, y) (zip xs ys)
type mismatch Vect.cons y ys has type Vect β (?m.4718 + 1) : Type ?u.4530 but is expected to have type Vect β 0 : Type ?u.4530
n
を明示的な引数にすることで、長さについての詳細化をより見やすくすることができます:def Vect.zip : (n : Nat) → Vect α n → Vect β n → Vect (α × β) n | 0, .nil, .nil => .nil | k + 1, .cons x xs, .cons y ys => .cons (x, y) (zip k xs ys)
演習問題
依存型を使ったプログラミングの感覚をつかむには、この節の演習問題は非常に重要です。各演習問題において、コードを試しながら、型チェッカがどのミスを捉え、どのミスを捉えられないかを試行錯誤してみてください。これはエラーメッセージに対する感覚を養う良い方法でもあります。
- オレゴン州の3つの最高峰とデンマークの3つの最高峰を組み合わせたときに
Vect.zip
が正しい答えを出すかダブルチェックしてください。Vect
にはList
のような糖衣構文がないので、まずoregonianPeaks : Vect String 3
とdanishPeaks : Vect String 3
を定義しておくと便利でしょう。
(α → β) → Vect α n → Vect β n
型を持つVect.map
関数を定義してください。
Vect
の各要素を結合する際に関数を適用する関数Vect.zipWith
を定義してください。これは(α → β → γ) → Vect α n → Vect β n → Vect γ n
型になるべきです。
- 要素がペアである
Vect
をVect
のペアに分割する関数Vect.unzip
を定義してください。これはVect (α × β) n → Vect α n × Vect β n
型になるべきです。
Vect
の 末尾 に要素を追加するVect.snoc
を定義してください。これはVect α n → α → Vect α (n + 1)
型になるべきで、#eval Vect.snoc (.cons "snowy" .nil) "peaks"
はVect.cons "snowy" (Vect.cons "peaks" (Vect.nil))
を返すべきです。snoc
という名前は伝統的な関数型プログラミングのダジャレで、cons
を逆さにしたものです。
Vect
の順序を逆にする関数Vect.reverse
を書いてください。
- 次の型を持つ関数
Vect.drop
を定義してください:(n : Nat) → Vect α (k + n) → Vect α k
。この関数が正しく動作することを検証するには#eval danishPeaks.drop 2
がVect.cons "Ejer Bavnehøj" (Vect.nil)
を返すことを確認することで行えます。
- 型
(n : Nat) → Vect α (k + n) → Vect α n
でVect
の先頭からn
個の要素を返すの関数Vect.take
を定義してください。例をあげて動作することを確認もしてください。
ユニバースによるデザインパターン
Leanにおいて、
Type
やType 3
、Prop
などの他の型を分類する型を宇宙(universe)と呼んでいます。しかし、ユニバース という用語は、Leanの型のサブセットを表すためのデータ型とデータ型のコンストラクタを実際の型に変換するための関数を使用するデザインパターンに対しても用いられます。このデータ型の値は、その型に対しての コード (code)と呼ばれます。Lean組み込みの宇宙と同じように、このパターンで実装されたユニバースは利用可能な型のコレクションを記述する型ですが、これを実現するメカニズムは異なります。Leanにおいて、
Type
やType 3
、Prop
などの型があり、これらは他の型を直接記述します。このやり方は Russell風宇宙 (universes à la Russell)と呼ばれます。本節で説明するユーザ定義のユニバースは対象の型すべてを データ として表し、これらのコードを正真正銘実際の型へと解釈する明示的な関数を含みます。このやり方は Tarski風ユニバース (universes à la Tarski)と呼ばれます。Leanのような依存型理論に基づく言語ではほとんどの場合Russell流の宇宙が使用されますが、これらの言語においてAPIを定義する場合にはTarski流のユニバースは有用なパターンです。カスタムのユニバースを定義することで、APIで使用できる範囲に閉じた型のコレクションを切り出すことが可能になります。型のコレクションが閉じていることから、コードに対して再帰させることでユニバース内の すべての 型に対してプログラムを動作させることができます。カスタムユニバースの一例として、以下ではコード
nat
はNat
を、bool
はBool
をそれぞれ表しています:inductive NatOrBool where | nat | bool abbrev NatOrBool.asType (code : NatOrBool) : Type := match code with | .nat => Nat | .bool => Bool
Vect
のコンストラクタのパターンマッチで期待される長さを絞り込めるように、コードのパターンマッチで型を絞り込むことができます。例えば、このユニバースの型を文字列からデシリアライズするプログラムは次のように書くことができます:def decode (t : NatOrBool) (input : String) : Option t.asType := match t with | .nat => input.toNat? | .bool => match input with | "true" => some true | "false" => some false | _ => none
t
に対する依存パターンマッチにより、期待される結果の型t.asType
はそれぞれNatOrBool.nat.asType
とNatOrBool.bool.asType
に精練され、これらは実際の型Nat
とBool
に計算されます。他の一般的なデータと同じように、コードは再帰的である場合があります。
NestedPairs
型は自然数型とあらゆる入れ子に対応したペアの型を実装しています:inductive NestedPairs where | nat : NestedPairs | pair : NestedPairs → NestedPairs → NestedPairs abbrev NestedPairs.asType : NestedPairs → Type | .nat => Nat | .pair t1 t2 => asType t1 × asType t2
この場合、解釈関数
NestedPairs.asType
は再帰的になります。これはユニバースにBEq
を実装するためには、コードに対する再帰が必要になるということです:def NestedPairs.beq (t : NestedPairs) (x y : t.asType) : Bool := match t with | .nat => x == y | .pair t1 t2 => beq t1 x.fst y.fst && beq t2 x.snd y.snd instance {t : NestedPairs} : BEq t.asType where beq x y := t.beq x y
たとえ
NestedPairs
ユニバースのすべての型がすでにBEq
インスタンスを持っていたとしても、型クラス検索はインスタンス宣言のデータ型すべての可能なケースを自動的にチェックしません。これはNestedPairs
のような場合において、この可能なケースが無限に存在してしまう可能性があるからです。コードに対する再帰によってBEq
インスタンスを見つける方法をLeanに提示するのではなく、BEq
インスタンスに直接表現しようとするとエラーになります:instance {t : NestedPairs} : BEq t.asType where beq x y := x == y
failed to synthesize instance BEq (NestedPairs.asType t)
エラーメッセージの
t
はNestedPairs
型の未知の値を表しています。型クラス vs ユニバース
型クラスは必要なインタフェースの実装を持っている限りAPIとともにオープンエンドな型のコレクションを使用することができます。大体の場合、この方が望ましいです。APIのすべてのユースケースを事前に予測することは困難であり、型クラスはオリジナルの作者が予想したよりも多くの型でライブラリコードを使用できるようにする便利な方法です。
一方、Tarski風ユニバースは、APIがあらかじめ決められた型のコレクションでしか使えないように制限します。これはいくつかの状況で役に立ちます:
- どの型が渡されるかによって関数の動作が大きく異なる場合。この場合、型そのものに対するパターンマッチは不可能ですが、型に対応したコードに対するパターンマッチは可能です
- 外部システムが本質的に提供可能なデータの種類を制限しており、余分な柔軟性が望まれない場合
- ある操作の実装以上に型へのプロパティ追加が必要な場合
型クラスはJavaやC#のインタフェースと同じような状況の多くで役に立ちます。一方で、Tarski風ユニバースはsealed classは使えそうだが、通常の帰納的データ型が使えないようなケースで役に立ちます。
有限の型に対するユニバース
APIで使用できる型をあらかじめ決められたコレクションに制限することで、オープンエンドなAPIでは不可能な操作を可能にすることができます。例えば、関数は通常同値性を確かめることはできません。関数は同じ入力に対して同じ出力を写した時に等しいとみなせます。これは無限に時間がかかってしまう可能性があります。なぜなら、
Nat → Bool
型を持つ2つの関数を比較するにはすべての各Nat
に対して関数が同じBool
を返すかどうかをチェックする必要があるからです。言い換えれば、無限の型からの関数はそれ自体が無限です。関数は表として見ることができ、関数の引数の型が無限である場合はそれぞれのケースを表すために無限の行が必要になります。しかし有限の型からの関数はその表の行として有限個しか必要とせず、関数自体も有限となります。引数の型が有限である2つの関数は、すべての可能な引数を列挙し、それぞれの引数に対して関数を呼び出してその結果を比較することで関数同士が等しいかどうかをチェックすることができます。高階関数の同値チェックには与えられた型であるようなすべての関数を生成する必要があり、さらに引数の型の各要素を戻り値の各要素に写せるように戻り値の型が有限である必要があります。これは 速い 方法ではありませんが、有限時間で完了します。
有限の型を表現する1つの方法としてユニバースを使うものがあります:
inductive Finite where | unit : Finite | bool : Finite | pair : Finite → Finite → Finite | arr : Finite → Finite → Finite abbrev Finite.asType : Finite → Type | .unit => Unit | .bool => Bool | .pair t1 t2 => asType t1 × asType t2 | .arr t1 t2 => asType t1 → asType t2
このユニバースでは関数型が矢印(
arr
ow)で書かれることから、コンストラクタarr
が関数型を表しています。このユニバースの2つの値の同値性を確かめるのは
NestedPairs
ユニバースの場合とほとんど同じです。唯一の重要な違いはarr
のケースが追加されていることです。このケースではFinite.enumerate
という補助関数を使用してt1
でコード化された型からすべての値を生成し、これによって2つの関数がすべての可能な入力に対して等しい結果を返すことをチェックします:def Finite.beq (t : Finite) (x y : t.asType) : Bool := match t with | .unit => true | .bool => x == y | .pair t1 t2 => beq t1 x.fst y.fst && beq t2 x.snd y.snd | .arr t1 t2 => t1.enumerate.all fun arg => beq t2 (x arg) (y arg)
標準ライブラリにある関数
List.all
は与えられた関数がリストのすべての要素でtrue
を返すかどうかをチェックします。この関数は真偽値の関数の同値性を確かめるために使用できます:#eval Finite.beq (.arr .bool .bool) (fun _ => true) (fun b => b == b)
true
また標準ライブラリの関数を比較するのにも使えます:
#eval Finite.beq (.arr .bool .bool) (fun _ => true) not
false
関数合成などのツールを使って作られた関数でさえも比較することができます:
#eval Finite.beq (.arr .bool .bool) id (not ∘ not)
true
これは
Finite
ユニバースのコードがライブラリによって作成される特別に用意された類似物などではなく、Leanの 実際の 関数型を示すしているからです。enumerate
の実装もFinite
のコードに対する再帰です。def Finite.enumerate (t : Finite) : List t.asType := match t with | .unit => [()] | .bool => [true, false] | .pair t1 t2 => t1.enumerate.product t2.enumerate | .arr t1 t2 => t1.functions t2.enumerate
Unit
の場合、返される値は1つだけです。Bool
の場合、返される値は2つ(true
とfalse
)です。ペアの場合、戻り値はt1
でコード化された型の値とt2
でコード化された型の値のデカルト積になります。言い換えると、t1
のすべての値はt2
のすべての値をペアになります。補助関数List.product
は普通の再帰関数で書くこともできますが、ここでは恒等モナドのfor
を使って定義しています:def List.product (xs : List α) (ys : List β) : List (α × β) := Id.run do let mut out : List (α × β) := [] for x in xs do for y in ys do out := (x, y) :: out pure out.reverse
最後に、関数に対する
Finite.enumerate
のケースは、対象のすべての戻り値のリストを引数として受け取るFinite.functions
という補助関数に委譲されます。一般的に、ある有限の型から結果の値へのコレクションの関数をすべて生成することは、関数の表を生成することとして考えることができます。各関数は各入力に出力を割り当てるため、ある与えられた関数の引数が \( k \) 個の値がありうる場合、 \( k \) 行を表に持つことになります。表の各行は \( n \) 個の出力のいずれかを選択できるため、生成されうる関数は \( n ^ k \) 個になります。
繰り返しになりますが、有限の型から値へのリストへの関数の生成は、有限の型を記述するコードに対して再帰的に行われます:
def Finite.functions (t : Finite) (results : List α) : List (t.asType → α) := match t with
Unit
からの関数の表は1行です。これは関数がどの入力が与えられるかによって異なる結果を選ぶことができないからです。つまり、1つの入力に対して1つの関数が生成されます。| .unit => results.map fun r => fun () => r
戻り値が \( n \) 個ある場合、
Bool
からの関数は \( n^2 \) 個存在します。これはBool → α
型の各関数がBool
を使って特定の2つのα
を選択するからです:| .bool => (results.product results).map fun (r1, r2) => fun | true => r1 | false => r2
ペアからの関数を生成するにはカリー化を利用します。ペアからの関数はペアの最初の要素を受け取り、ペアの2番目の要素を待つ関数を返す関数に変換できます。こうすることで
Finite.functions
を再帰的に使うことができます:| .pair t1 t2 => let f1s := t1.functions <| t2.functions results f1s.map fun f => fun (x, y) => f x y
高階関数の生成はちょっと頭を使います。各高階関数は関数を引数に取ります。この引数の関数はその入出力の挙動に基づいて他の関数と区別することができます。一般的に、高階関数は引数の関数をあらゆる引数に適用することができ、その適用結果に基づいてあらゆる挙動を実行することができます。このことから高階関数の構成手段が示唆されます:
- 関数の引数として考えられるすべての引数のリストから始めます。
- 各ありうる引数について、それらに引数の関数を適用することの観察から生じる可能な挙動すべてを構築します。これは
Finite.functions
と残りの可能な引数に対する再帰を使用して行うことができます。というのも再帰の結果は残りの可能な引数の関数に基づく関数を表すからです。 - これらの観察に応答する潜在的な挙動に対して、引数の関数を現在の可能な引数に適用する高階関数を構築します。そしてその結果を観察の挙動に渡します。
- 再帰のベースのケースは各結果の値に対して何も観察しない高階関数です。これは引数関数を無視し、ただ結果の値を返します。
この再帰関数を直接定義しようとすると、Leanは関数全体が終了することを証明できません。しかし、右畳み込み (right fold)と呼ばれるより単純な再帰の形式を使用することで、関数が終了することを終了チェッカに明確に伝えることができます。右畳み込みは3つの引数を受け取ります:すなわち、リストの先頭と末尾の再帰結果を結合するステップ関数、リストが空の時に返すデフォルト値、そして処理対象のリストです。この処理は本質的にはリストを解析し、リストの各
::
をステップ関数の呼び出しに置き換え、[]
をデフォルト値に置き換えます:def List.foldr (f : α → β → β) (default : β) : List α → β | [] => default | a :: l => f a (foldr f default l)
リストの
Nat
の合計を求めるにはfoldr
を使用します:[1, 2, 3, 4, 5].foldr (· + ·) 0 ===> (1 :: 2 :: 3 :: 4 :: 5 :: []).foldr (· + ·) 0 ===> (1 + 2 + 3 + 4 + 5 + 0) ===> 15
foldr
を使うことで、高階関数は次のように作ることができます:| .arr t1 t2 => let args := t1.enumerate let base := results.map fun r => fun _ => r args.foldr (fun arg rest => (t2.functions rest).map fun more => fun f => more (f arg) f) base
Finite.functions
の完全な定義は以下の通りです:def Finite.functions (t : Finite) (results : List α) : List (t.asType → α) := match t with | .unit => results.map fun r => fun () => r | .bool => (results.product results).map fun (r1, r2) => fun | true => r1 | false => r2 | .pair t1 t2 => let f1s := t1.functions <| t2.functions results f1s.map fun f => fun (x, y) => f x y | .arr t1 t2 => let args := t1.enumerate let base := results.map fun r => fun _ => r args.foldr (fun arg rest => (t2.functions rest).map fun more => fun f => more (f arg) f) base
Finite.enumerate
とFinite.functions
がお互いに呼び合っているため、これらはmutual
ブロック内で定義しなければなりません。つまり、Finite.enumerate
の定義の直前にmutual
キーワードが置かれます:mutual def Finite.enumerate (t : Finite) : List t.asType := match t with
そして
Finite.functions
の定義の直後にend
キーワードが置かれます:| .arr t1 t2 => let args := t1.enumerate let base := results.map fun r => fun _ => r args.foldr (fun arg rest => (t2.functions rest).map fun more => fun f => more (f arg) f) base end
比較関数についてのこのアルゴリズムは特別実用的というようなものではありません。というのもチェックのためのケースは指数関数的に増大するからです;
((Bool × Bool) → Bool) → Bool
のような単純な型でさえ 65536 個の異なる関数を記述します。なぜこんなにたくさんになるのでしょうか?上記で行った推論および型 \( T \) で記述される値の数の表現として \( \left| T \right| \) を用いると、\[ \left| \left( \left( \mathtt{Bool} \times \mathtt{Bool} \right) \rightarrow \mathtt{Bool} \right) \rightarrow \mathtt{Bool} \right| \]
は
\[ \left|\mathrm{Bool}\right|^{\left| \left( \mathtt{Bool} \times \mathtt{Bool} \right) \rightarrow \mathtt{Bool} \right| }, \]
であり、これは
\[ 2^{2^{\left| \mathtt{Bool} \times \mathtt{Bool} \right| }}, \]
となり、これはさらに
\[ 2^{2^4} \]
もしくは65536となると予想されます。入れ子になった指数は急速に大きくなり、結果として多くの高階関数が存在することになります。
演習問題
Finite
でコード化された型の任意の値を文字列に変換する関数を書いてください。関数はそれらの表として表現されなければなりません。- 空の型
Empty
をFinite
とFinite.beq
に追加してください。 Option
をFinite
とFinite.beq
に追加してください。
使用事例:型付きクエリ
添字族はほかの言語に似せたAPIを構築する際に非常に有用です。無効なHTMLの生成を許可しないHTML構築のライブラリを記述したり、設定ファイル形式の特定のルールをエンコードしたり、複雑なビジネス制約をモデル化したりするのに使用できます。この節では、このテクニックがより強力なデータベースクエリ言語を構築するために使えることの簡単なデモンストレーションとして、添字族を使用したLeanにおける関係代数のサブセットのエンコードについて説明します。
このサブセットではテーブル間で同じフィールド名が無いことなどの要件を強制するために型システムを使用し、クエリから返される値の型にスキーマを反映させるために型レベルの計算を使用します。しかし、これは現実的なシステムではありません。データベースは連結リストの連結リストとして表現され、型システムはSQLのものよりずっと単純であり、関係代数の演算子はSQLの演算子と一致しません。しかし、有用な原理やテクニックを示すには十分な規模です。
データのユニバース
この関係代数では、カラムに保持できる基本データは
Int
、String
、Bool
型でDBType
というユニバースで記述されます:inductive DBType where | int | string | bool abbrev DBType.asType : DBType → Type | .int => Int | .string => String | .bool => Bool
asType
を使用すると、これらのコードを型として使用することができます。例えば:#eval ("Mount Hood" : DBType.string.asType)
"Mount Hood"
3つのデータベースの型はどれで記述された値であっても同値性を比較することは可能です。しかし、このことをLeanに説明するには少し手間がかかります。
BEq
を直接使うだけでは失敗します:def DBType.beq (t : DBType) (x y : t.asType) : Bool := x == y
failed to synthesize instance BEq (asType t)
入れ子になったペアのユニバースと同じように、型クラスの検索では
t
の値の可能性を自動的にチェックすることができません。解決策は、パターンマッチを使ってx
とy
の型を絞り込むことです:def DBType.beq (t : DBType) (x y : t.asType) : Bool := match t with | .int => x == y | .string => x == y | .bool => x == y
このバージョンの関数では、
x
とy
はそれぞれInt
・String
・Bool
型を持ち、これらの型すべてがBEq
インスタンスを持っています。dbEq
の定義では、DBType
でコード化された型に対してBEq
インスタンスを定義することができます:instance {t : DBType} : BEq t.asType where beq := t.beq
これはコードによるインスタンスとは異なります:
instance : BEq DBType where beq | .int, .int => true | .string, .string => true | .bool, .bool => true | _, _ => false
前者のインスタンスはコードによって記述された型から引き出された値の比較をしている一方で、後者はコード自体の比較しています。
同じ手法で
Repr
インスタンスも書くことができます。Repr
クラスのメソッドは値を表示する際に演算子の優先順位なども考慮にいれるよう設計されていることからreprPrec
と呼ばれます。依存パターンマッチによって型を絞り込むことで、Int
・String
・Bool
のRepr
インスタンスからreprPrec
メソッドを利用することができます:instance {t : DBType} : Repr t.asType where reprPrec := match t with | .int => reprPrec | .string => reprPrec | .bool => reprPrec
スキーマとテーブル
スキーマはデータベースの各カラムの名前と型を記述します:
structure Column where name : String contains : DBType abbrev Schema := List Column
実は、スキーマはテーブルの行を記述するユニバースと見なすことができます。空のスキーマはユニットタイプを、1つのカラムを持つスキーマはその値単独を、少なくとも2つのカラムを持つスキーマはタプルで表現されます:
abbrev Row : Schema → Type | [] => Unit | [col] => col.contains.asType | col1 :: col2 :: cols => col1.contains.asType × Row (col2::cols)
直積型についての最初の節 で説明したように、Leanの直積型とタプルは右結合です。つまり、入れ子になったペアは通常のフラットなタプルと等価です。
テーブルはスキーマを共有する行のリストです:
abbrev Table (s : Schema) := List (Row s)
例えば、山頂の旅日記はスキーマ
peak
で表現することができます:abbrev peak : Schema := [ ⟨"name", DBType.string⟩, ⟨"location", DBType.string⟩, ⟨"elevation", DBType.int⟩, ⟨"lastVisited", .int⟩ ]
本書の著者が訪れた山頂セレクションは通常のタプルのリストとして表示されます:
def mountainDiary : Table peak := [ ("Mount Nebo", "USA", 3637, 2013), ("Moscow Mountain", "USA", 1519, 2015), ("Himmelbjerget", "Denmark", 147, 2004), ("Mount St. Helens", "USA", 2549, 2010) ]
別の例は滝とその旅日記です:
abbrev waterfall : Schema := [ ⟨"name", .string⟩, ⟨"location", .string⟩, ⟨"lastVisited", .int⟩ ] def waterfallDiary : Table waterfall := [ ("Multnomah Falls", "USA", 2018), ("Shoshone Falls", "USA", 2014) ]
再訪:再帰とユニバースについて
行をタプルを使って便利に構造化することには次のような代償が伴います:すなわち
Row
が2つの基本ケースを別々に扱うということは、型にRow
を使用しコード(つまりスキーマ)に対して再帰的に定義される関数も同じように区別する必要があるということです。このことが問題になるケースの一例として、スキーマに対する再帰を使用して行が等しいかどうかをチェックする関数を定義する同値チェックが挙げられます。以下の例はLeanの型チェッカを通過しません:def Row.bEq (r1 r2 : Row s) : Bool := match s with | [] => true | col::cols => match r1, r2 with | (v1, r1'), (v2, r2') => v1 == v2 && bEq r1' r2'
type mismatch (v1, r1') has type ?m.6559 × ?m.6562 : Type (max ?u.6571 ?u.6570) but is expected to have type Row (col :: cols) : Type
問題はパターン
col :: cols
が行の型を十分に絞り込めないことです。これはLeanがRow
の定義にある要素が1つのパターン[col]
とcol1 :: col2 :: cols
のどちらがマッチしたかを判断できないためで、Row
の呼び出しはペアの型まで計算されません。解決策はRow.bEq
の定義におけるRow
の構造を鏡写しにすることです:def Row.bEq (r1 r2 : Row s) : Bool := match s with | [] => true | [_] => r1 == r2 | _::_::_ => match r1, r2 with | (v1, r1'), (v2, r2') => v1 == v2 && bEq r1' r2' instance : BEq (Row s) where beq := Row.bEq
別の文脈とは異なり、型の中に出現する関数はその入出力の挙動だけから考察することはできません。このような型を使用するプログラムは、その構造が型のパターンマッチや再帰的な挙動と一致させるために型レベルの関数で使用されるアルゴリズムをそのまま映すことを強制されます。依存型を使ったプログラミングのスキルの大部分は適切な計算動作を持つ適切な型レベル関数を選定することが占めています。
カラムへのポインタ
クエリの中にはスキーマが特定のカラムを含んでいる場合にのみ意味を為すものがあります。例えば、標高が1000mを超える山を返すクエリは、整数からなるカラム
"elevation"
を持つスキーマでのみ意味を持ちます。あるカラムがスキーマに含まれていることを示す1つの方法は、そのカラムへのポインタを直接提供し、無効なポインタを除外するような添字族としてポインタを定義することです。あるカラムがスキーマに存在するには2つの方法があります:スキーマの先頭にあるか、その後続に存在するかです。結果的にカラムがスキーマの後続にある場合、それはスキーマの後続リストの先頭になります。
HasCol
添字族はこの仕様をLeanの実装に翻訳したものです:inductive HasCol : Schema → String → DBType → Type where | here : HasCol (⟨name, t⟩ :: _) name t | there : HasCol s name t → HasCol (_ :: s) name t
この族の3つの引数はスキーマ、カラム名、そしてその型です。3つとも添字ですが、引数を列名と型の後にスキーマが来るように並べ替えると、名前と型をパラメータにすることができます。コンストラクタ
here
はスキーマがカラム⟨name, t⟩
から始まっている場合に使用できます;つまりこれはスキーマの最初のカラムへのポインタであり、最初のカラムが期待する名前と型を持つ場合にのみ使用できます。コンストラクタthere
はある小さいスキーマへのポインタを、このスキーマに1つカラムを追加したスキーマへのポインタに変換します。"elevation"
はpeak
の3番目のカラムであるため、最初の2カラムをthere
で通過することでこのカラムが先頭のカラムとなることで発見できます。言い換えるとHasCol peak "elevation" .int
という型を満たすには、.there (.there .here)
という式を使用します。HasCol
は装飾された一種のNat
と考えることができるでしょう。zero
はhere
、succ
はthere
にそれぞれ対応します。型情報が追加されたことで、off-by-oneエラーは発生しなくなります。あるスキーマの特定のカラムへのポインタによってそのカラムの値を行から抽出することができます:
def Row.get (row : Row s) (col : HasCol s n t) : t.asType := match s, col, row with | [_], .here, v => v | _::_::_, .here, (v, _) => v | _::_::_, .there next, (_, r) => get r next
最初のステップはスキーマのパターンマッチです。というのも、これによって行がタプルか単一の値であるかが決定されるからです。
HasCol
が利用可能であり、HasCol
のコンストラクタはどちらも空でないスキーマを指定しているため、空のスキーマに対するケースは不要です。もしスキーマにカラムが1つしかない場合、ポインタはそのカラムを指す必要があるため、HasCol
のhere
コンストラクタのみをマッチさせる必要があります。スキーマに2つ以上列がある場合は、値が行の先頭にいる場合であるhere
のケースと、再帰呼び出しが用いられるthere
のケースが必要です。HasCol
型はそのカラムが行に存在することを保証しているため、Row.get
はOption
を返す必要はありません。HasCol
は2つの役割を演じています:- 特定の名前と型のカラムがスキーマに存在するという 根拠 としての機能。
- そのカラムに紐づく値を行から探すために用いられる データ としての機能。
1つ目の役割である根拠は命題の使われ方と似ています。添字族
HasCol
の定義は与えられたカラムが存在する根拠としての要点の指定として読むことができます。しかし、命題とは異なり、HasCol
のどのコンストラクタが使われたかは重要です。2つ目の役割として、コンストラクタはNat
のようにコレクション内のデータを見つけるために使用されます。添字族を使用したプログラミングでは、両方の視点を流暢に切り替える能力が必要である場合がよくあります。副スキーマ
関係代数における重要な操作の1つとして、テーブルや行をより小さなスキーマにする 射影 (projection)があります。この小さくなったスキーマに含まれないすべてのカラムは忘れ去られます。射影が意味を持つためには、小さくなったスキーマは大きいスキーマの副スキーマでなければなりません。副スキーマとは小さくなったスキーマのすべてのカラムが大きいスキーマに存在していることを指します。
HasCol
によって失敗することのない行からの単一カラムの検索を書くことができるように、副スキーマの関係を添字族として表現することで失敗することのない射影関数を書くことができます。あるスキーマが別のスキーマの副スキーマになる方法は、添字族として定義できます。基本的な考え方は、小さい方のスキーマのカラムがすべて大きい方に含まれている場合に小さい方が大きい方の副スキーマであるというものです。もし小さい方のスキーマが空であれば、これは確実に大きい方の副スキーマとなります。これをコンストラクタ
nil
で表現します。もし小さい方のスキーマにカラムがある場合、そのカラムが大きい方に存在し、かつそれを除いたすべてのカラムからなる副スキーマも大きい方の副スキーマでなければなりません。これはコンストラクタcons
で表現されます。inductive Subschema : Schema → Schema → Type where | nil : Subschema [] bigger | cons : HasCol bigger n t → Subschema smaller bigger → Subschema (⟨n, t⟩ :: smaller) bigger
言い換えると、
Subschema
は小さい方のスキーマの各カラムに大きい方のスキーマでの位置を表すHasCol
を割り当てます。スキーマ
travelDiary
はpeak
とwaterfall
の両方に共通するフィールドを表します:abbrev travelDiary : Schema := [⟨"name", .string⟩, ⟨"location", .string⟩, ⟨"lastVisited", .int⟩]
これは明らかに
peak
の副スキーマで、次の例のように示されます:example : Subschema travelDiary peak := .cons .here (.cons (.there .here) (.cons (.there (.there (.there .here))) .nil))
しかし、このようなコードは読みにくく、保守も難しいです。これを改善する一つの方法は、
Subschema
とHasCol
のコンストラクタを自動的に書くようにLeanに指示することです。これは 命題と証明に関する幕間 で紹介したタクティク機能を使って行うことができます。その幕間ではby simp
を使って様々な命題の根拠を提供しました。今回の文脈では、2つのタクティクが有効です:
constructor
タクティクは、データ型のコンストラクタを使って問題を解決するようLeanに指示します。repeat
タクティクは受け取ったタクティクを失敗するか証明が完了するまで何度も繰り返すよう指示します。
次の例では、
by constructor
は.nil
と書くのと同じ効果があります:example : Subschema [] peak := by constructor
しかし、ちょっとでも複雑なもので同じタクティクを試すと失敗します:
example : Subschema [⟨"location", .string⟩] peak := by constructor
unsolved goals case a ⊢ HasCol peak "location" DBType.string case a ⊢ Subschema [] peak
unsolved goals
から始まるエラーはタクティクが想定した式を完全に構築できなかったことを表します。Leanのタクティク言語では、ゴール (goal)はタクティクが裏で適切な式を構築することで満たすべき型を指します。この場合、constructor
によってSubschema.cons
が適用とcons
が期待する2つの引数を表す2つのゴールが発生します。もう1つconstructor
のインスタンスを追加すると、最初のゴール(HasCol peak \"location\" DBType.string
)はpeak
の最初のカラムが"location"
ではないことからHasCol.there
で処理されます:example : Subschema [⟨"location", .string⟩] peak := by constructor constructor
unsolved goals case a.a ⊢ HasCol [{ name := "location", contains := DBType.string }, { name := "elevation", contains := DBType.int }, { name := "lastVisited", contains := DBType.int }] "location" DBType.string case a ⊢ Subschema [] peak
しかし、3つ目の
constructor
を追加すると、HasCol.here
が適用されるため、1つ目のゴールが解決されます:example : Subschema [⟨"location", .string⟩] peak := by constructor constructor constructor
unsolved goals case a ⊢ Subschema [] peak
4つ目の
constructor
のインスタンスによってゴールSubschema peak []
が解決されます:example : Subschema [⟨"location", .string⟩] peak := by constructor constructor constructor constructor
実際に、タクティクを使わずに書いたものにも4つのコンストラクタが存在します:
example : Subschema [⟨"location", .string⟩] peak := .cons (.there .here) .nil
constructor
の適切な書く回数を試して見つける代わりに、repeat
タクティクを使うことでconstructor
が機能しつづける限り試し続けるようにLeanに依頼することができます:example : Subschema [⟨"location", .string⟩] peak := by repeat constructor
この柔軟なバージョンはより興味のある
Subschema
の問題にも対応します:example : Subschema travelDiary peak := by repeat constructor example : Subschema travelDiary waterfall := by repeat constructor
うまくいくまでやみくもにコンストラクタを試すアプローチは
Nat
やList Bool
のような型にはあまり役に立ちません。これは式がNat
型を持っているからと言っても、結局のところそれが 正しいNat
であるとは限らないからです。しかしHasCol
やSubschema
のような型では添字によって十分制約されているため、コンストラクタはただ1つしか適用できません。これはそのプログラムの中身自体にはあまり面白みがなく、コンピュータは正しいものを選ぶことができるということです。あるスキーマが別のスキーマの副スキーマである場合、大きい方のスキーマにカラムを追加して拡張したより大きなスキーマの副スキーマでもあります。この事実は関数定義として捉えることができます。
Subschema.addColumn
はsmaller
がbigger
の副スキーマである根拠を取り、smaller
がc :: bigger
、すなわちカラムが追加されたbigger
の副スキーマであるという根拠を返します:def Subschema.addColumn (sub : Subschema smaller bigger) : Subschema smaller (c :: bigger) := match sub with | .nil => .nil | .cons col sub' => .cons (.there col) sub'.addColumn
副スキーマは小さい方のスキーマの各カラムが大きい方のどこにあるかを記述します。
Subschema.addColumn
はこれらの記述をもとの大きいスキーマから拡張されたスキーマへ変換しなければなりません。nil
の場合、小さい方のスキーマは[]
であり、またnil
は[]
がc :: bigger
の副スキーマである根拠でもあります。cons
、つまりsmaller
のあるカラムをbigger
に配置する方法を記述したケースの場合、そのカラムを配置するには新しいカラムc
を考慮するためにthere
でカラムの位置を調節する必要があります。そして再帰呼び出しで残りのカラムを調整します。Subschema
の別の考え方はこれが2つのスキーマの 関係 を定義しているというものです。つまりSubschema bigger smaller
型の式が存在するということは、(bigger, smaller)
がその関係にあるということです。この関係は反射的であり、すべてのスキーマはそれ自身の副スキーマであることを意味します:def Subschema.reflexive : (s : Schema) → Subschema s s | [] => .nil | _ :: cs => .cons .here (reflexive cs).addColumn
行の射影
s'
がs
の副スキーマであるという根拠をもとに、s
の行をs'
の行に射影することができます。これはs'
がs
の副スキーマであるという根拠を用いて行われ、s'
の各列がs
のどこにあるかを説明します。s'
の新しい行は、古い行の適切な位置から値を取り出すことで1列ずつ構築されます。この射影を行う関数
Row.project
には3つの場合分けが存在しており、それぞれRow
自体のケースに対応しています。Row.get
とSubschema
の引数の各HasCol
を使用して射影された行を構築します:def Row.project (row : Row s) : (s' : Schema) → Subschema s' s → Row s' | [], .nil => () | [_], .cons c .nil => row.get c | _::_::_, .cons c cs => (row.get c, row.project _ cs)
条件と選択
射影はテーブルから不要な列を除外しますが、クエリでは不要な行も除外できなければなりません。この操作は 選択 (selection)と呼ばれます。選択はどの行が必要かを表現する手段があることが前提になります。
今回のクエリ言語の例ではSQLの
WHERE
節で記述するものと同じような式を持ちます。式は添字族DBExpr
で表現されます。式はデータベースのカラムを参照することができますが、式内の異なる部分式はすべて同じスキーマを持つため、DBExpr
はスキーマをパラメータとして受け取ります。さらに、各式には型があり、それが変化することで添字になります:inductive DBExpr (s : Schema) : DBType → Type where | col (n : String) (loc : HasCol s n t) : DBExpr s t | eq (e1 e2 : DBExpr s t) : DBExpr s .bool | lt (e1 e2 : DBExpr s .int) : DBExpr s .bool | and (e1 e2 : DBExpr s .bool) : DBExpr s .bool | const : t.asType → DBExpr s t
col
コンストラクタはデータベースのカラムへの参照を表します。eq
コンストラクタは2つの式の同値を確かめ、lt
は片方の式がもう片方未満であることをチェック、and
は論理積、const
は何らかの型の定数値を表します。例えば
peak
の式として、elevation
カラムが1000より大きく、かつその場所が"Denmark"
であることをチェックするものは次のように書けます:def tallInDenmark : DBExpr peak .bool := .and (.lt (.const 1000) (.col "elevation" (by repeat constructor))) (.eq (.col "location" (by repeat constructor)) (.const "Denmark"))
これはやや煩雑です。特に、カラムへの参照に
by repeat constructor
の定型的な呼び出しが含まれています。マクロ (macro)と呼ばれるLeanの機能によってこのような定型文を排除することで式を読みやすくしてくれます:macro "c!" n:term : term => `(DBExpr.col $n (by repeat constructor))
この宣言は
c!
というキーワードをLeanに追加し、c!
のインスタンスに続く式を、対応するDBExpr.col
構文で置き換えるように指示しています。ここで、term
はLeanの式を表しており、コマンドやタクティクなどの言語の一部を表しているわけではありません。LeanのマクロはC言語のプリプロセッサマクロ(CPP)に少し似ていますが、言語への統合が進んでおり、CPPの落とし穴のいくつかを自動的に避けることができます。実は、このマクロはSchemeやRacketのマクロと非常に密接な関係があります。このマクロを使うと、式はもっと読みやすくなります:
def tallInDenmark : DBExpr peak .bool := .and (.lt (.const 1000) (c! "elevation")) (.eq (c! "location") (.const "Denmark"))
与えられた行に対応する式の値を見つけるには、
Row.get
を使用してカラム参照を抽出し、他のすべての式の値に関するLeanの操作に委譲します:def DBExpr.evaluate (row : Row s) : DBExpr s t → t.asType | .col _ loc => row.get loc | .eq e1 e2 => evaluate row e1 == evaluate row e2 | .lt e1 e2 => evaluate row e1 < evaluate row e2 | .and e1 e2 => evaluate row e1 && evaluate row e2 | .const v => v
コペンハーゲン地域で最も高い丘であるValby Bakkeについての式を評価すると、Valby Bakkeの標高は海抜1km未満であるため
false
が返されます:#eval tallInDenmark.evaluate ("Valby Bakke", "Denmark", 31, 2023)
false
これを標高1230mの架空の山で評価すると
true
が返されます:#eval tallInDenmark.evaluate ("Fictional mountain", "Denmark", 1230, 2023)
true
アメリカのアイダホ州の最高峰で評価すると、アイダホはデンマークの一部ではないため、
false
が返されます:#eval tallInDenmark.evaluate ("Mount Borah", "USA", 3859, 1996)
false
クエリ
クエリ言語は関係代数に基づいています。テーブルに加え、以下の演算子があります:
- 2つのクエリの結果を結合する2つの式の和
- 同じスキーマを持つ2つの式において、最初の結果から2番目の結果の行を削除する差
- 何らかの基準で式に従ってクエリの結果をフィルタリングする選択
- クエリの結果からカラムを取り除く副スキーマへの射影
- 直積、あるクエリのすべての行と別のクエリのすべての行を結合します
- クエリ結果のカラム名の属性名変更、これはカラムのスキーマを変更します
- クエリ内のすべてのカラムに名前を前置する
厳密には最後の演算子は必要ではありませんが、言語をより便利に使うことができます。
繰り返しになりますが、クエリは添字族で表現されます:
inductive Query : Schema → Type where | table : Table s → Query s | union : Query s → Query s → Query s | diff : Query s → Query s → Query s | select : Query s → DBExpr s .bool → Query s | project : Query s → (s' : Schema) → Subschema s' s → Query s' | product : Query s1 → Query s2 → disjoint (s1.map Column.name) (s2.map Column.name) → Query (s1 ++ s2) | renameColumn : Query s → (c : HasCol s n t) → (n' : String) → !((s.map Column.name).contains n') → Query (s.renameColumn c n') | prefixWith : (n : String) → Query s → Query (s.map fun c => {c with name := n ++ "." ++ c.name})
select
コンストラクタでは、選択に使用する式がブール値を返す必要があります。product
コンストラクタの型にはdisjoint
が含まれており、これにより2つのスキーマが同じ名前を持たないことが保証されます:def disjoint [BEq α] (xs ys : List α) : Bool := not (xs.any ys.contains || ys.any xs.contains)
型が期待されるところで
Bool
型の式を使用すると、Bool
からProp
への強制が発火します。命題の根拠がtrue
に、命題の反論がfalse
にそれぞれ強制されることから決定可能な命題を真偽値と見なすことができるように、真偽値はその式がtrue
に等しいことを述べる命題へと強制されます。このライブラリのすべての使用ケースはスキーマがあらかじめ分かっているコンテキストにおいて発生すると考えられるため、この命題はby simp
で証明することができます。同様に、renameColumn
コンストラクタは変更予定の新しい名前がスキーマにすでに存在しないことをチェックします。ここでは補助関数Schema.renameColumn
を使用して、HasCol
が指すカラムの名前を変更します:def Schema.renameColumn : (s : Schema) → HasCol s n t → String → Schema | c :: cs, .here, n' => {c with name := n'} :: cs | c :: cs, .there next, n' => c :: renameColumn cs next n'
クエリの実行
クエリを実行するにはいくつかの補助関数が必要です。クエリの結果はテーブルです;これはつまりクエリ言語の各操作にはテーブルに対応した実装が必要だということです。
直積
2つのテーブルの直積を取るには、1つ目のテーブルの各行を2つ目のテーブルの各行に追加します。まず、
Row
の構造上、行に1つのカラムを追加する場合にスキーマに対して追加結果が素の値かタプルであるかを決定するためのパターンマッチが必要になります。これは一般的な操作であるため、パターンマッチを補助関数にまとめると便利です:def addVal (v : c.contains.asType) (row : Row s) : Row (c :: s) := match s, row with | [], () => v | c' :: cs, v' => (v, v')
2つの行を追加するには1つ目のスキーマと1つ目の行の構造に対しての再帰が必要です。というのも、行の構造はスキーマの構造と同期して進むからです。最初の行が空の場合、追加結果として2つ目の行が返されます。最初の行が単一の値の場合、その値が2つ目の行に追加されます。最初の行が複数の列を含む場合、最初の列の値は、残りの行に対する再帰の結果に追加されます。
def Row.append (r1 : Row s1) (r2 : Row s2) : Row (s1 ++ s2) := match s1, r1 with | [], () => r2 | [_], v => addVal v r2 | _::_::_, (v, r') => (v, r'.append r2)
List.flatMap
は入力リストの各要素に対してリストを返す関数を適用し、その結果のリストを順番に追加した結果を返します:def List.flatMap (f : α → List β) : (xs : List α) → List β | [] => [] | x :: xs => f x ++ xs.flatMap f
この型シグネチャは
List.flatMap
がMonad List
のインスタンスを実装するのに使えることを示唆しています。実際に、pure x := [x]
と共にList.flatMap
でモナドが実装されています。しかし、これはあまり便利なMonad
インスタンスではありません。List
モナドは基本的にMany
の亜種であり、ユーザがいくつかの値を要求する前に、探索空間を通して可能な すべて のパスを探索します。このようなパフォーマンスの罠があるため、通常List
用のMonad
インスタンスを定義するのは良い考えとは言えません。しかし、ここではクエリ言語には返される結果の数を制限する演算子がないため、すべての可能性を組み合わせることが望まれます:def Table.cartesianProduct (table1 : Table s1) (table2 : Table s2) : Table (s1 ++ s2) := table1.flatMap fun r1 => table2.map r1.append
List.product
と同じように、別の実装方法として恒等モナドで変更を伴うループを使うことができます:def Table.cartesianProduct (table1 : Table s1) (table2 : Table s2) : Table (s1 ++ s2) := Id.run do let mut out : Table (s1 ++ s2) := [] for r1 in table1 do for r2 in table2 do out := (r1.append r2) :: out pure out.reverse
差
テーブルから不要な行を除外するには、リストと
Bool
を返す関数を受け取るList.filter
を使って行えます。これによって関数がtrue
を返す要素のみを含む新しいリストが返されます。例えば、["Willamette", "Columbia", "Sandy", "Deschutes"].filter (·.length > 8)
は以下のように評価されます。
["Willamette", "Deschutes"]
これは
"Columbia"
と"Sandy"
の長さが8
以下であるからです。テーブルの要素を除外するには補助関数List.without
を使います:def List.without [BEq α] (source banned : List α) : List α := source.filter fun r => !(banned.contains r)
クエリを解釈する際に、これは
Row
に対するBEq
インスタンスと共に使用されます。属性名変更
行の属性名変更は再帰関数で行われ、該当のカラムが見つかるまで行を走査し、その時点で新しい名前のカラムは古い名前のカラムと同じ値になります:
def Row.rename (c : HasCol s n t) (row : Row s) : Row (s.renameColumn c n') := match s, row, c with | [_], v, .here => v | _::_::_, (v, r), .here => (v, r) | _::_::_, (v, r), .there next => addVal v (r.rename next)
この関数は引数の 型 を変更しますが、実際の戻り値には元の引数とまったく同じデータを含みます。実行時においては、
renameRow
はただ遅いだけの恒等関数でしかありません。添字族を使用したプログラミングの難しさの1つは、パフォーマンスが重要な場合にこの種の操作が邪魔になることです。このような「再インデックス」関数を排除するには、とても注意深く、時に脆い設計が必要です。カラム名への接頭辞の付与
カラム名に接頭辞を追加することは、属性名変更と非常に似ています。
prefixRow
は希望するカラムのみ処理して戻るのではなく、すべてのカラムに対して処理しなければなりません:def prefixRow (row : Row s) : Row (s.map fun c => {c with name := n ++ "." ++ c.name}) := match s, row with | [], _ => () | [_], v => v | _::_::_, (v, r) => (v, prefixRow r)
これは
List.map
と一緒に使うことでテーブルのすべての行に接頭辞を追加することができます。繰り返しになりますが、この関数は値の型を変更するためだけに存在します。ピースをひとつにまとめる
これらの補助関数がすべて定義されたなら、クエリを実行するのに必要なのは短い再帰関数だけです:
def Query.exec : Query s → Table s | .table t => t | .union q1 q2 => exec q1 ++ exec q2 | .diff q1 q2 => exec q1 |>.without (exec q2) | .select q e => exec q |>.filter e.evaluate | .project q _ sub => exec q |>.map (·.project _ sub) | .product q1 q2 _ => exec q1 |>.cartesianProduct (exec q2) | .renameColumn q c _ _ => exec q |>.map (·.rename c) | .prefixWith _ q => exec q |>.map prefixRow
コンストラクタの引数の中には実行時に使用されないものもあります。特に、コンストラクタ
project
と関数Row.project
は小さい方のスキーマを明示的な引数として受け取りますが、このスキーマが大きい方の副スキーマであるという 根拠 の型にはLeanが自動的に引数を埋めるために十分な情報が含まれています。同様に、product
コンストラクタで必要とされる2つのテーブルが同じカラム名を持たないという事実はTable.cartesianProduct
では必要ありません。一般的に、依存型はLeanがプログラマの代わりに引数を埋めるための機会を多く提供します。ドット記法は
List.map
やList.filter
、Table.cartesianProduct
などのTable
とList
の両方の名前空間で定義された関数を呼び出すクエリの結果で使用されます。これはTable
がabbrev
を使って定義されているためです。型クラス検索と同じように、ドット記法はabbrev
で作成された定義を見抜くことができます。select
の実装も非常に簡潔です。クエリq
を実行した後、List.filter
を使用して式を満たさない行を除外します。フィルタにはRow s
からBool
への関数が期待されますが、DBExpr.evaluate
の型はRow s → DBExpr s t → t.asType
です。select
コンストラクタの型は式がDBExpr s .bool
型であることを要求するため、t.asType
はこのコンテキストではBool
となります。標高が500mを超えるすべての山の高さを求めるクエリは次のように書くことができます:
open Query in def example1 := table mountainDiary |>.select (.lt (.const 500) (c! "elevation")) |>.project [⟨"elevation", .int⟩] (by repeat constructor)
これを実行すると、期待通り整数のリストが返されます:
#eval example1.exec
[3637, 1519, 2549]
観光ツアーを計画するためには、ある山と滝のペアをすべて同じ場所に合わせることが妥当かもしれません。これは両方のテーブルの直積を取り、それらが等しい行だけを選び、名前を射影することで実現されます:
open Query in def example2 := let mountain := table mountainDiary |>.prefixWith "mountain" let waterfall := table waterfallDiary |>.prefixWith "waterfall" mountain.product waterfall (by simp) |>.select (.eq (c! "mountain.location") (c! "waterfall.location")) |>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩] (by repeat constructor)
例で挙げたデータにはアメリカの滝だけが含まれているため、クエリを実行するとアメリカの山と滝のペアが返されます:
#eval example2.exec
[("Mount Nebo", "Multnomah Falls"), ("Mount Nebo", "Shoshone Falls"), ("Moscow Mountain", "Multnomah Falls"), ("Moscow Mountain", "Shoshone Falls"), ("Mount St. Helens", "Multnomah Falls"), ("Mount St. Helens", "Shoshone Falls")]
見るかもしれないエラー
多くの潜在的なエラーは
Query
の定義によって除外されます。例えば、"mountain.location"
に追加された修飾子を忘れると、コンパイル時にエラーが発生し、c! "location"
がハイライト表示されます:open Query in def example2 := let mountains := table mountainDiary |>.prefixWith "mountain" let waterfalls := table waterfallDiary |>.prefixWith "waterfall" mountains.product waterfalls (by simp) |>.select (.eq (c! "location") (c! "waterfall.location")) |>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩] (by repeat constructor)
これは素晴らしいフィードバックです!一方でエラーメッセージの文面から対処法を決めるのはかなり難しいです:
unsolved goals case a.a.a.a.a.a.a mountains : Query (List.map (fun c => { name := "mountain" ++ "." ++ c.name, contains := c.contains }) peak) := prefixWith "mountain" (table mountainDiary) waterfalls : Query (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) waterfall) := prefixWith "waterfall" (table waterfallDiary) ⊢ HasCol (List.map (fun c => { name := "waterfall" ++ "." ++ c.name, contains := c.contains }) []) "location" ?m.109970
同じように、2つのテーブルの名前に接頭辞をつけ忘れると、スキーマに同じフィールド名が無いことの根拠を提供すべきところの
by simp
でエラーになります:open Query in def example2 := let mountains := table mountainDiary let waterfalls := table waterfallDiary mountains.product waterfalls (by simp) |>.select (.eq (c! "mountain.location") (c! "waterfall.location")) |>.project [⟨"mountain.name", .string⟩, ⟨"waterfall.name", .string⟩] (by repeat constructor)
しかし、このエラーメッセージも同じように役に立ちません:
unsolved goals mountains : Query peak := table mountainDiary waterfalls : Query waterfall := table waterfallDiary ⊢ False
Leanのマクロシステムには、クエリに便利な構文を提供するだけでなく、エラーメッセージが有用になるようアレンジするために必要なものもすべて含まれています。残念ながら、Leanマクロを使った言語の実装について説明するのは本書の範囲を超えています。
Query
のような添字族はユーザインタフェースというよりは、型付きデータベースの対話ライブラリのコアとして使うことがベストでしょう。演習問題
データ
日付を表す構造体を定義してください。それを
DBType
ユニバースに追加し、それに合わせて残りの実装を更新してください。必要と思われるDBType
のコンストラクタも追加してください。nullable型
次のような構造体でデータベースの型を表現することで、クエリ言語にnullableなカラムのサポートを追加してください:
structure NDBType where underlying : DBType nullable : Bool abbrev NDBType.asType (t : NDBType) : Type := if t.nullable then Option t.underlying.asType else t.underlying.asType
この型を
DBType
とColumn
の中のDBType
と置き換え、DBType
のコンストラクタの型を決定するためにNULL
と比較演算子に関するSQLのルールを探索してください。タクティクの実験
Leanに
by repeat constructor
を使って以下の型の値を求めるとどのような結果になるでしょうか?それぞれがなぜその結果になるのかを説明してください。Nat
List Nat
Vect Nat 4
Row []
Row [⟨"price", .int⟩]
Row peak
HasCol [⟨"price", .int⟩, ⟨"price", .int⟩] "price" .int
添字・パラメータ・宇宙レベル
帰納型の添字とパラメータの区別は、単にコンストラクタ間で型への引数が変化するか、もしくはしないかを記述する方法だけにとどまりません。帰納型の引数がパラメータか添字かは、それらの宇宙レベル間の関係を決定するときに重要になります。特に、帰納型はパラメータと同じ宇宙レベルを持つことが可能ですが、添字に対しては宇宙レベルは大きくなければなりません。この制約はLeanがプログラミング言語としてだけでなく、定理証明器としても使用できるようにするために必要です。こうしなければLeanの論理は一貫性が無くなることでしょう。型に対する引数がパラメータと添字のどちらにするべきかを決定する正確なルールと共に、これらのルールを説明するにはエラーメッセージを使って実験するのは良い方法です。
一般的に、帰納型の定義ではパラメータはコロンの前、添字はコロンの後に取られます。パラメータには関数の引数のように名前が与えられる一方、添字には型のみが記述されます。これは
Vect
の定義で見ることができます:inductive Vect (α : Type u) : Nat → Type u where | nil : Vect α 0 | cons : α → Vect α n → Vect α (n + 1)
この定義では、
α
はパラメータでNat
は添字です。パラメータは定義全体を通して参照することができますが(例えば、Vect.cons
は第一引数の型としてα
を使用しています)、それらは常に一貫使用される必要があります。添字は変更されることが期待されるため、データ型の定義の先頭で引数として提供されるのではなく、コンストラクタごとに個別の値が割り当てられます。パラメータを使った非常にシンプルなデータ型として次の
WithParameter
を考えます:inductive WithParameter (α : Type u) : Type u where | test : α → WithParameter α
宇宙レベル
u
は、パラメータと帰納型自体の両方に使用することができ、これはパラメータがデータ型の宇宙レベルを増大させないことを示します。同様に、複数のパラメータがある場合、帰納型はどちらか大きい方の宇宙レベルを受け取ります:inductive WithTwoParameters (α : Type u) (β : Type v) : Type (max u v) where | test : α → β → WithTwoParameters α β
パラメータはデータ型の宇宙レベルを増加させないため、より便利に扱うことができます。Leanは添字のように(コロンの後に)記述されるがパラメータのように用いられる引数を識別し、それらをパラメータに変えようとします:以下の帰納的データ型はどちらもコロンの後にパラメータが記述されています:
inductive WithParameterAfterColon : Type u → Type u where | test : α → WithParameterAfterColon α inductive WithParameterAfterColon2 : Type u → Type u where | test1 : α → WithParameterAfterColon2 α | test2 : WithParameterAfterColon2 α
最初のデータ型の宣言でパラメータに名前が付けられていない場合、一貫して使用される限り各コンストラクタで異なる名前を使用しても良いです。次の宣言はLeanに受け入れられます:
inductive WithParameterAfterColonDifferentNames : Type u → Type u where | test1 : α → WithParameterAfterColonDifferentNames α | test2 : β → WithParameterAfterColonDifferentNames β
しかし、この柔軟性は、パラメータの名前を明示的に宣言するデータ型には適用されません:
inductive WithParameterBeforeColonDifferentNames (α : Type u) : Type u where | test1 : α → WithParameterBeforeColonDifferentNames α | test2 : β → WithParameterBeforeColonDifferentNames β
inductive datatype parameter mismatch β expected α
同様に、添字に名前を付けようとするとエラーになります:
inductive WithNamedIndex (α : Type u) : Type (u + 1) where | test1 : WithNamedIndex α | test2 : WithNamedIndex α → WithNamedIndex α → WithNamedIndex (α × α)
inductive datatype parameter mismatch α × α expected α
適切な宇宙レベルを用い添字をコロンの後ろに置くことで、Leanに許容される宣言となります:
inductive WithIndex : Type u → Type (u + 1) where | test1 : WithIndex α | test2 : WithIndex α → WithIndex α → WithIndex (α × α)
帰納型の宣言においてコロンの後にある引数がすべてのコンストラクタで一貫して使用されていればパラメータであるとLeanが判断できる場合もありますが、それでもすべてのパラメータはすべての添字より前に来る必要があります。添字の後にパラメータを置こうとすると、引数自体が添字と見なされ、データ型の宇宙レベルを上げる必要があります:
inductive ParamAfterIndex : Nat → Type u → Type u where | test1 : ParamAfterIndex 0 γ | test2 : ParamAfterIndex n γ → ParamAfterIndex k γ → ParamAfterIndex (n + k) γ
invalid universe level in constructor 'ParamAfterIndex.test1', parameter 'γ' has type Type u at universe level u+2 it must be smaller than or equal to the inductive datatype universe level u+1
パラメータは型である必要はありません。次の例は
Nat
のような通常のデータ型をパラメータとして使用できることを示しています:inductive NatParam (n : Nat) : Nat → Type u where | five : NatParam 4 5
inductive datatype parameter mismatch 4 expected n
エラーメッセージの通りに
n
を使用すると、宣言が受理されます:inductive NatParam (n : Nat) : Nat → Type u where | five : NatParam n 5
これらの実験から何が結論付けられるでしょうか?パラメータと添字のルールは以下の通りです:
- パラメータは各コンストラクタの型で同じものを使用しなければなりません。
- すべてのパラメータはすべての添字より前に来なければなりません。
- 定義されるデータ型の宇宙レベルは最も低くても最大のパラメータのものと同じ大きさでなければならず、最大の添字より大きくなければなりません。
- コロンの前に書かれた名前付きの引数は常にパラメータであり、コロンの後ろに書かれた引数は通常は添字です。コロンの後ろにある引数が一貫して使われ、かつ添字より後ろに来ないように使われている場合、それらをパラメータに変更するよう判断できます。
どれがパラメータであるかわからない場合は、Leanのコマンド
#print
を使ってデータ型の引き数のうちいくつがパラメータなのかをチェックすることができます。例えば、Vect
の場合、パラメータ数が1であることが示されます:#print Vect
inductive Vect.{u} : Type u → Nat → Type u number of parameters: 1 constructors: Vect.nil : {α : Type u} → Vect α 0 Vect.cons : {α : Type u} → {n : Nat} → α → Vect α n → Vect α (n + 1)
データ型の引き数の順番を決める際に、どの引数をパラメータにし、どの引数を添字にするかを考えることには価値があります。可能な限り多くの引数をパラメータにすることは宇宙レベルを制御し、複雑なプログラムの型チェックを容易にすることができます。これを可能にする1つの方法は、引数リストにおいてすべてのパラメータがすべての添字の前に来るようにすることです。
さらに、Leanはコロンの後にある引数がなんであろうともその使われ方からパラメータであることを判断することができますが、パラメータを明示的な名前で書くことは良い考え方です。こうすることで読み手に意図が明確に伝わ子、コンストラクタ間で引数が誤って一貫しない使われ方をした際にLeanがエラーを報告するようになります。
依存型プログラミングの落とし穴
依存型の柔軟性によって、より便利なプログラムが型チェッカに受理されるようになります。というのも、この型の言語は表現力の乏しい型システムでは記述できないようなものについて記述するのに十分な表現力を備えているからです。同時に、依存型は非常にきめ細やかな仕様を表現できるため、バグを含むプログラムについて通常の型システムよりも多くのものが型チェッカで拒否されるようになります。このパワーには代償が伴います。
Row
のような型を返す関数の内部と、その関数が生成する型との間の密結合などはより大きな困難の一例です:関数のインタフェースと実装の区別は関数が型の中で使われると崩れ始めます。通常、関数の型シグネチャや入出力の動作を変更しない限り、すべてのリファクタリングが有効です。クライアントコードを壊すことなく、関数はより効率的なアルゴリズムやデータ構造を使用するように書き換え、バグを修正し、ソースコードの明瞭性を向上させることができます。しかし、関数が型の中で使用されると、関数の実装内部は型の一部となり、したがって他のプログラムへの インタフェース の一部となります。例として、以下の2つの
Nat
の加算の実装を見てみましょう。Nat.plusL
は最初の引数に対して再帰的です:def Nat.plusL : Nat → Nat → Nat | 0, k => k | n + 1, k => plusL n k + 1
一方、
Nat.plusR
は第2引数に対して再帰的です:def Nat.plusR : Nat → Nat → Nat | n, 0 => n | n, k + 1 => plusR n k + 1
足し算の実装はどちらもベースの数学的なコンセプトに忠実であるため、同じ引数が与えられた時に同じ結果を返します。
しかし、これら2つの実装は型の中で用いられると全く異なるインタフェースを示します。例として、2つの
Vect
を連結する関数を考えてみましょう。この関数は引数の長さの和を長さとしたVect
を返す必要があります。Vect
は基本的にList
により情報をもった型を加えたものであるので、この関数をList.append
と同じように最初の引数に対してパターンマッチと再帰を行うように記述することは理にかなっているでしょう。型シグネチャとプレースホルダを指す最初のパターンマッチから始めると、2つのメッセージが得られます。def appendL : Vect α n → Vect α k → Vect α (n.plusL k) | .nil, ys => _ | .cons x xs, ys => _
nil
のケースにある最初のメッセージは、プレースホルダがplusL 0 k
の長さを持つVect
で置き換えられるべきであるということを述べています:don't know how to synthesize placeholder context: α : Type u_1 n k : Nat ys : Vect α k ⊢ Vect α (Nat.plusL 0 k)
cons
のケースにある2番目のメッセージでは、プレースホルダは長さplusL (n✝ + 1) k
のVect
で置き換えられるべきであることを述べています:don't know how to synthesize placeholder context: α : Type u_1 n k n✝ : Nat x : α xs : Vect α n✝ ys : Vect α k ⊢ Vect α (Nat.plusL (n✝ + 1) k)
n
の後にある ダガー と呼ばれる記号はLeanが内部的に考案した名前を示すために使用されます。コンストラクタcons
の添字はn + 1
でありVect
の後続のリストの長さがn
であることから、裏では最初のVect
に対して暗黙的に行われたパターンマッチによって、最初のNat
の値が絞り込まれます。ここで、n✝
は引数n
より1つ小さいNat
を表します。定義上の同値
plusL
の定義には0, k => k
のパターンのケースがあります。これは最初のプレースホルダで使用されている長さに適用されるため、アンダースコアの型Vect α (Nat.plusL 0 k)
はVect α k
と別の書き方ができます。同様に、plusL
はn + 1, k => plusN n k + 1
というパターンのケースを含んでいます。つまり、2つ目のアンダースコアの型はVect α (plusL n✝ k + 1)
と書くことと等価です。裏で何が行われているかを明らかにするために、まず最初に
Nat
の引数を明示的に記述します。これによってプログラム中で今や名前が明示的に書かれることになるため、ダガーのないエラーメッセージが得られます:def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k) | 0, k, .nil, ys => _ | n + 1, k, .cons x xs, ys => _
don't know how to synthesize placeholder context: α : Type u_1 k : Nat ys : Vect α k ⊢ Vect α (Nat.plusL 0 k)
don't know how to synthesize placeholder context: α : Type u_1 n k : Nat x : α xs : Vect α n ys : Vect α k ⊢ Vect α (Nat.plusL (n + 1) k)
アンダースコアに簡略化された型の注釈を付けても型エラーは発生しません。これはプログラム内で書かれた型がLeanが自力で見つけたものと等価であることを意味します:
def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k) | 0, k, .nil, ys => (_ : Vect α k) | n + 1, k, .cons x xs, ys => (_ : Vect α (n.plusL k + 1))
don't know how to synthesize placeholder context: α : Type u_1 k : Nat ys : Vect α k ⊢ Vect α k
don't know how to synthesize placeholder context: α : Type u_1 n k : Nat x : α xs : Vect α n ys : Vect α k ⊢ Vect α (Nat.plusL n k + 1)
最初のケースは
Vect α k
を要求し、ys
はその型を持ちます。これは空リストを他のリストに追加するとそのリストが返されることと対になっています。最初のアンダースコアの代わりにys
を使って型を絞り込むと、プログラム中にまだ埋められていないアンダースコアは残り1つとなります:def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k) | 0, k, .nil, ys => ys | n + 1, k, .cons x xs, ys => (_ : Vect α (n.plusL k + 1))
don't know how to synthesize placeholder context: α : Type u_1 n k : Nat x : α xs : Vect α n ys : Vect α k ⊢ Vect α (Nat.plusL n k + 1)
ここで非常に重要なことが起こりました。Leanが
Vect α (Nat.plusL 0 k)
を期待したコンテキストでVect α k
を受け取ったのです。しかし、Nat.plusL
はabbrev
ではないため、型チェック中に実行されるべくもないと思われるかもしれません。つまり何か別のことが起こっています。何が起こっているかを理解する鍵となるのは、Leanが型チェックをする際に行うことはただ
abbrev
を展開するだけではないということです。それだけでなく、片方の型の任意の式がもう片方の型を期待するコンテキストで使われているような2つの型が等しいかどうかのチェックをしながら計算を行うこともできます。この性質は 定義上の同値 (definitional equality)と呼ばれ、とらえがたいものです。当たり前ですが、同じように書かれた2つの型は定義上同値です。例えば、
Nat
はNat
と、List String
はList String
と等しいと見なされるべきです。異なるデータ型から構築された任意の2つの具体的な型は等しくありません。そのためList Nat
はInt
と等しくありません。さらに、内部的な名前の変更だけが異なる型は等しいです。そのため、(n : Nat) → Vect String n
は(k : Nat) → Vect String k
と同じです。型は通常のデータを含むことができるため、定義上の同値はデータが等しい場合についても記述しなければなりません。同じコンストラクタの使用は等しいです。そのため、0
は0
と、[5, 3, 1]
は[5, 3, 1]
と等しくなります。しかし、型に含まれるのは型に含まれるのは関数の矢印、データ型、コンストラクタだけではありません。型には 変数 と 関数 も含まれます。変数の定義上の同値は比較的シンプルです:各変数は自分自身と等しくなります。そのため
(n k : Nat) → Vect Int n
は(n k : Nat) → Vect Int k
と定義上等しくありません。一方で関数はもっと複雑です。数学では2つの関数が入力と出力の挙動が同じであるときに等しいと見なしますが、それをチェックする効率的なアルゴリズムは無いため、Leanでは定義上同値なボディを持つfun
式を持つ関数は定義上同値であると見なします。言い換えると、2つの関数が定義上同値であるためには 同じアルゴリズム を使い、同じ補助関数 を呼ばなければなりません。これは通常あまり役に立たないため、関数の定義上の同値は2つの型に全く同じ定義関数が存在する場合に使用されることがほとんどです。関数が型の中で 呼ばれた 場合、定義上の同値のチェックによって関数呼び出しの簡約が発火される場合があります。型
Vect String (1 + 4)
は、1 + 4
が3 + 2
と定義上等しいため、型Vect String (3 + 2)
と定義上等しいです。これらの等価性をチェックするには、どちらも5
に簡約され、コンストラクタのルールが5回使われることで確認できます。データに適用された関数の定義上の同値は、まずそれらがすでに同じであるかのチェックを行います。つまるところ、["a", "b"] ++ ["c"]
が["a", "b"] ++ ["c"]
と等しいことのチェックのために簡約する必要はないわけです。等しくなかった場合、関数が呼ばれ、得られた値で置き換えられ、その値がチェックされます。全ての関数の引数が具体的なデータというわけではありません。例えば、型の中には
zero
とsucc
コンストラクタのどちらからも生成されていないNat
が含まれることがあります。型(n : Nat) → Vect String n
の中で、変数n
はNat
ですが、この関数が呼ばれるまではこれが どっちのNat
であるか知ることは不可能です。実際、この関数はまず0
で呼び、その後で17
を、それから33
で呼び出されるかもしれません。appendL
の定義で見たように、Nat
型の変数もplusL
のような関数に渡すことができます。実際、型(n : Nat) → Vect String n
は(n : Nat) → Vect String (Nat.plusL 0 n)
と定義上等しくなります。n
とNat.plusL 0 n
が定義上同値である理由は、plusL
のパターンマッチがその 最初の 引数を調べるからです。これは問題です:0は足し算の左右どちらともの単位元であるべきであるにもかかわらず、(n : Nat) → Vect String n
は(n : Nat) → Vect String (Nat.plusL n 0)
と定義上同値 ではない からです。これはパターンマッチが変数に遭遇したことで行き詰ってしまうことで発生します。n
の実際の値がわかるまで、Nat.plusL n 0
のどのケースを選択すべきか知るすべはありません。同じ問題がクエリの例での
Row
関数でも発生します。Row
の定義が中身が1つのリストと最低でも2つ以上要素を持つリストを分けているため、型Row (c :: cs)
はどのデータ型にも簡約することができません。つまり、具体的なList
コンストラクタに対して変数cs
をマッチさせようとすると詰まってしまいます。これがRow
を分解したり構成したりするほとんどすべての関数がRow
の3つのケースと同じようにマッチさせる必要がある理由です:これを解消すると、パターンマッチにもコンストラクタにも使える具体的な型が見えてきます。appendL
の欠落しているケースではVect α (Nat.plusL n k + 1)
が必要になります。この添字での+ 1
によって次のステップでVect.cons
を使うことが示唆されます:def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k) | 0, k, .nil, ys => ys | n + 1, k, .cons x xs, ys => .cons x (_ : Vect α (n.plusL k))
don't know how to synthesize placeholder context: α : Type u_1 n k : Nat x : α xs : Vect α n ys : Vect α k ⊢ Vect α (Nat.plusL n k)
appendL
を再帰的に呼び出すことで、目的の長さのVect
を構築することができます:def appendL : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusL k) | 0, k, .nil, ys => ys | n + 1, k, .cons x xs, ys => .cons x (appendL n k xs ys)
これでプログラムが完成したので、
n
とk
への明示的なマッチを削除することで読みやすく、関数も呼び出しやすくなります:def appendL : Vect α n → Vect α k → Vect α (n.plusL k) | .nil, ys => ys | .cons x xs, ys => .cons x (appendL xs ys)
定義上の同値を使った型の比較は関数定義の内部を含め、定義上同値なものに関連するすべてのものが、依存型と添字族を使うプログラムの インタフェース の一部になるということを意味します。型の中に関数の内部を公開するということは、その公開されたプログラムをリファクタリングすることでそれを使用するプログラムが型チェックをしなくなってしまう可能性があるということです。特に、
plusL
がappendL
の型に使われているということは、plusL
の定義をplusR
と同等な他の定義に置き換えることができないということを意味します。足し算での行き詰まり
appendを
plusR
で代わりに定義するとどうなるでしょうか?同じように始めると、それぞれのケースで長さとプレースホルダのアンダースコアが明示され、次のような有益なエラーメッセージが表示されます:def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k) | 0, k, .nil, ys => _ | n + 1, k, .cons x xs, ys => _
don't know how to synthesize placeholder context: α : Type u_1 k : Nat ys : Vect α k ⊢ Vect α (Nat.plusR 0 k)
don't know how to synthesize placeholder context: α : Type u_1 n k : Nat x : α xs : Vect α n ys : Vect α k ⊢ Vect α (Nat.plusR (n + 1) k)
しかし、最初のプレースホルダを囲んで
Vect α k
の型注釈を付けようとすると、型の不一致エラーとなります:def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k) | 0, k, .nil, ys => (_ : Vect α k) | n + 1, k, .cons x xs, ys => _
type mismatch ?m.3036 has type Vect α k : Type ?u.2973 but is expected to have type Vect α (Nat.plusR 0 k) : Type ?u.2973
このエラーは
plusR 0 k
とk
が定義上等しく ない ことを指摘しています。これは
plusR
が次のような定義を持っているためです:def Nat.plusR : Nat → Nat → Nat | n, 0 => n | n, k + 1 => plusR n k + 1
このパターンマッチは第1引数ではなく 第2 引数に対して行われるため、その位置に変数
k
が存在すると簡約をすることができません。Leanの標準ライブラリにあるNat.add
はplusL
ではなくplusR
と等価であるため、この定義でNat.add
を使おうとすると全く同じ問題が起こります:def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n + k) | 0, k, .nil, ys => (_ : Vect α k) | n + 1, k, .cons x xs, ys => _
type mismatch ?m.3068 has type Vect α k : Type ?u.2973 but is expected to have type Vect α (0 + k) : Type ?u.2973
足し算は変数に つまって しまいます。これを解消するには、命題の同値 を使用します。
命題の同値
命題の同値は2つの式が等しいという数学的な文です。定義上の同値はLeanが必要な時に自動的にチェックする一種の曖昧な事実ですが、命題の同値の記述には明示的な証明が必要です。一度命題の同値が証明されると、プログラム内でそれを使って型を修正し、等式を片方の辺を他方のもので置き換えることができ、型チェッカの詰まりを解消できます。
定義上の同値がこのように限定されている理由は、アルゴリズムによるチェックを可能にするためです。命題の同値はより機能が豊かですが、証明と称されるものが実際に証明であることを検証できたとしても、コンピュータは一般的に2つの式が命題的に等しいかどうかをチェックすることができません。定義上の同値と命題の同値の断絶は人間と機械の間の分業を表現しています:退屈極まりない等式は定義上の同値の一部として自動的にチェックされ、人間の頭脳は命題の同値で利用される興味深い問題に向けることができます。同様に、定義上の同値は型チェッカによって自動的に呼び出されますが、命題の同値は明確に呼びかけなければなりません。
「命題・証明・リストの添え字アクセス」 にて、いくつかの同値についての文が
simp
を使って証明されました。これらの等式はすべて、命題の同値がすでに定義上の同値になっているものです。一般的に、命題の同値についての文を証明するには、まずそれらを定義上の同値か既存の証明済みの等式に近い形にし、simp
のようなツールを使って単純化されたケースを処理します。simp
タクティクは非常に強力です:裏では、高速で自動化された多くのツールを使って証明を構築します。これよりはシンプルなrfl
と呼ばれるタクティクは命題の同値を証明するために定義上の同値を使用します。rfl
という名前は 反射律 (reflexivity)の略であり、すべてのものはそれ自身に等しいという同値についての性質です。appendR
の詰まりを解消するには、k = Nat.plusR 0 k
という証明が必要ですが、これはplusR
が第2引数の変数に着目しているため定義上の同値ではないのでした。これを計算させるためにはk
を具体的なコンストラクタにしなければなりません。これはパターンマッチの仕事です。特に、
k
は 任意のNat
でありうるので、このタスクは 任意のk
に対してk = Nat.plusR 0 k
であるという根拠を返す関数を必要とします。これは(k : Nat) → k = Nat.plusR 0 k
という型を持つ同値の証明を返す関数でなければなりません。一番初めのパターンとプレースホルダから始めると、次のようなメッセージが返ってきます:def plusR_zero_left : (k : Nat) → k = Nat.plusR 0 k | 0 => _ | k + 1 => _
don't know how to synthesize placeholder context: ⊢ 0 = Nat.plusR 0 0
don't know how to synthesize placeholder context: k : Nat ⊢ k + 1 = Nat.plusR 0 (k + 1)
パターンマッチによって
k
を0
に絞り込むと、最初のプレースホルダは定義上成立する文の根拠となります。rfl
タクティクはこれを処理し、残るは2番目のプレースホルダのみとなります:def plusR_zero_left : (k : Nat) → k = Nat.plusR 0 k | 0 => by rfl | k + 1 => _
2番目のプレースホルダは少し厄介です。式
Nat.plusR 0 k + 1
はNat.plusR 0 (k + 1)
と定義上同値です。これは、ゴールがk + 1 = Nat.plusR 0 k + 1
とも書けることを意味します:def plusR_zero_left : (k : Nat) → k = Nat.plusR 0 k | 0 => by rfl | k + 1 => (_ : k + 1 = Nat.plusR 0 k + 1)
don't know how to synthesize placeholder context: k : Nat ⊢ k + 1 = Nat.plusR 0 k + 1
文の等式の両側にある
+ 1
の下には関数自体が返す別のインスタンスがあります。言い換えれば、k
に対する再帰呼び出しはk = Nat.plusR 0 k
という根拠を返すことになります。等式は関数の引数に適用されなければ等式になりません。つまりx = y
ならばf x = f y
となります。標準ライブラリには関数congrArg
があり、関数と同値の証明を受け取り、等号の両辺に関数を適用した新しい証明を返します。今回の場合、関数は(· + 1)
です:def plusR_zero_left : (k : Nat) → k = Nat.plusR 0 k | 0 => by rfl | k + 1 => congrArg (· + 1) (plusR_zero_left k)
命題の同値は右向きの三角形の演算子
▸
を使ってプログラムに導入することができます。同値の証明を第1引数に、他の式を第2引数に与えることで、この演算子は第2引数の型において等式の左辺のインスタンスを右辺の等式に置き換えます。つまり、以下の定義には型エラーがありません:def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k) | 0, k, .nil, ys => plusR_zero_left k ▸ (_ : Vect α k) | n + 1, k, .cons x xs, ys => _
最初のプレースホルダは以下の型が期待されています:
don't know how to synthesize placeholder context: α : Type u_1 k : Nat ys : Vect α k ⊢ Vect α k
これは
ys
で埋めることができます:def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k) | 0, k, .nil, ys => plusR_zero_left k ▸ ys | n + 1, k, .cons x xs, ys => _
残りのプレースホルダを埋めるには、別の加算についてのインスタンスでの詰まりを解消する必要があります:
don't know how to synthesize placeholder context: α : Type u_1 n k : Nat x : α xs : Vect α n ys : Vect α k ⊢ Vect α (Nat.plusR (n + 1) k)
ここで、証明すべき文は
Nat.plusR (n + 1) k = Nat.plusR n k + 1
です。これは▸
を使うことで+ 1
を先頭に抜き出し、これによってcons
のインデックスと一致させることができます。この証明は
plusR
への第2引数、つまりk
へのパターンマッチを行う再帰関数です。これはplusR
自身が第2引数でパターンマッチを行うためであり、証明はパターンマッチによってplusR
を「解消」し、計算の挙動を明らかにすることができます。この証明の骨格はplusR_zero_left
のものと非常によく似ています:def plusR_succ_left (n : Nat) : (k : Nat) → Nat.plusR (n + 1) k = Nat.plusR n k + 1 | 0 => by rfl | k + 1 => _
残ったケースの型は
Nat.plusR (n + 1) k + 1 = Nat.plusR n (k + 1) + 1
と定義上同値であるため、plusR_zero_left
と同様にcongrArg
で解くことができます:don't know how to synthesize placeholder context: n k : Nat ⊢ Nat.plusR (n + 1) (k + 1) = Nat.plusR n (k + 1) + 1
これによって証明が完成します:
def plusR_succ_left (n : Nat) : (k : Nat) → Nat.plusR (n + 1) k = Nat.plusR n k + 1 | 0 => by rfl | k + 1 => congrArg (· + 1) (plusR_succ_left n k)
完成した証明は
appendR
の2番目のケースの詰まりを解くことに使うことができます:def appendR : (n k : Nat) → Vect α n → Vect α k → Vect α (n.plusR k) | 0, k, .nil, ys => plusR_zero_left k ▸ ys | n + 1, k, .cons x xs, ys => plusR_succ_left n k ▸ .cons x (appendR n k xs ys)
再び
appendR
の長さの引数を暗黙にすると、証明の中で要求されていた明示的な名前がなくなります。しかし、これらの型をマッチさせるための値はほかにありえないため、Leanの型チェッカは裏で自動的にそれらを埋めるための情報を十分に持っています:def appendR : Vect α n → Vect α k → Vect α (n.plusR k) | .nil, ys => plusR_zero_left _ ▸ ys | .cons x xs, ys => plusR_succ_left _ _ ▸ .cons x (appendR xs ys)
長所と短所
添字族には重要な特性があります:これらへのパターンマッチは定義上の同値に影響を与えます。例えば、
Vect
に対するMatch
式でnil
のケースにおいて、長さは単純に0
に なります 。定義上の同値はとても便利です。というのもこれはいつでも有効であり、明示的に呼び出す必要がないからです。しかし、依存型とパターンマッチによる定義上の同値の使用にはソフトウェア工学的に重大な欠点があります。まず第一に、関数は型の中で使用する用として特別に書かなければならず、型の中で便利に使用される関数では最も効率的なアルゴリズムを使用していない可能性があります。一度型の中で関数が使用されて公開されると、その実装はインタフェースの一部となり、将来のリファクタリングが困難になります。第二に、定義上の同値は時間がかかることがあります。2つの式が定義上同値であるかどうかをチェックするよう求められた時で問題の関数が複雑で抽象化のレイヤーが多い場合、Leanは大量のコードを実行する必要がある可能性が発生します。第三に、定義上の同値が失敗した時に得られるエラーメッセージは関数の内部的な用語で表現されるため、いつでも理解しやすいとは限りません。エラーメッセージに含まれる式の出所を理解するのは必ずしも容易ではありません。最後に、添字族と依存型関数のあつまりに自明でない不変量をエンコードすることはしばしば脆弱になります。関数の簡約についての挙動の公開によって便利な定義上の同値を提供しないことが判明した際に、システムの初期の定義を変更しなければならないことがよくあります。別の方法として、等式の証明の要求をプログラムにちりばめることもできますが、これは非常に扱いにくくなる可能性があります。
慣用的なLeanのコードでは、添字族はあまり使われません。その代わりに、部分型と明示的な命題を使用して重要な不変性を強制することが一般的です。このアプローチでは明示的な証明が多く、定義上の同値に訴えることはほとんどありません。対話型の定理証明器にふさわしく、Leanは明示的な証明を便利にするように設計されています。一般的に、ほとんどの場合においてこのアプローチが望ましいです。
しかし、データ型の添字族を理解することは重要です。
plusR_zero_left
やplusR_succ_left
などの再帰関数は、実は 数学的帰納法による証明 (proofs by mathematical induction)です。再帰の基本ケースは帰納法の基本ケースに対応し、再帰呼び出しは帰納法の仮定に訴えることを表しています。より一般的には、Leanにおける新しい命題はしばしば帰納的な根拠の型として定義され、これらの帰納型は通常は添字を持ちます。定理を証明するプロセスはこの節の証明と同じようなプロセスで、実際にはこれらの型を持つ式を裏で構築しています。また、添字を持つデータ型はまさにその仕事に適したツールであることもあります。添字付きのデータ型の使い方を熟知することは、どのような場合に添字付きのデータ型を使うべきか知るための重要な要素です。演習問題
plusR_succ_left
のスタイルの再帰関数を使って、すべてのn
とk
に対してn.plusR k = n + k
であることを証明してください。Vect
上の関数でplusL
よりもplusR
の方が自然であるものを書いてください。つまりplusL
ではその定義を用いた証明が必要となるようなものです。
まとめ
依存型
依存型とは型が関数呼び出しや通常のデータコンストラクタのような型ではないコードを含むものであり、型システムの表現力を飛躍的に高めてくれます。引数の 値 から型を 計算 できるということは、関数の戻り値の型を引数の値によって変えることができるということです。これは例えば、データベースのクエリの結果の型を、潜在的に失敗するかもしれないキャスト操作などの必要無しに、データベースのスキーマと発行された特定のクエリに依存させるために使用することができます。クエリが変更されると、それを実行した結果の型も変更されるため、コンパイル時に即座にフィードバックを得ることができます。
関数の戻り値の型が値に依存する場合、パターンマッチで値を分析すると、値を表す変数がパターン内のコンストラクタで置き換えられるため型が 絞り込まれる ことがあります。関数の型シグネチャは戻り値の型が引数の値に依存する方法を文書化したものであり、パターンマッチは戻り値の型が各引数の可能性に対してどのように満たされるかを説明するものです。
型の中で発生する通常のコードは型チェック中に実行されますが、無限にループする可能性のある
partial
関数は呼び出されません。ほとんどの場合、この計算は 本書の冒頭 で紹介した通常の評価のルールに従い、最終的な値が見つかるまで式は逐次値を置き換えられていきます。型チェック中の計算には実行時の計算と異なる重要な点があります:型の中のいくつかの値はその時点では値がわからない 変数 である場合があります。このような場合、パターンマッチは「詰まり」、例えばパターンマッチによって特定のコンストラクタが選択されるまで、あるいは選択されない限り処理を続行しません。型レベルの計算は一種の部分評価と見なすことができ、プログラム中の十分既知の部分だけが評価され、それ以外の部分は放置されます。ユニバースパターン
依存型を使う際によくあるパターンは、型システムのサブセットを切り出すことです。例えば、データベースのクエリライブラリは可変長の文字列や固定長の文字列、特定の範囲の数値を返すかもしれませんが、関数やユーザ定義のデータ型、
IO
アクションなどは決して返しません。型システムのドメイン固有のサブセットの定義は、まず希望する型の構造にマッチするコンストラクタを持つデータ型を定義し、次にこのデータ型からの値を正真正銘の型に解釈する関数を定義することで行えます。コンストラクタは問題の対象の型の コード と呼ばれ、パターン全体は Tarski風ユニバース と呼ばれたり、文脈からType 3
やProp
のような宇宙を意味するものではないことが明らかな場合は単に ユニバース と呼ばれたりします。カスタムのユニバースは関心のあるタイプごとにインスタンスを持つ型クラスを定義することの代替手段です。型クラスは拡張可能ですが、拡張性が常に望まれるとは限りません。カスタムユニバースを定義すると、型を直接扱うよりも多くの利点があります:
- 等値性の検査や直列化など、宇宙の あらゆる 型に対して機能する汎用的な操作はコードの再帰によって実装することができます。
- 外部システムが受け入れる型を正確に表現することができ、コードによるデータ型の定義は何が期待されるかを文書化する役割を果たします。
- Leanのパターンマッチの完全性についてのチェッカはコードの取り忘れがないことを保証しますが、一方で型クラスによる解決策ではインスタンスが無いことによるエラーはクライアントコードに先送りにされます。
添字族
データ型は2種類の異なる引数を取ることができます:パラメータ はデータ型の各コンストラクタで同一の値ですが、添字 はそれぞれのコンストラクタで異なる場合があります。与えられた添字の選択によって、データ型のコンストラクタの中で利用可能なものが限定されます。例として、
Vect.nil
は長さの添字が0
の場合にのみ利用可能であり、Vect.cons
は長さの添字がn
に対してn + 1
の場合にのみ利用可能です。通常、パラメータはデータ宣言のコロンの前に名前付き引数として記述され、添字はコロンの後に関数型の引き数として記述されますが、Leanはコロンの後にある引数がパラメータとして使用されるケースを推測することができます。添字族によってデータ間の複雑な関係を式にすることができ、すべてコンパイラによってチェックされます。データ型の不変量は直接エンコードすることができ、一時的であってもそれを変更することはできません。コンパイラがデータ型の不変量を知ることには大きな利点があります:コンパイラはプログラマにそれらを満たすために何をすべきかを知らせることができるようになります。コンパイル時のエラー、特にアンダースコアに起因するエラーを戦略的に利用することで、プログラミングの思考プロセスの一部をLeanに委ねることが可能になり、プログラマはほかのことに気を配ることができるようになります。
添字族を使って不変量をエンコードすると困難が生じる可能性があります。まず、それぞれの不変量は独自のデータ型を必要とし、そのデータ型は独自のサポートのためのライブラリを必要とします。つまるところ、
List.append
とVect.append
は互換性が無いということです。これはコードの重複につながります。第二に、添字族を便利に使用するには、型の中で使用される関数の再帰構造が型チェックされるプログラムの再帰構造と一致する必要があります。添字族によるプログラミングはこのように適切な一致を起こせるように手配する技術なのです。一致のミスの回避のために等式の証明に訴えることは可能ですが、これは難しく、難解な正当化がちりばめられたプログラムとなってしまいます。第三に、型チェック中に大きな値に対して複雑なコードを実行すると、コンパイル時の速度低下を招く可能性があります。複雑なプログラムでこのような速度低下を避けるには特殊なテクニックが必要になります。定義上と命題の同値
Leanの型チェッカは2つの型が交換可能かどうかを、時々ではありますがチェックしなければなりません。型は任意のプログラムを含むことができるため、型チェッカは任意のプログラムの同値性をチェックできなければなりません。しかし、任意のプログラムについて完全に一般的な数学的な同値をチェックする効率的なアルゴリズムは存在しません。これを回避するために、Leanは2つの同値の概念を含んでいます:
- 定義上の同値 とは、同値性の下位の近似であり、基本的にはモジュロ計算と束縛変数の名前の変更の構文表現の同値性をチェックします。Leanは定義上の同値が必要とされる状況では、自動的に定義上の同値をチェックします。
- 命題の同値 はプログラマによって明示的に証明され、明示的に呼び出されなければなりません。その見返りとして、Leanは証明が有効であること、そして呼び出しが正しいゴールを達成することを自動的にチェックします。
この2つの同値の概念はプログラマとLean自身の間の分業を表しています。定義上の同値は単純ですが自動的であり、命題の同値は手動ですが、表現力豊かです。命題の同値は型にはまり込んだプログラムを解きほぐすことに使えます。
しかし、型レベルの計算を解くために命題の同値を多用することは一般的にコードの臭いとなります。これは通常、型の一致がうまく設計されていないことを意味し、型と添字を再設計するか、必要な不変量を強制するために別のテクニックを使用する方が良い考えです。命題の同値がプログラムの仕様を満たしていることを証明するためであったり、部分型の一部として使われる分には違和感はあまりありません。
休憩:タクティク・帰納法・証明
証明とユーザインタフェースについての注意
本書では証明の書き方について、あたかも一度に書いてLeanに提出し、Leanがエラーメッセージを返して残りの作業を説明するものであるかのように紹介しました。Leanとのやりとりは実際にはもっと楽しいものです。Leanはカーソルが移動するたびに証明に関する情報を提供し、証明を簡単にする対話的な機能も多数存在します。詳細についてはLean開発環境のドキュメントを参照してください。
本書での、証明の段階的な構築とその結果得られるメッセージの表示に焦点を当てたアプローチは、Leanのエキスパートが使う手順よりはるかに遅いとはいえ、証明を書いている最中にLeanが提供してくれる種類の対話的なフィードバックを示しています。同時に、不完全な証明が完全なものへと進化していく過程を見ることは、証明する上で有益な視点となります。証明を書くスキルが上がるにつれて、Leanのフィードバックはエラーではなく、読者自身の思考プロセスをサポートするものであると感じられるようになるでしょう。対話的なアプローチを学ぶことはとても重要です。
再帰と帰納法
前章の関数
plusR_succ_left
とplusR_zero_left
は2つの観点から見ることができます。一方からは、他の再帰関数がリストや文字列などのデータ構造を構築するのと同じように、命題の根拠を構築する再帰関数となります。他方からは、これは 数学的帰納法 (mathematical induction)による証明でもあります。数学的帰納法はある文が以下の2つのステップによって すべての 自然数について証明されるという証明技法です:
- その文が \( 0 \) について成り立つことを示す。これは 基本ケース (base case)と呼ばれます。
- その文がとある任意に選ばれた整数 \( n \) について成り立つという仮定の下で \( n + 1 \) について成り立つことを示す。これは 帰納法のステップ (induction step)と呼ばれます。その文が \( n \) について成り立つという仮定は 帰納法の仮定 (induction hypothesis)と呼ばれます。
ある文を すべての 自然数についてチェックすることは不可能であるため、帰納法は原理的にはどのような自然数にも拡張できる証明を書く手段を提供します。例えば、3という値について具体的な証明が必要な場合、まず基本ケースを使い、次に帰納法のステップを3回使うことで、0・1・2、そして最後に3について証明することができます。こうして、すべての自然数についての証明ができます。
帰納法のタクティク
congrArg
のような補助関数を使用する再帰関数として帰納法による証明を書くことは、証明の背後にある意図を表現するにあたっては必ずしも良い仕事ではありません。再帰関数は確かに帰納法の構造を持っていますが、これは証明を エンコード したものと見なすべきでしょう。さらに、Leanのタクティクシステムは、再帰関数を明示的に記述する時には利用できない、証明の構築を自動化する多くの機会を提供します。Leanは1つのタクティクブロックで帰納法による証明全体を実行できる帰納法用の タクティク を提供しています。裏では、Leanは帰納法の使用に対応する再帰関数を構築しています。帰納法のタクティクで
plusR_zero_left
を証明するには、まずそのシグネチャを書くことから始めます(これは正真正銘の証明であるため、theorem
を使います)。続いて、by induction k
を定義の本体として使います:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k
出力されるメッセージでは2つのゴールが示されます:
unsolved goals case zero ⊢ Nat.zero = Nat.plusR 0 Nat.zero case succ n✝ : Nat n_ih✝ : n✝ = Nat.plusR 0 n✝ ⊢ Nat.succ n✝ = Nat.plusR 0 (Nat.succ n✝)
タクティクブロックはLeanの型チェッカがファイルを処理する間に実行されるプログラムであり、C言語のプリプロセッサマクロをより強力にしたようなものです。これらのタクティクは実際のプログラムを生成します。
タクティク言語ではゴールは複数になることがあります。それぞれのゴールは型といくつかの仮定から構成されます。これらはアンダースコアをプレースホルダとして使った場合と似ています。すなわち、ゴールの型は証明されるものを、仮定はスコープ内で使用できるものをそれぞれ表します。ゴール
case zero
の場合、仮定は無く、型はNat.zero = Nat.plusR 0 Nat.zero
となり、これは定理のk
を0
に置き換えた文です。ゴールcase succ
では、n✝
とn_ih✝
という2つの仮定があります。裏では、induction
タクティクによって全体の型を絞り込む依存パターンマッチが作成され、n✝
はそのパターンにおけるNat.succ
の引数を表しています。仮定n_ih✝
は生成された関数をn✝
に対して再帰的に呼び出した結果を表します。その型は定理の全体的な型に対して、ただk
の代わりにn✝
にしただけのものです。ゴールcase succ
の一部として満たされる型は、定理全体の型に対して、k
の代わりにNat.succ n✝
にしたものです。induction
タクティクを使用した結果得られた2つのゴールは数学的帰納法の説明における基本ケースと帰納法のステップに相当します。基本ケースはcase zero
です。case succ
では、n_ih✝
が帰納法の仮定に、case succ
の全体が帰納法のステップにそれぞれ相当します。この証明の記述にあたっての次のステップは2つのゴールに対して順番に焦点を当てることです。
do
ブロックにおいてpure ()
を使うことで「何もしない」ことを示すことができるように、タクティク言語にはskip
という文があり、これも何もしません、これはLeanの構文がタクティクを要求しているものの、まだどれを使うべきか明確でない場合に使うことができます。with
をinduction
文の最後に追加すると、パターンマッチに似た構文になります:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => skip | succ n ih => skip
2つの
skip
文にはそれぞれメッセージが関連付けられます。1つ目は基本ケースについて示しています:unsolved goals case zero ⊢ Nat.zero = Nat.plusR 0 Nat.zero
2つ目は帰納法のステップについて示しています:
unsolved goals case succ n : Nat ih : n = Nat.plusR 0 n ⊢ Nat.succ n = Nat.plusR 0 (Nat.succ n)
帰納法のステップにて、ダガーが付いたアクセスできない名前は
succ
の後に置いた名前、すなわちn
とih
によって置き換えられています。induction ... with
の後にあるこれらのケースはパターンではありません:これらは0個以上の名前を伴うゴールの名前です。この名前はゴールで導入される仮定に使われます;ゴールが導入する以上の名前を指定するとエラーになります:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => skip | succ n ih lots of names => skip
too many variable names provided at alternative 'succ', #5 provided, but #2 expected
基本ケースに焦点を当てると、再帰関数の中での場合と同じように、
rfl
タクティクがinduction
タクティクの中でもうまく機能します:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => skip
この証明の再帰関数によるものでは、型注釈によって期待される型が理解しやすいものになっていました。タクティク言語ではゴールを解きやすくするための具体的な変換方法がいくつもあります。
unfold
タクティクは定義された名前をその定義に置き換えます:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => unfold Nat.plusR
これで、ゴールの等式の右辺は
Nat.plusR 0 (Nat.succ n)
ではなくNat.plusR 0 n + 1
になりました:unsolved goals case succ n : Nat ih : n = Nat.plusR 0 n ⊢ Nat.succ n = Nat.plusR 0 n + 1
congrArg
のような関数や▸
のような演算子に訴える代わりに、証明のゴールを等号の証明によって変換することができるタクティクがあります。最も重要なものの1つがrw
で、これは等号の証明のリストを受け取り、各等式の左辺に対応するゴールの式を右辺で置き換えます。これはplusR_zero_left
にてほとんど正しく機能します:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => unfold Nat.plusR rw [ih]
しかし、書き換えの方向性が誤っていました。
n
をNat.plusR 0 n
に置き換えたことでゴールの複雑性は減るどころかむしろ増えました:unsolved goals case succ n : Nat ih : n = Nat.plusR 0 n ⊢ Nat.succ (Nat.plusR 0 n) = Nat.plusR 0 (Nat.plusR 0 n) + 1
これは
rewrite
の呼び出しでih
の前に左向き矢印を置くことで改善されます。これは等式の右辺を左辺で置き換えるよう指示します:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => unfold Nat.plusR rw [←ih]
この書き換えによって等式の両辺が同一になり、Leanはこれをもって
rfl
を処理します。これで証明は完了です。タクティクゴルフ
ここまでのところ、タクティク言語はその真価をまだ発揮していません。上記の証明は再帰関数より短くありません;これは完全なLeanの言語ではなく、ドメイン固有の言語で書かれているに過ぎません。しかし、タクティクを使った証明はより短く、より簡単で、より保守しやすいものになります。ゴルフでスコアが低い方が良いように、タクティクゴルフでは証明が短い方が良いです。
plusR_zero_left
の帰納法のステップは単純化のためのタクティクsimp
を使って証明することができます。simp
を単独で使っても役に立ちません:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => simp
simp made no progress
しかし、
simp
は定義の集まりを使用するように設定することができます。rw
と同様に、これらの引数はリストで提供します。simp
にNat.plusR
の定義を考慮するよう依頼すると、よりシンプルなゴールにたどり着きます:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => simp [Nat.plusR]
unsolved goals case succ n : Nat ih : n = Nat.plusR 0 n ⊢ n = Nat.plusR 0 n
特に、このゴールは帰納法の仮定と同じものになりました。単純な等式を自動的に証明するだけでなく、単純化は
Nat.succ A = Nat.succ B
のようなゴールをA = B
に自動的に置き換えます。帰納法の仮定ih
はまさに(exactly)正しい型を持っているため、exact
タクティクによってその仮定を使うべきであることを示すことができます:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => simp [Nat.plusR] exact ih
しかし、
exact
の使用はやや脆弱です。証明を「ゴルフ」している最中に帰納法の仮定の名前の変更があり得るため、この証明が機能しなくなる原因になります。assumption
タクティクは仮定のうちの どれか がゴールにマッチすれば、それによって現在のゴールを解決します:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => simp [Nat.plusR] assumption
この証明は展開と明示的な書き換えを行った以前の証明よりも短くありません。しかし、
simp
が多くの種類のゴールを解決できるという事実を利用して、一連の変換を変換をより短くすることができます。最初のステップはinduction
の後ろのwith
を削除することです。構造化された読みやすい証明のためには、with
構文は便利です。ケースが欠けていれば文句を言い、帰納法の構造を明確に示すことができます。しかし、証明を短くするには、もっと自由なアプローチが必要になることがよくあります。with
無しのinduction
を使用すると証明の状態は単純に2つのゴールを持ったものになります。induction ... with
タクティクの分岐と同じように、case
タクティクをそのどちらかのゴールを選択するために使うことができます。言い換えると、次の証明は前の証明と等価です:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k case zero => rfl case succ n ih => simp [Nat.plusR] assumption
ゴールを1つ持つコンテキスト(つまり
k = Nat.plusR 0 k
)では、induction k
タクティクは2つのゴールを生成します。一般的には、タクティクはエラーで失敗するかあるゴールを0個以上の新しいゴールに変換します。それぞれの新しいゴールは証明すべきことが残っていることを表します。もしその結果ゴールが0個になれば、そのタクティクは成功であり、その部分の証明は終わったことになります。<;>
演算子は2つのタクティクを引数に取り、新しいタクティクを生成します。T1 <;> T2
はT1
を現在のゴールに適用し、続いてT2
をT1
が作成した すべての ゴールに適用します。言い換えると、<;>
は多くの種類のゴールを解決することができる一般的なタクティクを、一度に複数のゴールに使用することを可能にします。そのような一般的なタクティクの1つがsimp
です。simp
はこの証明の基本ケースの完成と帰納法のステップを進めることの両方を行うことができるため、induction
と<;>
と一緒に使うことで証明を短縮することができます:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k <;> simp [Nat.plusR]
この結果、ゴールはただ1つ、変換された帰納法のステップだけとなります:
unsolved goals case succ n✝ : Nat n_ih✝ : n✝ = Nat.plusR 0 n✝ ⊢ n✝ = Nat.plusR 0 n✝
このゴールで
assumption
を実行すれば証明が完了します:theorem plusR_zero_left (k : Nat) : k = Nat.plusR 0 k := by induction k <;> simp [Nat.plusR] <;> assumption
ここで
ih
が明示的に名付けられていないため、exact
は使えないでしょう。初心者にとって、この証明は読みやすくはありません。しかし、熟練したユーザにとって
simp
のような強力なタクティクで多くの単純なケースを処理し、証明のテキストを興味深いケースに集中させることはよくあるパターンです。さらに、このような証明は、証明に関係する関数やデータ型の小さな変更に対して頑強である傾向があります。タクティクゴルフゲームは、証明を書く時のセンスとスタイルを磨くことに役立ちます。他のデータ型への帰納法
数学的帰納法は
Nat.zero
の基本ケースとNat.succ
の帰納法のステップを提供することで自然数についての文を証明します。帰納法の原理はほかのデータ型でも有効です。再帰引数を持たないコンストラクタが基本ケースを形成し、再帰引数を持つコンストラクタが帰納法のステップを形成します。帰納法によって証明を実行できるこの能力がまさにこれらのデータ型を 帰納的 データ型と呼ぶ所以です。その一例が二分木に対する帰納法です。二分木に対する帰納法は、ある文が以下の2つのステップによって すべての 二分木について証明されるという証明技法です:
- その文が
BinTree.leaf
について成り立つことを示す。これは基本ケースと呼ばれます。 - その文がとある任意に選ばれた木
l
とr
について成り立つという仮定の下でBinTree.branch l x r
について成り立つことを示す。ここでx
は任意に選ばれた新しいデータの点です。これは 帰納法のステップ と呼ばれます。その文がl
とr
について成り立つという仮定は 帰納法の仮定 と呼ばれます。
BinTree.count
は木にある枝の数を数えます:def BinTree.count : BinTree α → Nat | .leaf => 0 | .branch l _ r => 1 + l.count + r.count
木のコピー は木の枝の数を変えません。これは木に対する帰納法を使って証明できます。最初のステップは定義を述べて
induction
を呼び出すことです:theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by induction t with | leaf => skip | branch l x r ihl ihr => skip
基本ケースは葉のコピーの数の計上は、もとの葉の数の計上と同じということを述べています:
unsolved goals case leaf α : Type ⊢ count (mirror leaf) = count leaf
帰納法のステップでは、左と右の部分木のコピーをしても枝の数に影響がないという仮定を許し、これらの部分木を含んだ木をコピーしても全体の枝の数が保たれるという証明を要求します:
unsolved goals case branch α : Type l : BinTree α x : α r : BinTree α ihl : count (mirror l) = count l ihr : count (mirror r) = count r ⊢ count (mirror (branch l x r)) = count (branch l x r)
基本ケースは真です。というのも
leaf
をコピーするとleaf
になり、式の左右は定義上同値になるからです。これはsimp
を使ってBinTree.mirror
を展開する命令で表現できます:theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by induction t with | leaf => simp [BinTree.mirror] | branch l x r ihl ihr => skip
帰納法のステップでは、ゴールの中に帰納法の仮定とすぐに一致するものはありません。
BinTree.count
とBinTree.mirror
の定義を使って単純化すると、関係性が明らかになります:theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by induction t with | leaf => simp [BinTree.mirror] | branch l x r ihl ihr => simp [BinTree.mirror, BinTree.count]
unsolved goals case branch α : Type l : BinTree α x : α r : BinTree α ihl : count (mirror l) = count l ihr : count (mirror r) = count r ⊢ 1 + count (mirror r) + count (mirror l) = 1 + count l + count r
帰納法の仮定がどちらもゴールの左辺を右辺の近いものに書き換えるために使うことができます:
theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by induction t with | leaf => simp [BinTree.mirror] | branch l x r ihl ihr => simp [BinTree.mirror, BinTree.count] rw [ihl, ihr]
unsolved goals case branch α : Type l : BinTree α x : α r : BinTree α ihl : count (mirror l) = count l ihr : count (mirror r) = count r ⊢ 1 + count r + count l = 1 + count l + count r
simp_arith
タクティクはsimp
に追加で算術的な等式を使用できるようにしたもので、この目標を証明するにあたって十分であり、以下を出力します:theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by induction t with | leaf => simp [BinTree.mirror] | branch l x r ihl ihr => simp [BinTree.mirror, BinTree.count] rw [ihl, ihr] simp_arith
展開される定義に加えて、単純化器は証明ゴールを単純化する間に書き換えとして使用する等式証明の名前を渡すこともできます。
BinTree.mirror_count
は以下のように書くこともできます:theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by induction t with | leaf => simp [BinTree.mirror] | branch l x r ihl ihr => simp_arith [BinTree.mirror, BinTree.count, ihl, ihr]
証明が複雑になると、手作業で仮定を列挙することは面倒になります。さらに、手作業で仮定の名前を書くと複数のサブゴールの証明ステップでの再利用が難しくなります。
simp
やsimp_arith
に*
という引数を与えることで、ゴールを単純化したり解いたりする際に、全ての 仮定を使用するように指示することができます。つまり、証明は以下のようにも書けます:theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by induction t with | leaf => simp [BinTree.mirror] | branch l x r ihl ihr => simp_arith [BinTree.mirror, BinTree.count, *]
どちらの分岐も単純化器を使用しているため、証明は以下のように簡約されます:
theorem BinTree.mirror_count (t : BinTree α) : t.mirror.count = t.count := by induction t <;> simp_arith [BinTree.mirror, BinTree.count, *]
演習問題
induction ... with
タクティクを使ってplusR_succ_left
を証明してください。plus_succ_left
の証明を<;>
を使って一行になるよう書き換えてください。- リストについての帰納法を使って、リストの連結が結合的であることを証明してください:
theorem List.append_assoc (xs ys zs : List α) : xs ++ (ys ++ zs) = (xs ++ ys) ++ zs
プログラミング・証明・パフォーマンス
この章ではプログラミングについて述べます。プログラムは正しい結果を計算する必要がありますが、同時に効率的である必要もあります。効率的な関数型プログラムを書くためには、データ構造の適切な使い方と、プログラムを実行するために必要な時間と空間について考える方法の両方を知ることが重要です。
またこの章では証明についても述べます。Leanの効率的なプログラミングにおいて最も重要なデータ構造の1つは配列ですが、配列を安全に使用するには配列の添字が境界内にあることを証明する必要があります。さらに、配列に関する興味深いアルゴリズムのほとんどは構造的再帰のパターンに従いません、その代わりに配列上の繰り返しになります。これらのアルゴリズムは必ず終了しますが、Leanはこれを自動的にチェックできるとは限りません。プログラムが終了する理由を示すために証明を利用することができます。
高速化のためにプログラムを書き直すと理解しにくいコードになることが多いものです。証明はまた、2つのプログラムが異なるアルゴリズムや実装技術を使っていても常に同じ答えを計算することを示すことができます。このように、遅くて簡単なプログラムを、早くて複雑なバージョンの仕様書としての役割を果たすようにすることができます。
証明とプログラミングを組み合わせることで、プログラムを安全かつ効率的にすることができます。証明は実行時の境界チェックの省略を可能にし、多くのテストを不要にします。また、実行時のパフォーマンスのオーバーヘッドを発生させることなく、プログラムに対する極めて高い信頼性を提供してくれます。しかし、プログラムに関する定理の証明には時間とコストがかかるため、他のツールの方が経済的な場合が多いものです。
対話的な定理証明は奥の深いトピックです。この章ではLeanでプログラミングしているときに実際に出てくる証明を中心にしつつ、味見だけにとどまります。ほとんどの興味深い定理はプログラミングと密接な関係を持ちません。さらに学ぶための教材のリストについては「次のステップ」 を参照してください。しかし、プログラミングを学ぶ時と同じように、証明の書き方の学習にあたっては実体験に勝るものはありません!
末尾再帰
Leanの
do
記法によってfor
やwhile
などの伝統的な繰り返し構文が使えるようになりますが、裏ではこれらの構文は再帰関数の呼び出しに変換されています。ほとんどのプログラミング言語では、再帰関数はループに対して重要な欠点を持っています:ループはスタック上の領域を消費しませんが、再帰関数は再帰呼び出しの数に比例してスタック領域を消費します。スタック領域は一般的に限られているため、再帰関数として自然に表現されるアルゴリズムを、明示的に可変で割り当てられたヒープ領域でのループに書き直す必要がしばしばあります。関数型プログラミングではその逆が一般的です。可変状態を持つループとして自然に表現されるプログラムはスタック領域を消費するかもしれませんが、再帰関数に書き換えれば高速に実行できます。これは関数型プログラミング言語の重要な側面によるものです:すなわち 末尾呼び出しの除去 (tail-call elimination)です。末尾呼び出しとはある関数から別の関数への呼び出しの中でも、呼び出し時に新しいスタックフレームをプッシュするのではなく、現在のスタックフレームに置き換えることで通常のジャンプにコンパイルできるものを指します。
末尾呼び出しの除去は単なるオプショナルな最適化ではありません。その存在は効率的な関数型コードを書くための基礎的な部分です。この機能が有用であるためには、信頼できるものでなければなりません。プログラマは確実に末尾呼び出しを特定できなければならず、コンパイラによる末尾呼び出しの除去が信頼できなければなりません。
関数
NonTail.sum
はNat
のリストの内容を加算します:def NonTail.sum : List Nat → Nat | [] => 0 | x :: xs => x + sum xs
この関数をリスト
[1, 2, 3]
に適用すると、次のような評価のステップの流れになります:NonTail.sum [1, 2, 3] ===> 1 + (NonTail.sum [2, 3]) ===> 1 + (2 + (NonTail.sum [3])) ===> 1 + (2 + (3 + (NonTail.sum []))) ===> 1 + (2 + (3 + 0)) ===> 1 + (2 + 3) ===> 1 + 5 ===> 6
この評価ステップにおいて、括弧は
NonTail.sum
の再帰呼び出しを示しています。言い換えると、3つの数値を足すには、このプログラムは最初にリストが空でないことをチェックしなければなりません。リストの先頭(1
)とリストの後続の和を足すには、まずリストの後続の和を計算する必要があります:1 + (NonTail.sum [2, 3])
しかし、リストの後続の和を計算するためには、プログラムはそれが空かどうかをチェックしなければなりません。そしてこれは空ではなく、後続のリストの先頭は
2
です。上記の結果のステップではNonTail.sum [3]
の結果が返ることを待ちます:1 + (2 + (NonTail.sum [3]))
実行時の呼び出しのスタックの要点は、値
1
・2
・3
とそれらを再帰呼び出しの結果に加算する命令を追跡することです。再帰呼び出しが完了すると、制御が呼び出しを行ったスタックフレームに戻り、加算の各ステップが実行されます。リストの先頭とそれらの加算の命令を格納した領域は解放されません;これはリストの長さに比例した領域を占めます。関数
Tail.sum
もNat
のリストの内容を加算します:def Tail.sumHelper (soFar : Nat) : List Nat → Nat | [] => soFar | x :: xs => sumHelper (x + soFar) xs def Tail.sum (xs : List Nat) : Nat := Tail.sumHelper 0 xs
これをリスト
[1, 2, 3]
に適用すると、次のような評価の流れになります:Tail.sum [1, 2, 3] ===> Tail.sumHelper 0 [1, 2, 3] ===> Tail.sumHelper (0 + 1) [2, 3] ===> Tail.sumHelper 1 [2, 3] ===> Tail.sumHelper (1 + 2) [3] ===> Tail.sumHelper 3 [3] ===> Tail.sumHelper (3 + 3) [] ===> Tail.sumHelper 6 [] ===> 6
内部の補助関数は自分自身を再帰的に呼び出しますが、最終的な結果を計算するために何も覚えておく必要はありません。
Tail.sumHelper
の中間呼び出しは再帰呼び出しの結果をそのまま返すだけであるため、Tail.sumHelper
が基本ケースに到達すると制御を直接Tail.sum
に戻すことができます。つまり、Tail.sumHelper
を再帰的に呼び出すたびに1つのスタックフレームを再利用することができます。末尾呼び出しの除去とはまさにこのスタックフレームの再利用のことであり、Tail.sumHelper
は 末尾再帰関数 と呼ばれます。Tail.sumHelper
の最初の引数にはコールスタックで追跡すべき情報がすべて含まれています。すなわち、遭遇してきた数値をすべて加算した値です。各再帰呼び出しでは、コールスタックに新しい情報を追加するのではなく、この引数が新しい情報で更新されます。コールスタックの情報を置き換えるsoFar
のような引数は アキュムレータ (accumulator)と呼ばれます。この記事を書いている時点と筆者のコンピュータでは、216,856以上の要素を持つリストを渡すと、
NonTail.sum
はスタックオーバーフローでクラッシュします。一方で、Tail.sum
は100,000,000個の要素を持つリストでもスタックオーバーフローを起こすことなく合計を計算することができます。Tail.sum
の実行中に新しいスタックフレームをプッシュする必要がないため、現在のリストを保持する可変な変数を持つwhile
ループと完全に等価です。再帰呼び出しの度に、スタック上の関数の引数はリストの次のノードに置き換えられます。末尾位置と非末尾位置
Tail.sumHelper
が末尾再帰である理由は、再帰呼び出しが 末尾の位置 にあるからです。非形式的には、関数呼び出しが末尾の位置にあるのは、呼び出し元が戻り値を変更する必要がなく、そのまま返す場合です。より形式的には、末尾位置は式に対して明示的に定義することができます。もし
match
式が末尾の位置であれば、その各ブランチも末尾の位置となります。一度match
がブランチを選択すると、制御はすぐにそのブランチに進みます。同様に、if
式が末尾にある場合、if
式の両方のブランチも末尾の位置となります。最後に、let
式が末尾にある場合は、その本体も末尾となります。これ以外の他のすべての位置は末尾の位置とはなりません。関数やコンストラクタの引数は末尾の位置ではありません。なぜなら、評価は引数の値に適用される関数やコンストラクタを追跡しなければならないからです。内部関数の本体も末尾の位置ではありません。というのも制御が渡されないかもしれないからです:すなわち、関数の本体は関数が呼び出されるまで評価されないからです。同様に、関数型の本体も末尾の位置ではありません。
E
in(x : α) → E
を評価するためには、結果として得られる型が(x : α) → ...
で囲まれていることを追跡する必要があります。NonTail.sum
では、再帰呼び出しは+
の引数であるため末尾の位置ではありません。Tail.sumHelper
ではパターンマッチ(これ自体が関数の本体である)の直下であるため、この再帰呼び出しは末尾の位置です。この記事を書いている時点では、Leanは再帰関数内の直接の末尾呼び出しのみを除去します。つまり、ある関数
f
の定義におけるf
への末尾呼び出しは除去されますが、他の関数g
への末尾呼び出しは除去されません。スタックフレームを節約して他の関数への末尾呼び出しを除去することは可能ですが、Leanではまだ実装されていません。リストの反転
関数
NonTail.reverse
は各サブリストの先頭を結果の末尾に追加することでリストを反転させます:def NonTail.reverse : List α → List α | [] => [] | x :: xs => reverse xs ++ [x]
これを
[1, 2, 3]
に使用して反転させると、次のような評価の流れになります:NonTail.reverse [1, 2, 3] ===> (NonTail.reverse [2, 3]) ++ [1] ===> ((NonTail.reverse [3]) ++ [2]) ++ [1] ===> (((NonTail.reverse []) ++ [3]) ++ [2]) ++ [1] ===> (([] ++ [3]) ++ [2]) ++ [1] ===> ([3] ++ [2]) ++ [1] ===> [3, 2] ++ [1] ===> [3, 2, 1]
末尾再帰版では各ステップでのアキュムレータに対して
· ++ [x]
の代わりにx :: ·
を用います:def Tail.reverseHelper (soFar : List α) : List α → List α | [] => soFar | x :: xs => reverseHelper (x :: soFar) xs def Tail.reverse (xs : List α) : List α := Tail.reverseHelper [] xs
これは
NonTail.reverse
で計算している間に各スタックフレームに保存されたコンテキストが基本ケースに至って初めて適用されるためです。コンテキストの各「記憶された」断片は、後入れ先出しの順に実行されます。一方で、アキュムレータ渡し版では、以下の簡約ステップの流れからわかるように、元の基本ケースではなくリストの最初の要素からアキュムレータを更新します:Tail.reverse [1, 2, 3] ===> Tail.reverseHelper [] [1, 2, 3] ===> Tail.reverseHelper [1] [2, 3] ===> Tail.reverseHelper [2, 1] [3] ===> Tail.reverseHelper [3, 2, 1] [] ===> [3, 2, 1]
つまり、非末尾再帰版は基本ケースから開始し、対象のリストを右から左に走査して再帰の結果を更新します。これらのリストの要素は先入先出の順序でアキュムレータに影響を与えます。アキュムレータを使用する末尾再帰版はリストの先頭から開始し、リストを左から右へ走査し、アキュムレータの初期値を更新します。
加算は可換であるため、
Tail.sum
ではこの点を考慮する必要はありません。リストの結合は可換ではないため、逆方向に実行しても同じ効果を持つ演算を見つけるように注意しなければなりません。NonTail.reverse
の再帰の結果の後に[x]
を追加することは、反転されたリストの先頭にx
を追加することに似ています。複数の再帰呼び出し
BinTree.mirror
の定義では、2つの再帰呼び出しがあります:def BinTree.mirror : BinTree α → BinTree α | .leaf => .leaf | .branch l x r => .branch (mirror r) x (mirror l)
命令型言語が
reverse
やsum
のような関数にwhileループを使うように、この種の走査には再帰関数を使うことが一般的です。この関数はアキュムレータを渡すスタイルを使って末尾再帰的に書き換えることは簡単にはできません。通常、各再帰ステップに1回より多い再帰呼び出しが必要な場合、アキュムレータを渡すスタイルを使用することは難しいです。この難しさは、再帰関数をループと明示的なデータ構造を使用するように書き換える難しさと似ており、さらに関数が終了することをLeanに納得させる複雑さも備わっています。しかし、
BinTree.mirror
のように複数の再帰呼び出しは、それ自体が複数回再帰的に出現するコンストラクタを持つデータ構造を示すことが多いです。このような場合、構造体の深さは全体のサイズに対して対数になることが多く、スタックとヒープのトレードオフが小さくなります。これらの関数を末尾再帰にするための体系的なテクニックとして 継続渡しスタイル (continuation-passing style)を使用するなどの方法がありますが、この章の範囲外です。演習問題
以下の非末尾再帰関数をそれぞれアキュムレータを渡すスタイルの末尾再帰関数に変換してください:
def NonTail.length : List α → Nat | [] => 0 | _ :: xs => NonTail.length xs + 1
def NonTail.factorial : Nat → Nat | 0 => 1 | n + 1 => factorial n * (n + 1)
NonTail.filter
の変換では、末尾再帰によって一定のスタック領域と入力リストの長さに線形な時間を要するプログラムになるはずです。オリジナルに対して一定のオーバーヘッドは許容されます:def NonTail.filter (p : α → Bool) : List α → List α | [] => [] | x :: xs => if p x then x :: filter p xs else filter p xs
同値の証明
あるプログラムを末尾再帰とアキュムレータを使うように書き直すと、元のプログラムとはかなり異なった見た目になることがあります。オリジナルの再帰関数の方が大抵理解しやすいですが、実行時にスタックを使い果たしてしまう危険性があります。両方のバージョンのプログラムをいくつかの例でテストして単純なバグを取り除いた後に、証明を使ってこれらのプログラムが同値であることをはっきりと示すことができます。
sum
の等価の証明sum
についてこの両方のバージョンが等しいことを証明するには、まずスタブの証明で定理の文を書き始めます:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by skip
想定されるようにLeanは未解決のゴールを表示します:
unsolved goals ⊢ NonTail.sum = Tail.sum
NonTail.sum
とTail.sum
は定義上同値ではないため、ここではrfl
タクティクを使うことはできません。しかし、関数が等しいと言えるのは定義上の同値だけではありません。同じ入力に対して同じ出力を生成することを証明することで、2つの関数が等しいことを証明することも可能です。言い換えると、\( f = g \) はすべての可能な入力 \( x \) に対して \( f(x) = g(x) \) を示すことで証明できます。この原理は 関数の外延性 (function extensionality)と呼ばれます。関数の外延性はまさにNonTail.sum
がTail.sum
と等しいことを説明します:どちらも数値のリストを合計を計算するからです。Leanのタクティク言語では、関数の外延性は
funext
を使うことで呼び出され、その後に任意の引数に使われる名前が続きます。この任意の引数はコンテキスト中に仮定として追加され、ゴールはこの引数に適用される関数が等しいことの証明を要求するものへと変化します:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs
unsolved goals case h xs : List Nat ⊢ NonTail.sum xs = Tail.sum xs
このゴールは引数
xs
の帰納法によって証明できます。どちらのsum
関数も、空のリストに適用すると基本ケースに対応する0
を返します。入力リストの先頭に数値を追加すると、両方の関数でその数値を計算結果に加算します。これは帰納法のステップに対応します。induction
タクティクを実行すると、2つのゴールが得られます:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs induction xs with | nil => skip | cons y ys ih => skip
unsolved goals case h.nil ⊢ NonTail.sum [] = Tail.sum []
unsolved goals case h.cons y : Nat ys : List Nat ih : NonTail.sum ys = Tail.sum ys ⊢ NonTail.sum (y :: ys) = Tail.sum (y :: ys)
nil
に対応する基本ケースはrfl
を使って解決できます。というのも、どちらの関数も空のリストを渡すと0
を返すからです:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs induction xs with | nil => rfl | cons y ys ih => skip
帰納法のステップを解く最初のステップはゴールを単純化することで、
simp
によってNonTail.sum
とTail.sum
を展開します:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs induction xs with | nil => rfl | cons y ys ih => simp [NonTail.sum, Tail.sum]
unsolved goals case h.cons y : Nat ys : List Nat ih : NonTail.sum ys = Tail.sum ys ⊢ y + NonTail.sum ys = Tail.sumHelper 0 (y :: ys)
Tail.sum
を展開することで処理がTail.sumHelper
に即座に委譲されていることがわかり、これもまた単純化されるべきです:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs induction xs with | nil => rfl | cons y ys ih => simp [NonTail.sum, Tail.sum, Tail.sumHelper]
これらによって、ゴールでは
sumHelper
の計算のステップが進み、アキュムレータにy
が加算されます:unsolved goals case h.cons y : Nat ys : List Nat ih : NonTail.sum ys = Tail.sum ys ⊢ y + NonTail.sum ys = Tail.sumHelper y ys
帰納法の仮定で書き換えを行うことで、ゴールから
NonTail.sum
のすべての言及が削除されます:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs induction xs with | nil => rfl | cons y ys ih => simp [NonTail.sum, Tail.sum, Tail.sumHelper] rw [ih]
unsolved goals case h.cons y : Nat ys : List Nat ih : NonTail.sum ys = Tail.sum ys ⊢ y + Tail.sum ys = Tail.sumHelper y ys
この新しいゴールは、あるリストの和にある数を加えることは、
sumHelper
の最初のアキュムレータとしてその数を使うことと同じであるということを述べています。わかりやすくするために、この新しいゴールは別の定理として証明することができます:theorem helper_add_sum_accum (xs : List Nat) (n : Nat) : n + Tail.sum xs = Tail.sumHelper n xs := by skip
unsolved goals xs : List Nat n : Nat ⊢ n + Tail.sum xs = Tail.sumHelper n xs
繰り返しになりますが、これは帰納法による証明であり、基本ケースは
rfl
を使っています:theorem helper_add_sum_accum (xs : List Nat) (n : Nat) : n + Tail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => rfl | cons y ys ih => skip
unsolved goals case cons n y : Nat ys : List Nat ih : n + Tail.sum ys = Tail.sumHelper n ys ⊢ n + Tail.sum (y :: ys) = Tail.sumHelper n (y :: ys)
これは帰納法のステップであるため、ゴールは帰納法の仮定
ih
と一致するまで単純化する必要があります。Tail.sum
とTail.sumHelper
の定義を使って単純化すると、以下のようになります:theorem helper_add_sum_accum (xs : List Nat) (n : Nat) : n + Tail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => rfl | cons y ys ih => simp [Tail.sum, Tail.sumHelper]
unsolved goals case cons n y : Nat ys : List Nat ih : n + Tail.sum ys = Tail.sumHelper n ys ⊢ n + Tail.sumHelper y ys = Tail.sumHelper (y + n) ys
理想的には、帰納法の仮定を使って
Tail.sumHelper (y + n) ys
を置き換えることができますが、両者は一致しません。この帰納法の仮定はTail.sumHelper n ys
には使えますが、Tail.sumHelper (y + n) ys
には使えません。つまり、この証明は行き詰ってしまいます。再挑戦
この方針でなんとか頑張る代わりに、ここで一歩引いて考えてみましょう。なぜこの関数の末尾再帰バージョンと非末尾再帰バージョンは等しいのでしょうか?根本的に言えばリストの各要素について、再帰版の結果に加算されるのと同じ量だけアキュムレータが増えます。この洞察によってエレガントな証明を書くことができます。重要なのは、帰納法による証明は帰納法の仮定を 任意の アキュムレータ値に適用できるように設定しなければならないということです。
先ほどの試みは置いておいて、上記の洞察は次の文にエンコードされます:
theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by skip
この文において、
n
がコロンの後にある型の一部であることが非常に重要です。この結果、ゴールは「全てのn
について~」の略である∀ (n : Nat)
で始まります。unsolved goals xs : List Nat ⊢ ∀ (n : Nat), n + NonTail.sum xs = Tail.sumHelper n xs
帰納法のタクティクを用いると、この「全ての~について~」という文を含んだゴールができます:
theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => skip | cons y ys ih => skip
nil
のケースでは、ゴールは次のようになります:unsolved goals case nil ⊢ ∀ (n : Nat), n + NonTail.sum [] = Tail.sumHelper n []
cons
の帰納法のステップでは、帰納法の仮定と具体的なゴールの両方に「全てのn
について~」が含まれます:unsolved goals case cons y : Nat ys : List Nat ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys ⊢ ∀ (n : Nat), n + NonTail.sum (y :: ys) = Tail.sumHelper n (y :: ys)
言い換えると、ゴールの証明はより難しくなりましたが、これに応じて帰納法の仮定はより有用なものになったということです。
「全ての \( x \) について~」で始まる文の数学的な証明はある任意の \( x \) を仮定してその文を証明します。ここで「任意」という言葉は \( x \) について追加の性質を何も仮定しないことを意味し、これによって文は どんな \( x \) について成立します。Leanでは、「全ての~について~」文は依存型の関数です:これに適用されるどんな値に対しても、この命題の根拠を返します。同様に、任意の \( x \) を選ぶプロセスは
fun x => ...
を使うことと同じです。タクティク言語においては、任意の \( x \) を選ぶこの処理はintro
タクティクを使って実行されます。これは裏ではタクティクのスクリプトが完成したタイミングで関数を生成します。intro
タクティクにはこの任意の値に使用する名前を指定します。nil
のケースでintro
タクティクを使うことでゴールから∀ (n : Nat),
が取り除かれ、仮定n : Nat
が追加されます:theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => intro n | cons y ys ih => skip
unsolved goals case nil n : Nat ⊢ n + NonTail.sum [] = Tail.sumHelper n []
この命題の同値の両辺は、定義上
n
に同値であるため、rfl
で十分です:theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => intro n rfl | cons y ys ih => skip
cons
のゴールにも「全ての~について~」が含まれます:unsolved goals case cons y : Nat ys : List Nat ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys ⊢ ∀ (n : Nat), n + NonTail.sum (y :: ys) = Tail.sumHelper n (y :: ys)
このことから
intro
を使う必要があります。theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => intro n rfl | cons y ys ih => intro n
unsolved goals case cons y : Nat ys : List Nat ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys n : Nat ⊢ n + NonTail.sum (y :: ys) = Tail.sumHelper n (y :: ys)
これでこの証明のゴールは
NonTail.sum
とTail.sumHelper
の両方をy :: ys
に適用したものとなります。単純化することで次のステップをより明確にすることができます。theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => intro n rfl | cons y ys ih => intro n simp [NonTail.sum, Tail.sumHelper]
unsolved goals case cons y : Nat ys : List Nat ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys n : Nat ⊢ n + (y + NonTail.sum ys) = Tail.sumHelper (y + n) ys
このゴールは帰納法の仮定に非常に近いです。一致しない点は2つです:
- この等式の左辺は
n + (y + NonTail.sum ys)
ですが、帰納法の仮定では左辺はNonTail.sum ys
に何かしらの数値を足した数であることが求められています。言い換えれば、このゴールは(n + y) + NonTail.sum ys
と書き直されるべきで、これは自然数の足し算が結合的であることから成り立ちます。 - 左辺を
(n + y) + NonTail.sum ys
と書き換えた1場合、右辺のアキュムレータの引数はy + n
ではなくn + y
とする必要があります。足し算は可換でもあるため、この書き換えも成り立ちます。
足し算の結合性と可換性はLeanの標準ライブラリですでに証明されています。結合性の証明は
Nat.add_assoc
という名前で(n m k : Nat) → (n + m) + k = n + (m + k)
という型を持ち、可換性の証明はNat.add_comm
という名前で(n m : Nat) → n + m = m + n
という型を持ちます。通常、rw
タクティクには型が同値である式を指定します。しかし、引数が等式を返す依存型の関数である場合、等式がゴールの何かとマッチするようにその関数の引数を見つけようとします。ここでは結合性が適用できる可能性のある個所はただ1つですが、等式Nat.add_assoc
の右辺が証明のゴールにマッチするため、書き換えの方向は逆にしなければなりません:theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => intro n rfl | cons y ys ih => intro n simp [NonTail.sum, Tail.sumHelper] rw [←Nat.add_assoc]
unsolved goals case cons y : Nat ys : List Nat ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys n : Nat ⊢ n + y + NonTail.sum ys = Tail.sumHelper (y + n) ys
しかし、
Nat.add_comm
で直接書き換えすると間違った結果になります。ここでのrw
タクティクは間違った書き換え場所を推測しており、意図しないゴールに導きます:theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => intro n rfl | cons y ys ih => intro n simp [NonTail.sum, Tail.sumHelper] rw [←Nat.add_assoc] rw [Nat.add_comm]
unsolved goals case cons y : Nat ys : List Nat ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys n : Nat ⊢ NonTail.sum ys + (n + y) = Tail.sumHelper (y + n) ys
これは
Nat.add_comm
の引数としてy
とn
を明示的に提示することで解決します:theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => intro n rfl | cons y ys ih => intro n simp [NonTail.sum, Tail.sumHelper] rw [←Nat.add_assoc] rw [Nat.add_comm y n]
unsolved goals case cons y : Nat ys : List Nat ih : ∀ (n : Nat), n + NonTail.sum ys = Tail.sumHelper n ys n : Nat ⊢ n + y + NonTail.sum ys = Tail.sumHelper (n + y) ys
これでゴールは帰納法の仮定にマッチするようになりました。特に、帰納法の仮定の型は依存型の関数型です。
ih
をn + y
に適用すると、期待していた型そのものになります。exact
タクティクはその引数が正確に望みの型である場合、証明のゴールを完成させます:theorem non_tail_sum_eq_helper_accum (xs : List Nat) : (n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs := by induction xs with | nil => intro n; rfl | cons y ys ih => intro n simp [NonTail.sum, Tail.sumHelper] rw [←Nat.add_assoc] rw [Nat.add_comm y n] exact ih (n + y)
実際の証明では、ゴールをこのような補助的な定義の型と一致させるためにはさらにほんの少しだけ追加作業が必要になります。最初のステップは、ここでも関数の外延性を呼び出すことです:
theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs
unsolved goals case h xs : List Nat ⊢ NonTail.sum xs = Tail.sum xs
次のステップは
Tail.sum
を展開し、Tail.sumHelper
を公開することです:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs simp [Tail.sum]
unsolved goals case h xs : List Nat ⊢ NonTail.sum xs = Tail.sumHelper 0 xs
こうすることで、型はほとんど一致します。しかし、補助定理では左辺に追加で加算を行っています。言い換えると、証明のゴールは
NonTail.sum xs = Tail.sumHelper 0 xs
ですが、non_tail_sum_eq_helper_accum
をxs
と0
に適用すると0 + NonTail.sum xs = Tail.sumHelper 0 xs
という型が得られます。ここで標準ライブラリには(n : Nat) → 0 + n = n
という型を持つNat.zero_add
という証明があります。この関数をNonTail.sum xs
に適用すると式は0 + NonTail.sum xs = NonTail.sum xs
という型になり、これで右辺から左辺への書き換えによって望みの型が得られます:theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs simp [Tail.sum] rw [←Nat.zero_add (NonTail.sum xs)]
unsolved goals case h xs : List Nat ⊢ 0 + NonTail.sum xs = Tail.sumHelper 0 xs
最終的に、この補助定理によって証明が完成します:
theorem non_tail_sum_eq_tail_sum : NonTail.sum = Tail.sum := by funext xs simp [Tail.sum] rw [←Nat.zero_add (NonTail.sum xs)] exact non_tail_sum_eq_helper_accum xs 0
この証明はアキュムレータを渡す版の末尾再帰関数が非末尾再帰版と等しいことを証明する時に使用できる一般的なパターンを示しています。最初のステップでは、開始時のアキュムレータの引数と最終結果の関係を発見することです。例えば、
Tail.sumHelper
をn
のアキュムレータで開始すると、最終的な合計にn
が加算され、Tail.reverseHelper
をys
のアキュムレータで開始すると、最終的な反転されたリストがys
の前に追加されます。2つ目のステップは、この関係を定理として書き出し、帰納法によって証明することです。実際にはアキュムレータは常に0
や[]
などの中立な値で初期化されますが、開始時のアキュムレータを任意の値にすることができるこの一般的な文は、十分に強力な帰納法の仮定を得るために必要なものです。最後に、この補助定理を実際のアキュムレータの初期値で使用すると望ましい証明が得られます。例えば、non_tail_sum_eq_tail_sum
ではアキュムレータは0
が指定されます。この場合、中立的なアキュムレータの初期値が適切な場所で発生するようにゴールを書き換える必要があるかもしれません。演習問題
準備運動
Nat.zero_add
・Nat.add_assoc
・Nat.add_comm
の証明をinduction
タクティクを使って自分で書いてみましょう。アキュムレータについて他の証明
リストの反転
sum
についての証明をNonTail.reverse
とTail.reverse
の証明に適用してください。最初のステップはTail.reverseHelper
に渡されるアキュムレータの値と非末尾再帰的な反転の間の関係を考えることです。ちょうどTail.sumHelper
でアキュムレータに数値を追加することが全体の合計に追加することと同じように、Tail.reverseHelper
でアキュムレータに新しい要素を追加するためにList.cons
を使用することは全体の結果に何らかの変更を加えることと同じです。関係性がはっきりするまで、紙と鉛筆を使って3,4例の異なるアキュムレータの値を試してください。この関係を使って適切な補助定理を証明してください。それから全体の証明を書き下してください。NonTail.reverse
とTail.reverse
は多相であるため、両者が等価であることを示すにはLeanがα
にどのような型を使うべきかを考えさせないために@
を使う必要があります。いったんα
が通常の引数として扱われると、funext
はα
とxs
の両方で呼び出されるようになります:theorem non_tail_reverse_eq_tail_reverse : @NonTail.reverse = @Tail.reverse := by funext α xs
これによって適切なゴールが生まれます:
unsolved goals case h.h α : Type u_1 xs : List α ⊢ NonTail.reverse xs = Tail.reverse xs
階乗
アキュムレータと結果の関係を見つけ、適切な補助定理を証明することによって、前の節の練習問題で出てきた
NonTail.factorial
が読者の末尾再帰版の解答と等しいことを証明してください。1原文では
(y + n) + NonTail.sum ys
となっているが、おそらくy
とn
の位置が逆になっている配列と関数の停止
効率的なコードを書くためには、適切なデータ構造を選択することが重要です。連結リストには利点があります:アプリケーションによっては、リストの末尾を共有できることが非常に重要な場合もあります。しかし、可変長のシーケンシャルなデータコレクションを使用する場合、メモリのオーバーヘッドが少なく、局所性にも優れた配列の方が大体の場合において適しています。
しかし、配列はリストに対して2つの欠点を持っています:
- 配列はパタンマッチではなくインデックスによってアクセスされます。これにあたっては安全性を維持するために 証明の義務 が課せられます。
- 配列全体を左から右に処理するループは末尾再帰関数ですが、呼び出すたびに減少する引数を持ちません。
配列を効果的に使うには、配列のインデックスが範囲内にあることをLeanに証明する方法と、配列のインデックスが配列のサイズに到達した際にプログラムが終了することの証明の方法を知る必要があります。これらはどちらも命題の等式ではなく、不等式の命題を使って表現されます。
不等式
型によって順序の概念が異なるため、不等式は
LE
とLT
と呼ばれる2つの型クラスによって管理されています。標準型クラス の節での表は、これらのクラスが以下の構文とどのように関係しているかを説明します:式 脱糖後の式 型クラス名 x < y
LT.lt x y
LT
x ≤ y
LE.le x y
LE
x > y
LT.lt y x
LT
x ≥ y
LE.le y x
LE
言い換えると、ある型において
<
と≤
演算子の意味をカスタマイズすることができ、>
と≥
は<
と≤
から意味を派生させることができます。クラスLT
とLE
はBool
値ではなく命題を返すメソッドを持ちます:class LE (α : Type u) where le : α → α → Prop class LT (α : Type u) where lt : α → α → Prop
LE
のNat
についてのインスタンスはNat.le
に委譲されています:instance : LE Nat where le := Nat.le
Nat.le
を定義するにはまだ紹介していないLeanの特徴が必要です:帰納的に定義された関係です。帰納的に定義された命題・述語・関係
Nat.le
は 帰納的に定義された関係 (inductively-defined relation)です。inductive
は新しいデータ型を作ることに使われるのと同じように、これは新しい命題を作るのにも使われます。命題が引数を取る場合、これは 述語 (predicate)とよばれ、引数に対して真であるものが全てでなくいくつかだけだったりします。複数の引数を取る命題は 関係 (relation)と呼ばれます。帰納的に定義された命題の各コンストラクタは、その命題を証明するための方法です。言い換えれば、命題の宣言はそれが真であることを証明する様々な形式を記述しています。1つのコンストラクタを持つ引数のない命題の証明は非常に簡単です:
inductive EasyToProve : Prop where | heresTheProof : EasyToProve
この証明は以下のコンストラクタから構成されます:
theorem fairlyEasy : EasyToProve := by constructor
命題
True
もまた簡単に証明できますが、実はEasyToProve
と同じように定義されています:inductive True : Prop where | intro : True
引数を取らない帰納的に定義された命題は帰納的に定義されたデータ型よりは面白みに欠けます。というのもデータはそれ自体の正しさに興味があるからです。例えば自然数
3
と35
は異なりますし、ピザを3枚注文して、30分後に35枚届いたら腹が立つでしょう。命題のコンストラクタはその命題が真になりうる方法を記述していますが、命題が証明されてしまえば、どの コンストラクタが使われたかを知る必要はありません。これがProp
の宇宙で興味深い帰納的に定義される型のほとんどが引数を取る理由です。帰納的に定義された述語
IsThree
はその引数が3であることを示します:inductive IsThree : Nat → Prop where | isThree : IsThree 3
ここで使用されるメカニズムは
HasCol
のような添字族 と同様ですが、結果として得られる型は利用可能なデータではなく証明可能な命題です。この述語を使って、3が本当に3であることを証明できます:
theorem three_is_three : IsThree 3 := by constructor
同様に、
IsFive
は引数が5
であることを示す述語です:inductive IsFive : Nat → Prop where | isFive : IsFive 5
もしある数値が3であるなら、これに2を加えると結果は5になるはずです。これは定理の文として表すことができます:
theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by skip
結果のゴールは関数型を持ちます:
unsolved goals n : Nat ⊢ IsThree n → IsFive (n + 2)
そのため、
intro
タクティクによって引数を仮定に変換できます:theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by intro three
unsolved goals n : Nat three : IsThree n ⊢ IsFive (n + 2)
n
が3であるという仮定を使って、IsFive
のコンストラクタを使って証明を完成させることができるはずです:theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by intro three constructor
しかし、これはエラーになります:
tactic 'constructor' failed, no applicable constructor found n : Nat three : IsThree n ⊢ IsFive (n + 2)
このエラーは
n + 2
が5
と定義上等しくないために起こります。通常の関数定義では、仮定three
への依存パターンマッチによってn
を3
に絞り込むことができます。依存パターンマッチに相当するタクティクはcases
で、これはinduction
と似た構文を持っています:theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by intro three cases three with | isThree => skip
これによって得られるケースにおいて
n
は3
に絞り込まれます:unsolved goals case isThree ⊢ IsFive (3 + 2)
3 + 2
は5
に定義上等しいため、これでこのコンストラクタを適用可能です:theorem three_plus_two_five : IsThree n → IsFive (n + 2) := by intro three cases three with | isThree => constructor
標準的な偽についての命題
False
はコンストラクタを持ちません。これによって偽を証明するために直接根拠を提供することは不可能になっています。False
の根拠を提供する唯一の方法は、仮定自体が不可能である場合です。これはnomatch
を使って、到達不可能であることが型システムによってわかっているコードをマークすることができることと同様です。証明に関する最初の幕間 で説明したように、否定Not A
はA → False
の略です。Not A
は¬A
と書くこともできます。4は3ではありません:
theorem four_is_not_three : ¬ IsThree 4 := by skip
証明の初期のゴールは
Not
を含みます:unsolved goals ⊢ ¬IsThree 4
これが実際には関数型であることは
simp
を使って明らかにできます:theorem four_is_not_three : ¬ IsThree 4 := by simp [Not]
unsolved goals ⊢ IsThree 4 → False
ゴールは関数型であるため、
intro
を使って引数を仮定に変換することができます。intro
はNot
の定義をのものを展開することができるため、simp
を使う必要はありません。theorem four_is_not_three : ¬ IsThree 4 := by intro h
unsolved goals h : IsThree 4 ⊢ False
この証明では、
cases
タクティクによってゴールが即座に解決されます:theorem four_is_not_three : ¬ IsThree 4 := by intro h cases h
Vect String 2
のパターンマッチにVect.nil
のケースを含める必要がないように、IsThree 4
のケースによる証明にisThree
のケースを含める必要はありません。整数の不等式
Nat.le
の定義にはパラメータと添字が含まれます:inductive Nat.le (n : Nat) : Nat → Prop | refl : Nat.le n n | step : Nat.le n m → Nat.le n (m + 1)
パラメータ
n
は小さい方の数であり、添字はn
以上となるはずの数です。両方の数値が等しい場合はrefl
コンストラクタを使用し、添字がn
より大きい場合はstep
コンストラクタを使用します。根拠の観点からすると、 \( n \leq k \) の証明は \( n + d = k \) 1となるような \( d \) である数を見つけることから構成されます。Leanでは、この証明は
Nat.le.step
の \( d \) についてのインスタンスでラップされたNat.le.refl
コンストラクタから構成されます。各step
コンストラクタはその添字引数に1を加えるため、 \( d \) 回のstep
コンストラクタは大きい数に \( d \) を加算します。例えば、4が7以下である根拠はrefl
を囲む3つのstep
で構成されます:theorem four_le_seven : 4 ≤ 7 := open Nat.le in step (step (step refl))
未満関係は左の数字に1を足すことで定義されます:
def Nat.lt (n m : Nat) : Prop := Nat.le (n + 1) m instance : LT Nat where lt := Nat.lt
4が7未満であるという根拠は
refl
の周りにある2つのstep
で構成されます:theorem four_lt_seven : 4 < 7 := open Nat.le in step (step refl)
これは
4 < 7
が5 ≤ 7
と等しい訳です。停止性の証明
関数
Array.map
は配列を関数で変換し、入力配列の各要素に関数を適用した結果を含んだ新しい配列を返します。これを末尾再帰関数として書くと、出力配列をアキュムレータに渡すような関数に委譲するといういつものパターンになります。アキュムレータは空の配列で初期化されます。アキュムレータを渡す補助関数は配列の現在のインデックスを追跡する引数を取り、これは0
から始まります:def Array.map (f : α → β) (arr : Array α) : Array β := arrayMapHelper f arr Array.empty 0
補助関数は各繰り返しにて、インデックスがまだ範囲内にあるかどうかをチェックする必要があります。もし範囲内であれば、変換された要素をアキュムレータの最後に追加し、インデックスを
1
だけ加算して再度ループする必要があります。そうでない場合は終了してアキュムレータを返します。このコードの初期実装ではLeanが配列のインデックスが有効であることを証明できないため失敗します:def arrayMapHelper (f : α → β) (arr : Array α) (soFar : Array β) (i : Nat) : Array β := if i < arr.size then arrayMapHelper f arr (soFar.push (f arr[i])) (i + 1) else soFar
failed to prove index is valid, possible solutions: - Use `have`-expressions to prove the index is valid - Use `a[i]!` notation instead, runtime check is perfomed, and 'Panic' error message is produced if index is not valid - Use `a[i]?` notation instead, result is an `Option` type - Use `a[i]'h` notation instead, where `h` is a proof that index is valid α : Type ?u.1704 β : Type ?u.1707 f : α → β arr : Array α soFar : Array β i : Nat ⊢ i < Array.size arr
しかし、この条件式ではすでに配列のインデックスの有効性が要求する正確な条件(すなわち
i < arr.size
)をチェックしています。if
に名前を追加することで、配列のインデックスのタクティクが使用できる仮定が追加されるため、問題は解決します:def arrayMapHelper (f : α → β) (arr : Array α) (soFar : Array β) (i : Nat) : Array β := if inBounds : i < arr.size then arrayMapHelper f arr (soFar.push (f arr[i])) (i + 1) else soFar
しかし、Leanはこの修正されたプログラムを受け付けません。なぜなら、再帰呼び出しが入力コンストラクタの1つの引数に対して行われていないからです。実際、アキュムレータもインデックスも縮小するのではなく、むしろ増大します:
fail to show termination for arrayMapHelper with errors argument #6 was not used for structural recursion failed to eliminate recursive application arrayMapHelper f✝ arr (Array.push soFar (f✝ arr[i])) (i + 1) structural recursion cannot be used failed to prove termination, use `termination_by` to specify a well-founded relation
とはいえこの関数は停止するため、単に
partial
とマークするのはあまりに残念です。なぜ
arrayMapHelper
は停止するのでしょうか?各繰り返しはインデックスi
が配列arr
の範囲内にあるかどうかをチェックします。もし真であれば、i
が加算され、ループが繰り返されます。そうでなければプログラムは終了します。arr.size
は有限な値であるため、i
を加算できる回数も有限回です。関数を呼び出すたびに引数が減らない場合でも、arr.size -i
は0に向かって減っていきます。Leanは定義の最後に
termination_by
節を記述することで、停止について別の式を使うように指示することができます。termination_by
節には2つの要素があります:関数の引数の名前と、その名前を使っており各呼び出しのたびに値が減るべき式です。arrayMapHelper
の場合、最終的な定義は以下のようになります:def arrayMapHelper (f : α → β) (arr : Array α) (soFar : Array β) (i : Nat) : Array β := if inBounds : i < arr.size then arrayMapHelper f arr (soFar.push (f arr[i])) (i + 1) else soFar termination_by arrayMapHelper _ arr _ i _ => arr.size - i
同様の停止証明を使って、ある真偽値関数を満たす配列の最初の要素を見つけ、その要素をインデックスの両方を返す関数
Array.find
を書くことができます:def Array.find (arr : Array α) (p : α → Bool) : Option (Nat × α) := findHelper arr p 0
ここでもまた、
i
が増加するとarr.size - i
が減少するのでここでも補助関数は終了します:def findHelper (arr : Array α) (p : α → Bool) (i : Nat) : Option (Nat × α) := if h : i < arr.size then let x := arr[i] if p x then some (i, x) else findHelper arr p (i + 1) else none termination_by findHelper arr p i => arr.size - i
すべての停止引数がこれほど単純であるとは限りません。しかし、関数の引数に基づいで呼び出しのたびに減少する式を特定するという基本的な構造は、全ての停止証明で発生します。時には関数が停止する理由を解明するために想像力が要求されることもありますし、またLeanが停止引数を受け入れるために追加の証明を必要とすることもあります。
演習問題
- 末尾再帰のアキュムレータを渡す関数と
termination_by
節を使って配列に対してForM (Array α)
インスタンスを定義してください。 termination_by
を 必要としない 末尾再帰のアキュムレータを渡す関数を使用して配列を反転させる関数を実装してください。- 恒等モナドの
for ... in ...
ループを使ってArray.map
・Array.find
・ForM
インスタンスを再実装し、結果のコードを比較してください。 - 配列の反転を恒等モナドの
for ... in ...
ループを使って再実装してください。またそれを末尾再帰版と比較してください。
1原文では \( n + d = m \) となっていたが、mとkの書き間違いと思われる。
その他の不等式
arrayMapHelper
とfindHelper
が停止することをチェックするにはLeanの組み込みの証明自動化だけで十分です。必要なものは再帰的に呼び出すたびに値が減少する式を提供することだけです。しかし、Leanの組み込みの自動化は魔法ではないため、しばしば手助けが必要になります。マージソート
停止証明が自明でない関数の一例として、
List
のマージソートがあります。マージソートは2つのフェーズから構成されます。まずリストが半分に分割されます。半分になったそれぞれがマージソートでソートされ、その結果を結合してより大きなソート済みリストにする関数を使ってマージされます。基本ケースは空リストと要素が1つのリストで、どちらもすでにソート済みであると見なされます。2つのソート済みのリストをマージするには2つの基本ケースを考慮する必要があります:
- どちらかのリストが空であれば、結果はもう片方のリストになります。
- どちらのリストも空でない場合、それらの先頭を比較します。この関数の結果は2つのリストのどちらか小さい方の先頭に両方のリストの残りの要素をマージした結果が続きます。
これはどちらのリストに対しても構造的に再帰的ではありません。再帰呼び出しのたびに2つのリストのどちらかから1つの要素が削除されるため再帰は終了します。
termination_by
節は両方のリストの長さの合計を減少する値として使用します:def merge [Ord α] (xs : List α) (ys : List α) : List α := match xs, ys with | [], _ => ys | _, [] => xs | x'::xs', y'::ys' => match Ord.compare x' y' with | .lt | .eq => x' :: merge xs' (y' :: ys') | .gt => y' :: merge (x'::xs') ys' termination_by merge xs ys => xs.length + ys.length
リストの長さを使うだけでなく、両方のリストを含むペアを提供することもできます:
def merge [Ord α] (xs : List α) (ys : List α) : List α := match xs, ys with | [], _ => ys | _, [] => xs | x'::xs', y'::ys' => match Ord.compare x' y' with | .lt | .eq => x' :: merge xs' (y' :: ys') | .gt => y' :: merge (x'::xs') ys' termination_by merge xs ys => (xs, ys)
これはLeanが
WellFoundedRelation
という型クラスで表現されるデータのサイズに関する概念を組み込んでいるため機能します。この型クラスのペアへのインスタンスでは、ペアの1つ目か2つ目のアイテムのどちらかが縮小すると、自動的に小さくなったと見なされます。リストを分割する簡単な方法は、入力リストの各要素を2つの出力リストに楮に追加することです:
def splitList (lst : List α) : (List α × List α) := match lst with | [] => ([], []) | x :: xs => let (a, b) := splitList xs (x :: b, a)
マージソートでは基本ケースに到達したかどうかをチェックします。もしそうであれば入力リストを返します。そうでない場合は、入力を分割し、それぞれの半分をソートした結果をマージします:
def mergeSort [Ord α] (xs : List α) : List α := if h : xs.length < 2 then match xs with | [] => [] | [x] => [x] else let halves := splitList xs merge (mergeSort halves.fst) (mergeSort halves.snd)
Leanのパターンマッチのコンパイラは
xs.length < 2
かどうかをテストするif
によって導入された仮定h
が、1つ以上の要素を除外していることを見分けることができます。そのため「場合分けの考慮もれ」エラーは発生しません。しかし、このプログラムは常に終了するにも関わらず、構造的には再帰的ではありません:fail to show termination for mergeSort with errors argument #3 was not used for structural recursion failed to eliminate recursive application mergeSort halves.fst structural recursion cannot be used failed to prove termination, use `termination_by` to specify a well-founded relation
これが停止する理由は
splitList
が常に入力よりも短いリストを返すからです。したがって、halves.fst
とhalves.snd
の長さはxs
の長さ未満となります。これはtermination_by
節を使って表現することができます:def mergeSort [Ord α] (xs : List α) : List α := if h : xs.length < 2 then match xs with | [] => [] | [x] => [x] else let halves := splitList xs merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by mergeSort xs => xs.length
この節によってエラーメッセージが変化します。Leanは関数が構造的に再帰的でないと文句を言う代わりに、
(splitList xs).fst.length < xs.length
を自動的に証明できなかった旨を指摘します:failed to prove termination, possible solutions: - Use `have`-expressions to prove the remaining goals - Use `termination_by` to specify a different well-founded relation - Use `decreasing_by` to specify your own tactic for discharging this kind of goal α : Type u_1 xs : List α h : ¬List.length xs < 2 halves : List α × List α := splitList xs ⊢ List.length (splitList xs).fst < List.length xs
分割によってリストが短くなること
(splitList xs).snd.length < xs.length
もまた証明する必要があります。splitList
は2つのリストに交互に要素を追加するため、一度に両方の文を証明することが最も簡単であり、そのため証明の構造はsplitList
実装に用いられたアルゴリズムに従います。つまり∀(lst : List), (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length
を証明することが最も簡単です。しかし残念なことに、この文は偽です。特に、
splitList []
は([], [])
となります。出力のリストはどちらも長さが0
で、入力のリストの長さである0
未満ではありません。同様に、splitList ["basalt"]
は(["basalt"], [])
に評価され、["basalt"]
の長さは["basalt"]
未満ではありません。しかし、splitList ["basalt", "granite"]
は(["basalt"], ["granite"])
に評価され、出力のリストは入力リストよりも短くなります。したがって出力されるリストの長さは必ず入力リストの長さ以下である一方で、入力リストが2つ以上要素を持つときだけ出力リストの長さが入力リストより短くなることがわかります。前者を証明し、後者に拡張することが簡単です。まず次の定理から始めます:
theorem splitList_shorter_le (lst : List α) : (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by skip
unsolved goals α : Type u_1 lst : List α ⊢ List.length (splitList lst).fst ≤ List.length lst ∧ List.length (splitList lst).snd ≤ List.length lst
splitList
はリスト上において構造的に再帰的であるため、証明は帰納法を使うべきです。splitList
についての構造的な再帰は帰納法による証明に完璧に合致します:帰納法の基本ケースは再帰の基本ケースに、帰納法のステップは再帰呼び出しに一致します。induction
タクティクは2つのゴールを与えます:theorem splitList_shorter_le (lst : List α) : (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by induction lst with | nil => skip | cons x xs ih => skip
unsolved goals case nil α : Type u_1 ⊢ List.length (splitList []).fst ≤ List.length [] ∧ List.length (splitList []).snd ≤ List.length []
unsolved goals case cons α : Type u_1 x : α xs : List α ih : List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs ⊢ List.length (splitList (x :: xs)).fst ≤ List.length (x :: xs) ∧ List.length (splitList (x :: xs)).snd ≤ List.length (x :: xs)
空リストの長さは空リストの長さ以下であるため、
nil
のケースについてのゴールは単純化器を起動してsplitList
の定義を展開するよう指示することで証明できます。同様に、cons
の場合にsplitList
を用いて単純化するとゴールの長さをNat.succ
が包みます:theorem splitList_shorter_le (lst : List α) : (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by induction lst with | nil => simp [splitList] | cons x xs ih => simp [splitList]
unsolved goals case cons α : Type u_1 x : α xs : List α ih : List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs ⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs) ∧ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)
これは
List.length
の呼び出しがリストの先頭x :: xs
を消費し、入力リストの長さと最初の出力リストの長さの両方でNat.succ
に変換するからです。Leanにおいて
A ∧ B
はAnd A B
の略記です。And
はProp
宇宙での構造体型です。structure And (a b : Prop) : Prop where intro :: left : a right : b
言いかえれば、
A ∧ B
の証明はAnd.intro
コンストラクタをleft
フィールドのA
の証明とright
フィールドのB
の証明に適用したものです。cases
タクティクを使うと、証明をデータ型の各コンストラクタや命題の各証明に対して順番に考慮することができます。これは再帰のないmatch
式に相当します。構造体に対してcases
を使用すると、プログラムで使用するためにパターンマッチ式が構造体のフィールドを展開するのと同じように、構造体が分解され構造体の各フィールドに対して仮定が追加されます。構造体にはコンストラクタが1つしかないため、構造体に対してcases
を使用してもゴールが追加されることはありません。ih
はList.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs
の証明であるため、cases ih
を使うと仮定List.length (splitList xs).fst ≤ List.length xs
と仮定List.length (splitList xs).snd ≤ List.length xs
が得られます:theorem splitList_shorter_le (lst : List α) : (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by induction lst with | nil => simp [splitList] | cons x xs ih => simp [splitList] cases ih
unsolved goals case cons.intro α : Type u_1 x : α xs : List α left✝ : List.length (splitList xs).fst ≤ List.length xs right✝ : List.length (splitList xs).snd ≤ List.length xs ⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs) ∧ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)
証明のゴールも
And
であるため、constructor
タクティクを使ってAnd.intro
を適用し、各引数のゴールを得ることができます:theorem splitList_shorter_le (lst : List α) : (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by induction lst with | nil => simp [splitList] | cons x xs ih => simp [splitList] cases ih constructor
unsolved goals case cons.intro.left α : Type u_1 x : α xs : List α left✝ : List.length (splitList xs).fst ≤ List.length xs right✝ : List.length (splitList xs).snd ≤ List.length xs ⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs) case cons.intro.right α : Type u_1 x : α xs : List α left✝ : List.length (splitList xs).fst ≤ List.length xs right✝ : List.length (splitList xs).snd ≤ List.length xs ⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)
left
のゴールはleft
の仮定にとてもよく似ていますが、ゴールでは不等式の両辺をNat.succ
が包んでいます。同様に、right
のゴールはright
に似ていますが、Nat.succ
が入力リストの長さにのみ追加されています。これらのNat.succ
のラッピングが文の正しさを保持することを証明する時がきました。両辺に1を足す
left
ゴールの場合、証明すべき文はNat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m
です。言い換えると、もしn ≤ m
ならば両辺に1を足してもこの事実は変わらないということです。なぜそうなるのでしょうか?n ≤ m
ということの証明はNat.le.refl
コンストラクタにNat.le.step
コンストラクタをm - n
個のインスタンスでくるんだものです。両辺に1を足すことは単純にrefl
が以前より1だけ大きい数に適用され、同じ数のstep
コンストラクタを持つことを意味します。より形式的には、この証明は
n ≤ m
という根拠に対する帰納法で示されます。もし根拠がrefl
ならば、n = m
であるためNat.succ n = Nat.succ m
となり、再びrefl
を使うことができます。もし根拠がstep
ならば、帰納法の仮定からはNat.succ n ≤ Nat.succ m
という根拠が提供され、ゴールはNat.succ n ≤ Nat.succ (Nat.succ m)
を示すことになります。これはstep
と帰納法の仮定を併用することで可能です。Leanにおいて、この定理は以下のように表現されます:
theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by skip
そしてエラーメッセージがこの内容を要約しています:
unsolved goals n m : Nat ⊢ n ≤ m → Nat.succ n ≤ Nat.succ m
最初のステップは
intro
タクティクを使うことで、これによりn ≤ m
という仮定をスコープ内に導入し、名前を付けます:theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by intro h
unsolved goals n m : Nat h : n ≤ m ⊢ Nat.succ n ≤ Nat.succ m
この証明は
n ≤ m
についての根拠に対しての帰納法であるため、次に使うタクティクはinduction h
です:theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by intro h induction h
これによりゴールが
Nat.le
の各コンストラクタそれぞれに1つずつ、合計2つ作られます:unsolved goals case refl n m : Nat ⊢ Nat.succ n ≤ Nat.succ n case step n m m✝ : Nat a✝ : Nat.le n m✝ a_ih✝ : Nat.succ n ≤ Nat.succ m✝ ⊢ Nat.succ n ≤ Nat.succ (Nat.succ m✝)
refl
についてのゴールはrefl
を使って解くことができ、これはconstructor
タクティクによっても選ばれます。step
についてのゴールもstep
コンストラクタを使用する必要があります:theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by intro h induction h with | refl => constructor | step h' ih => constructor
unsolved goals case step.a n m m✝ : Nat h' : Nat.le n m✝ ih : Nat.succ n ≤ Nat.succ m✝ ⊢ Nat.le (Nat.succ n) (m✝ + 1)
このゴールはもはや
≤
演算子を用いた表示でありませんが、これは帰納法の仮定ih
と等価です。assumption
タクティクはゴールを埋める仮定を自動で選択し、これで証明が完了します:theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m := by intro h induction h with | refl => constructor | step h' ih => constructor assumption
この証明を再帰関数として書くと以下のようになります:
theorem Nat.succ_le_succ : n ≤ m → Nat.succ n ≤ Nat.succ m | .refl => .refl | .step h' => .step (Nat.succ_le_succ h')
帰納法によるタクティクベースの証明とこの再帰関数を比較することは学びになるでしょう。どの証明ステップがこの定義のどの部分に対応するでしょうか?
大きい辺に1を足す
splitList_shorter_le
の証明に必要な2つめの不等式は∀(n m : Nat), n ≤ m → n ≤ Nat.succ m
です。この証明はほとんどNat.succ_le_succ
と同じです。繰り返しになりますが、n ≤ m
という仮定が導入されることでNat.le.step
コンストラクタの数によってn
とm
の差を本質的に追跡することができます。したがって、この証明は基本ケースにNat.le.step
を足す必要があります。この証明は次のように書かれます:theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m := by intro h induction h with | refl => constructor; constructor | step => constructor; assumption
裏で何が起こっているかを明らかにするために、
apply
とexact
タクティクを使用してどちらのコンストラクタが適用されているかを正確に示すことができます。apply
タクティクは戻り値の型が現在のゴールに一致するような関数やコンストラクタを適用することで現在のゴールを解きます。もし関数などに引数を与えなかった場合はexact
では失敗してしまいますが、apply
では新たなゴールが作られます。:theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m := by intro h induction h with | refl => apply Nat.le.step; exact Nat.le.refl | step _ ih => apply Nat.le.step; exact ih
この証明をゴルフすることができます:
theorem Nat.le_succ_of_le (h : n ≤ m) : n ≤ Nat.succ m := by induction h <;> repeat (first | constructor | assumption)
この短いタクティクのスクリプトでは、
induction
によって導入された両方のゴールにrepeat (first | constructor | assumption)
を使って対処しています。タクティクfirst | T1 | T2 | ... | Tn
はT1
からTn
まで順番に試してみて、最初に成功したタクティクを使うことを意味します。つまり、repeat (first | constructor | assumption)
はできる限りコンストラクタを適用し、その後仮定を使ってゴールを解こうとします。最後に、この証明は再帰関数を使っても書くことができます:
theorem Nat.le_succ_of_le : n ≤ m → n ≤ Nat.succ m | .refl => .step .refl | .step h => .step (Nat.le_succ_of_le h)
証明についての各スタイルは適材適所です。詳細な証明のスクリプトは初心者がコードを読む場合や、証明のステップから何らかの洞察が得られるような場合に有用です。短く、高度に自動化された証明スクリプトは一般的に保守が容易です。なぜなら、自動化は定義やデータ型の小さな変更を柔軟に吸収し、かつロバストであることが多いからです。再帰関数は一般的に数学的証明の観点からは理解しにくく、保守もしづらいですが、対話型定理証明に取り組み始めたばかりのプログラマにとっては有用な橋渡しになります。
証明を完成させる
これで補助定理が両方とも証明されたため、
splitList_shorter_le
の残りの部分はすぐに完了します。現在の証明状態には、And
の左辺と右辺の2つのゴールがあります:unsolved goals case cons.intro.left α : Type u_1 x : α xs : List α left✝ : List.length (splitList xs).fst ≤ List.length xs right✝ : List.length (splitList xs).snd ≤ List.length xs ⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs) case cons.intro.right α : Type u_1 x : α xs : List α left✝ : List.length (splitList xs).fst ≤ List.length xs right✝ : List.length (splitList xs).snd ≤ List.length xs ⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)
これらのゴールは
And
構造体のフィールド名が付けられています。つまりcase
タクティク(cases
と混同しないように)を使ってそれぞれ順番に焦点を当てることができます:theorem splitList_shorter_le (lst : List α) : (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by induction lst with | nil => simp [splitList] | cons x xs ih => simp [splitList] cases ih constructor case left => skip case right => skip
両方の未解決ゴールを並べた1つのエラーから、それぞれ
skip
による2つのメッセージが表示されるようになります。left
のゴールではNat.succ_le_succ
を使うことができます:unsolved goals α : Type u_1 x : α xs : List α left✝ : List.length (splitList xs).fst ≤ List.length xs right✝ : List.length (splitList xs).snd ≤ List.length xs ⊢ Nat.succ (List.length (splitList xs).snd) ≤ Nat.succ (List.length xs)
右のゴールでは
Nat.le_suc_of_le
が合致します:unsolved goals α : Type u_1 x : α xs : List α left✝ : List.length (splitList xs).fst ≤ List.length xs right✝ : List.length (splitList xs).snd ≤ List.length xs ⊢ List.length (splitList xs).fst ≤ Nat.succ (List.length xs)
どちらの定理も前提条件に
n ≤ m
を含みます。これはleft✝
とright✝
という仮定として見つけることができ、assumption
タクティクによって最終的なゴールが対処されることを意味します:theorem splitList_shorter_le (lst : List α) : (splitList lst).fst.length ≤ lst.length ∧ (splitList lst).snd.length ≤ lst.length := by induction lst with | nil => simp [splitList] | cons x xs ih => simp [splitList] cases ih constructor case left => apply Nat.succ_le_succ; assumption case right => apply Nat.le_succ_of_le; assumption
次のステップはマージソートが終了することを証明するために必要な実際の定理に戻ることです:すなわち、リストが少なくとも2つの要素を持つ限り、それを分割した結果の長さは両方とももとのリスト未満という定理です。
theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) : (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length := by skip
unsolved goals α : Type u_1 lst : List α x✝ : List.length lst ≥ 2 ⊢ List.length (splitList lst).fst < List.length lst ∧ List.length (splitList lst).snd < List.length lst
パターンマッチは通常のプログラムと同様にタクティクスクリプトでもうまく機能します。
lst
には少なくとも2つの要素があるため、match
をつかって展開することができます。同時に依存パターンマッチによって型も絞り込まれます:theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) : (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length := by match lst with | x :: y :: xs => skip
unsolved goals α : Type u_1 lst : List α x y : α xs : List α x✝ : List.length (x :: y :: xs) ≥ 2 ⊢ List.length (splitList (x :: y :: xs)).fst < List.length (x :: y :: xs) ∧ List.length (splitList (x :: y :: xs)).snd < List.length (x :: y :: xs)
splitList
を使って単純化すると、x
とy
が取り除かれ、それぞれNat.succ
が増えたリストの長さが計算されます:theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) : (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length := by match lst with | x :: y :: xs => simp [splitList]
unsolved goals α : Type u_1 lst : List α x y : α xs : List α x✝ : List.length (x :: y :: xs) ≥ 2 ⊢ Nat.succ (List.length (splitList xs).fst) < Nat.succ (Nat.succ (List.length xs)) ∧ Nat.succ (List.length (splitList xs).snd) < Nat.succ (Nat.succ (List.length xs))
simp
をsimp_arith
に置き換えると、これらのNat.succ
コンストラクタは削除されます。なぜなら、simp_arith
はn + 1 < m + 1
がn < m
を意味することを利用するからです:theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) : (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length := by match lst with | x :: y :: xs => simp_arith [splitList]
unsolved goals α : Type u_1 lst : List α x y : α xs : List α x✝ : List.length (x :: y :: xs) ≥ 2 ⊢ List.length (splitList xs).fst ≤ List.length xs ∧ List.length (splitList xs).snd ≤ List.length xs
これでこのゴールは
splitList_shorter_le
にマッチし、証明が完結します:theorem splitList_shorter (lst : List α) (_ : lst.length ≥ 2) : (splitList lst).fst.length < lst.length ∧ (splitList lst).snd.length < lst.length := by match lst with | x :: y :: xs => simp_arith [splitList] apply splitList_shorter_le
mergeSort
が停止することを証明するために必要な事実は、結果として得られるAnd
から引き出すことができます:theorem splitList_shorter_fst (lst : List α) (h : lst.length ≥ 2) : (splitList lst).fst.length < lst.length := splitList_shorter lst h |>.left theorem splitList_shorter_snd (lst : List α) (h : lst.length ≥ 2) : (splitList lst).snd.length < lst.length := splitList_shorter lst h |>.right
マージソートが停止すること
マージソートは2つの再帰呼び出しを持ち、それぞれ
splitList
が返すサブリストに対して呼び出されます。それぞれの再帰呼び出しでは渡されるリストの長さが入力リストの長さよりも短いことを証明する必要があります。停止性の証明は通常2つのステップで書くと便利です:まずLeanが停止を検証できる命題を書き出し、それを証明します。そうでないと、命題の証明に多くの労力を費やした結果、それが再帰呼び出しの入力が小さくなることの証明に全く必要ないということだけが判明するということにもなりかねません。sorry
タクティクはどのようなゴール、それこそ偽のものであっても証明することができます。これは本番のコードや最終的な証明に使うことは意図されていませんが、証明やプログラムを前もって「下書き」する便利な方法です。sorry
を使った定義や定理には警告が付きます。sorry
を使ったmergeSort
の停止引数についての最初のスケッチは、Leanが証明できなかったゴールをhave
式にコピーすることから書き始められます。Leanにおいて、have
はlet
に似ています。have
を使う場合、名前を省略することができます。一般的に、let
は興味の対象の値を参照する名前の定義のために使用され、have
はLeanが何かしらの根拠を検索した際に見つかる命題を局所的に証明するために使用されます。この根拠は配列の検索が範囲内であることや関数が停止することなどです。def mergeSort [Ord α] (xs : List α) : List α := if h : xs.length < 2 then match xs with | [] => [] | [x] => [x] else let halves := splitList xs have : halves.fst.length < xs.length := by sorry have : halves.snd.length < xs.length := by sorry merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by mergeSort xs => xs.length
名前
mergeSort
に対して警告が現れます:declaration uses 'sorry'
エラーは無いため、この命題の方向性は停止性の証明に十分です。
この証明はまず補助定理の適用からはじまります:
def mergeSort [Ord α] (xs : List α) : List α := if h : xs.length < 2 then match xs with | [] => [] | [x] => [x] else let halves := splitList xs have : halves.fst.length < xs.length := by apply splitList_shorter_fst have : halves.snd.length < xs.length := by apply splitList_shorter_snd merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by mergeSort xs => xs.length
splitList_shorter_fst
とsplitList_shorter_snd
はどちらもxs.length ≥ 2
の証明を必要とするため、どちらの証明も失敗します:unsolved goals case h α : Type ?u.37732 inst✝ : Ord α xs : List α h : ¬List.length xs < 2 halves : List α × List α := splitList xs ⊢ List.length xs ≥ 2
これが証明を完成させることを確認するために、
sorry
を使いエラーになるかチェックします:def mergeSort [Ord α] (xs : List α) : List α := if h : xs.length < 2 then match xs with | [] => [] | [x] => [x] else let halves := splitList xs have : xs.length ≥ 2 := by sorry have : halves.fst.length < xs.length := by apply splitList_shorter_fst assumption have : halves.snd.length < xs.length := by apply splitList_shorter_snd assumption merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by mergeSort xs => xs.length
ここでも警告のみとなります。
declaration uses 'sorry'
ここで有力な仮定が1つあります:
if
によって得られるh : ¬List.length xs < 2
です。明らかに、xs.length < 2
でなければxs.length ≥ 2
となります。Leanのライブラリにはこの定理をNat.ge_of_not_lt
という名前で提供しています。これでプログラムが完成します:def mergeSort [Ord α] (xs : List α) : List α := if h : xs.length < 2 then match xs with | [] => [] | [x] => [x] else let halves := splitList xs have : xs.length ≥ 2 := by apply Nat.ge_of_not_lt assumption have : halves.fst.length < xs.length := by apply splitList_shorter_fst assumption have : halves.snd.length < xs.length := by apply splitList_shorter_snd assumption merge (mergeSort halves.fst) (mergeSort halves.snd) termination_by mergeSort xs => xs.length
この関数は以下のような例でテストできます:
#eval mergeSort ["soapstone", "geode", "mica", "limestone"]
["geode", "limestone", "mica", "soapstone"]
#eval mergeSort [5, 3, 22, 15]
[3, 5, 15, 22]
割り算が引き算の繰り返しであること
掛け算が足し算の繰り返しであり、累乗が掛け算の繰り返しであるように、割り算は引き算の繰り返しとして理解することができます。本書における再帰関数の最初の説明 では、除数が0でないときに停止する割り算を紹介していますが、Leanはこれを受け入れません。割り算が終了することを証明するためには、不等式に関する事実を使用する必要があります。
最初のステップは割り算についての定義を改めることで、これによって除数が0でないことの根拠が求められます:
def div (n k : Nat) (ok : k > 0) : Nat := if n < k then 0 else 1 + div (n - k) k ok
このエラーメッセージは追加の引数によっていくぶん長いですが、本質的には以下と同じ内容です:
fail to show termination for div with errors argument #1 was not used for structural recursion failed to eliminate recursive application div (n - k) k ok argument #2 was not used for structural recursion failed to eliminate recursive application div (n - k) k ok argument #3 was not used for structural recursion application type mismatch @Nat.le.brecOn (Nat.succ 0) fun k ok => Nat → Nat argument fun k ok => Nat → Nat has type (k : Nat) → k > 0 → Type : Type 1 but is expected to have type (a : Nat) → Nat.le (Nat.succ 0) a → Prop : Type structural recursion cannot be used failed to prove termination, use `termination_by` to specify a well-founded relation
この
div
の定義は最初の引数n
が再帰呼び出しのたびに小さくなることから停止します。これはtermination_by
節を使うことで表現されます:def div (n k : Nat) (ok : k > 0) : Nat := if h : n < k then 0 else 1 + div (n - k) k ok termination_by div n k ok => n
これで、エラーは再帰呼び出しに限定されるようになります:
failed to prove termination, possible solutions: - Use `have`-expressions to prove the remaining goals - Use `termination_by` to specify a different well-founded relation - Use `decreasing_by` to specify your own tactic for discharging this kind of goal n k : Nat ok : k > 0 h : ¬n < k ⊢ n - k < n
これは標準ライブラリの定理
Nat.sub_lt
を使って証明することができます。この定理は∀ {n k : Nat}, 0 < n → 0 < k → n - k < n
ということを述べています(波括弧はn
とk
が暗黙の引数であることを示しています)。この定理を使うにはn
とk
の両方が0よりも大きいことを証明する必要があります。k > 0
は0 < k
の糖衣構文であるため、必要なのは0 < n
を示すことだけです。これには2つの場合があります:n
が0
であるか、ある他のNat
n'
に対してn' + 1
であるかです。しかしn
は0
ではありえません。if
による2つ目の分岐は¬ n < k
を意味しますが、もしn = 0
でk > 0
であればn
はk
よりも小さいはずであり、これは矛盾になります。これによりn = Nat.succ n'
であり、Nat.succ n'
は明らかに0
より大きくなります。停止についての証明も含めた
div
の完全な定義は以下になります:def div (n k : Nat) (ok : k > 0) : Nat := if h : n < k then 0 else have : 0 < n := by cases n with | zero => contradiction | succ n' => simp_arith have : n - k < n := by apply Nat.sub_lt <;> assumption 1 + div (n - k) k ok termination_by div n k ok => n
演習問題
以下の定理を証明してください:
- 全ての整数 \( n \) について \( 0 < n + 1 \) である。
- 全ての整数 \( n \) について \( 0 \leq n \) である。
- 全ての整数 \( n \) と \( k \) について \( (n + 1) - (k + 1) = n - k \) である。
- 全ての整数 \( n \) と \( k \) について、もし \( k < n \) ならば \( n \neq 0 \) である。
- 全ての整数 \( n \) について \( n - n = 0 \) である。
- 全ての整数 \( n \) と \( k \) について、もし \( n + 1 < k \) ならば \( n < k \) である。
安全な配列のインデックス
Array
とNat
についてのGetElem
インスタンスは与えられたNat
が配列よりも小さいことの証明を要求します。実際には、これらの証明はインデックスと一緒に関数に渡されることが多いです。インデックスと証明を別々に渡すのではなく、Fin
という型を使うことで、インデックスと証明を1つの値にまとめることができます。これによりコードはさらに読みやすくなります。さらに、配列に対する組み込み操作の多くは、インデックスの引数をNat
ではなくFin
として受け取るため、これらの組み込み操作を使用するにはFin
の使い方を理解する必要があります。型
Fin n
はn
未満の数を表します。つまり、Fin 3
は0
・1
・2
を表し、Fin 0
は全く値を持ちません。Fin n
はあるNat
とそれがn
より小さいことの証明を含む構造体であるため、Fin
の定義はSubtype
に似ています:structure Fin (n : Nat) where val : Nat isLt : LT.lt val n
Leanは
Fin
の値を数値として便利に使うためにToString
とOfNat
のインスタンスを有しています。つまり、#eval (5 : Fin 8)
の結果は{val := 5, isLt := _}
ではなく5
です。与えた数値がその範囲より大きい場合に
OfNat
インスタンスは失敗ではなく範囲の値によるその値の剰余のFin
を返します。つまり#eval (45 : Fin 10)
はコンパイルエラーにならずに5
となります。戻り値の型において、見つかったインデックスとして返される
Fin
はそれが見つかったデータ構造との関連をより明確にします。前節 でのArray.find
はその有効性に関する情報が失われているため、そのインデックスを使って配列検索がすぐには実行できません。より具体的な型によって、プログラムを著しく複雑にすることなく使用できる値が返されます:def findHelper (arr : Array α) (p : α → Bool) (i : Nat) : Option (Fin arr.size × α) := if h : i < arr.size then let x := arr[i] if p x then some (⟨i, h⟩, x) else findHelper arr p (i + 1) else none termination_by findHelper arr p i => arr.size - i def Array.find (arr : Array α) (p : α → Bool) : Option (Fin arr.size × α) := findHelper arr p 0
演習問題
与えられた値の1つ大きい数値が範囲内であればその
Fin
を、そうでなければnone
を返す関数Fin.next? : Fin n → Option (Fin n)
を書いてください。これが以下に対して#eval (3 : Fin 8).next?
以下を出力し、
some 4
また以下に対して
#eval (7 : Fin 8).next?
以下を出力することを確かめてください。
none
挿入ソートと配列の更新
挿入ソートはソートアルゴリズムとしては最悪計算時間の複雑性において最適ではありませんが、それでもいくつもの有用な性質を持ちます:
- 実装および理屈がシンプルで直観的であること
- in-placeアルゴリズムであり、実行時に追加の領域を必要としないこと
- 安定ソートである
- 入力がソート済みである場合は高速
特にin-placeアルゴリズムである点はLeanにおいてメモリ管理の方法から有用です。いくつかのケースでは、通常では配列のコピーの操作は更新に最適化されます。これは配列内の要素の交換も含みます。
JavaScriptやJVM、.NETを含むメモリを動的に管理するほとんどの言語とランタイムでは、ガベージコレクションの追跡を用います。メモリを再要求する必要がある場合、システムはいくつかの ルート (コールスタックやグローバル値など)から開始し、再帰的にポインタを追いかけることで到達できる値を決定します。到達できない値はすべて割り当て解除され、メモリが解放されます。
ガベージコレクションの追跡の代替手段として、Python・Swift・Leanを含む多くの言語では参照カウントが用いられます。参照カウントを持つシステムでは、メモリ内のオブジェクトはそれへの参照がいくつあるかを追跡するフィールドを持ちます。新しい参照が確立されるとカウントが1増えます。参照が存在しなくなるとカウントが1減ります。カウントが0になると、オブジェクトは直ちに解放されます。
参照カウントはガベージコレクションの追跡に対して1つの大きな欠点があります:循環参照によるメモリーリークです。あるオブジェクト \( A \) がオブジェクト \( B \) を参照し、オブジェクト \( B \) がオブジェクト \( A \) を参照している場合、たとえそのプログラム内で \( A \) と \( B \) に対して他に参照が無くてもこれらは決して解放されません。循環参照は制御されていない再帰か、変更可能な参照のどちらかに起因します。Leanはどちらもサポートしていないため、循環参照を構築することは不可能です。
参照カウントによってLeanのランタイムシステムのデータ構造の割り当て・解放のプリミティブが、あるオブジェクトの参照カウントが0になるかどうか、既存のオブジェクトを新しく割り当てることなく再利用するかをチェックできるようになります。これは大きな配列を扱う際には特に重要になります。
Leanの配列についての挿入ソートの実装は以下の性質を満たさなければなりません:
- Leanがこの関数を
partial
注釈無しで許容するべきである - 他に参照がない配列が渡された場合、新しい配列を確保するのではなく、in-placeで配列を変更すること
最初の性質は簡単にチェックできます:もしLeanがその定義を受け入れるならば満足します。しかし2つ目は検証方法が必要になります。Leanには下記のシグネチャを持つ
dbgTraceIfShared
という関数が組み込まれています:#check dbgTraceIfShared
dbgTraceIfShared.{u} {α : Type u} (s : String) (a : α) : α
これは引数として文字列と値を受け取り、もしその値に複数の参照があれば渡された文字列を使用したメッセージを標準エラーに出力し、値を返します。これは厳密には純粋関数ではありません。しかし、これは関数が実際にメモリを割り当てたりコピーしたりせずにメモリを再利用できるかどうかをチェックするために、開発中にのみ使用することを意図しています。
dbgTraceIfShared
の使い方を学ぶにあたって、#eval
がコンパイルされたコード中のものよりも多くの値が共有されていると報告することを知ることが大事です。これは混乱を招く恐れがあります。エディタで実験するよりも、lake
を使って実行ファイルをビルドすることが重要です。挿入ソートは2つのループから構成されます。外側のループはポインタをソート対象の配列中で左から右に動かします。各繰り返しの後に、配列内のポインタの左側はソートされる一方で右側は未ソートとなります。内側のループはポインタが指す要素を受け取り、それを適切な場所が見つかりループの不変条件が復元するまで左へと移動させます。言い換えると、各繰り返しは配列の次の要素をソート済み領域の適切な位置に挿入します。
内側のループ
挿入ソートの内側のループは配列と挿入する要素を引数として受け取る末尾再帰関数として実装することができます。挿入されるこの要素は、左側の要素の方が小さいときか配列の先頭にたどり着くまで繰り返し左の要素と入れ替えられ続けます。内側のループは配列のインデックスとして使われる
Fin
の中のNat
に対して構造的に再帰的です:def insertSorted [Ord α] (arr : Array α) (i : Fin arr.size) : Array α := match i with | ⟨0, _⟩ => arr | ⟨i' + 1, _⟩ => have : i' < arr.size := by simp [Nat.lt_of_succ_lt, *] match Ord.compare arr[i'] arr[i] with | .lt | .eq => arr | .gt => insertSorted (arr.swap ⟨i', by assumption⟩ i) ⟨i', by simp [*]⟩
インデックス
i
が0
ならば、ソート済み領域に挿入されるこの要素はすでに領域の先頭であり最小の値です。インデックスがi' + 1
ならば、i'
番目の要素はi
番目の要素と比較されるべきです。ここでi
はFin arr.size
型ですが、i'
はi
のval
フィールドから得られたものなのでただのNat
であることに注意してください。したがってi'
をarr
のインデックスとして使う前にi' < arr.size
を証明する必要があります。i' < arr.size
の証明からhave
式を取り除くと以下のゴールが現れます:unsolved goals α : Type ?u.7 inst✝ : Ord α arr : Array α i : Fin (Array.size arr) i' : Nat isLt✝ : i' + 1 < Array.size arr ⊢ i' < Array.size arr
Nat.lt_of_succ_lt
はLeanの標準ライブラリにある定理です。このシグネチャは#check Nat.lt_of_succ_lt
で確認でき、以下のようになります:Nat.lt_of_succ_lt {n m : Nat} (a✝ : Nat.succ n < m) : n < m
言い換えると、これは
n + 1 < m
ならばn < m
であるということを述べています。simp
に*
を渡すことで最終的な証明を得るためにNat.lt_of_succ_lt
にi
のisLt
フィールドが組み合わせられます。i'
が挿入される要素の左の要素を調べるために使えるようになったため、これら2つの要素を調べて比較します。左の要素が挿入される要素以下であれば、ループは終了し、ループ不変条件が復元されます。もし左の要素が挿入される要素より大きければ、要素が入れ替わり内側のループが再び始まります。Array.swap
はFin
である両方のインデックスと、have
で成立したi' < arr.size
を利用しているby assumption
を受け取ります。次のループで調べるインデックスもi'
ですが、この場合by assumption
だけでは不十分です。なぜなら、この証明は2つの要素を入れ替えた結果ではなく、もとの配列arr
に対して書かれたものだからです。simp
タクティクのデータベースには、配列の2つの要素を入れ替えてもサイズが変わらないという事実が含まれており、[*]
引数はhave
によって導入された仮定を追加で使用するように指示します。外側のループ
挿入ソートの外側のループでは、ポインタを左から右へ動かし、各繰り返しで
insertSorted
を呼び出してポインタの要素を配列の正しい位置に挿入します。このループの基本形はArray.map
の実装に似ています:def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α := if h : i < arr.size then insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1) else arr
その結果生じるエラーも
termination_by
節をArray.map
に指定しない場合に生じるものと同じです。というのも再帰呼び出しのたびに減少する引数がないからです:fail to show termination for insertionSortLoop with errors argument #4 was not used for structural recursion failed to eliminate recursive application insertionSortLoop (insertSorted arr { val := i, isLt := h }) (i + 1) structural recursion cannot be used failed to prove termination, use `termination_by` to specify a well-founded relation
停止についての証明を構築する前に、
partial
修飾子を使って定義をテストし、これが期待される答えを返すことを確認しておくと便利です:partial def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α := if h : i < arr.size then insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1) else arr
#eval insertionSortLoop #[5, 17, 3, 8] 0
#[3, 5, 8, 17]
#eval insertionSortLoop #["metamorphic", "igneous", "sedentary"] 0
#["igneous", "metamorphic", "sedentary"]
停止性
ここでもまた、インデックスと配列のサイズの差が各再帰呼び出しのたびに小さくなっていくため関数は停止します。しかし、今回Leanはこの
termination_by
を受理しません:def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α := if h : i < arr.size then insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1) else arr termination_by insertionSortLoop arr i => arr.size - i
failed to prove termination, possible solutions: - Use `have`-expressions to prove the remaining goals - Use `termination_by` to specify a different well-founded relation - Use `decreasing_by` to specify your own tactic for discharging this kind of goal α : Type u_1 inst✝ : Ord α arr : Array α i : Nat h : i < Array.size arr ⊢ Array.size (insertSorted arr { val := i, isLt := h }) - (i + 1) < Array.size arr - i
問題はLeanが
insertSorted
が渡された配列と同じサイズの配列を返すことを知る方法がないことです。insertionSortLoop
が停止することを証明するには、まずinsertSorted
が配列のサイズを変更しないことを証明する必要があります。エラーメッセージから証明されていない停止条件を関数にコピーし、sorry
で「証明」することで、この関数を一時的に受け入れることができます:def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α := if h : i < arr.size then have : (insertSorted arr ⟨i, h⟩).size - (i + 1) < arr.size - i := by sorry insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1) else arr termination_by insertionSortLoop arr i => arr.size - i
declaration uses 'sorry'
insertSorted
は挿入される要素のインデックスに対して構造的に再帰的であるため、証明はインデックスに対する帰納法で行う必要があります。基本ケースでは配列は変更されずに返されるので、その長さは確かに変化しません。帰納法のステップでは、次の小さいインデックスで再帰的に呼び出しても配列の長さは変わらないという帰納法の仮定が成り立ちます。ここでは2つのケースを考慮します:要素がソート済みの領域に完全に挿入され、配列が変更されずに返される場合と、再帰呼び出しの前に要素が次の要素と交換される場合です。しかし、配列の2つの要素を入れ替えてもサイズは変わらないため、帰納法の仮定によれば次のインデックスによる再帰呼び出しは、引数と同じサイズの配列を返します。したがってサイズは変わりません。この自然言語の定理文をLeanに翻訳し、本章のテクニックを使って進めば、基本ケースの証明と帰納法のステップを進めるには十分です:
theorem insert_sorted_size_eq [Ord α] (arr : Array α) (i : Fin arr.size) : (insertSorted arr i).size = arr.size := by match i with | ⟨j, isLt⟩ => induction j with | zero => simp [insertSorted] | succ j' ih => simp [insertSorted]
帰納法のステップ中で
insertSorted
を使って単純化することでinsertSorted
内のパターンマッチが現れます:unsolved goals case succ α : Type u_1 inst✝ : Ord α arr : Array α i : Fin (Array.size arr) j' : Nat ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr isLt : Nat.succ j' < Array.size arr ⊢ Array.size (match compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] with | Ordering.lt => arr | Ordering.eq => arr | Ordering.gt => insertSorted (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt }) { val := j', isLt := (_ : j' < Array.size (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt })) }) = Array.size arr
if
やmatch
を含むゴールに直面した時、split
タクティク(マージソートの定義で使われているsplit
関数と混同しないように)は制御フローのパスごとにゴールを新しいゴールに置き換えます:theorem insert_sorted_size_eq [Ord α] (arr : Array α) (i : Fin arr.size) : (insertSorted arr i).size = arr.size := by match i with | ⟨j, isLt⟩ => induction j with | zero => simp [insertSorted] | succ j' ih => simp [insertSorted] split
さらに、それぞれの新しいゴールにはどのブランチがそのゴールにつながったかを示す仮定があり、この場合では
heq✝
と名付けられます:unsolved goals case succ.h_1 α : Type u_1 inst✝ : Ord α arr : Array α i : Fin (Array.size arr) j' : Nat ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr isLt : Nat.succ j' < Array.size arr x✝ : Ordering heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.lt ⊢ Array.size arr = Array.size arr case succ.h_2 α : Type u_1 inst✝ : Ord α arr : Array α i : Fin (Array.size arr) j' : Nat ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr isLt : Nat.succ j' < Array.size arr x✝ : Ordering heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.eq ⊢ Array.size arr = Array.size arr case succ.h_3 α : Type u_1 inst✝ : Ord α arr : Array α i : Fin (Array.size arr) j' : Nat ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr isLt : Nat.succ j' < Array.size arr x✝ : Ordering heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.gt ⊢ Array.size (insertSorted (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt }) { val := j', isLt := (_ : j' < Array.size (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt })) }) = Array.size arr
両方のケースに対して単純な証明を書くよりも、
split
の後に<;> try rfl
を加えることで2つの単純なケースはたちまち消え、1つのゴールだけが残ります:theorem insert_sorted_size_eq [Ord α] (arr : Array α) (i : Fin arr.size) : (insertSorted arr i).size = arr.size := by match i with | ⟨j, isLt⟩ => induction j with | zero => simp [insertSorted] | succ j' ih => simp [insertSorted] split <;> try rfl
unsolved goals case succ.h_3 α : Type u_1 inst✝ : Ord α arr : Array α i : Fin (Array.size arr) j' : Nat ih : ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr isLt : Nat.succ j' < Array.size arr x✝ : Ordering heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.gt ⊢ Array.size (insertSorted (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt }) { val := j', isLt := (_ : j' < Array.size (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt })) }) = Array.size arr
残念なことに、帰納法の仮定はこのゴールを証明するには不十分です。帰納法の仮定は
arr
に対してinsertSorted
を呼び出すとサイズが変わらないことを述べていますが、証明のゴールは再帰的な呼び出しの結果と置換の結果がサイズを変えないことを示すことです。証明を成功させるには、小さい方のインデックスを引数としてinsertSorted
に渡す 任意の 配列に対して機能する帰納法の仮定が必要です。induction
タクティクにgeneralizing
オプションを使用することで強力な帰納法の仮定を得ることができます。このオプションは、基本ケース、帰納法の仮定、および帰納法のステップで示されるゴールを生成するために使用される文にコンテキストから追加の仮定をもたらします。arr
を一般化することで、より強力な仮定を導くことができます:theorem insert_sorted_size_eq [Ord α] (arr : Array α) (i : Fin arr.size) : (insertSorted arr i).size = arr.size := by match i with | ⟨j, isLt⟩ => induction j generalizing arr with | zero => simp [insertSorted] | succ j' ih => simp [insertSorted] split <;> try rfl
その結果、
arr
は帰納法の仮定の「全ての~について~」文の一部となります:unsolved goals case succ.h_3 α : Type u_1 inst✝ : Ord α j' : Nat ih : ∀ (arr : Array α), Fin (Array.size arr) → ∀ (isLt : j' < Array.size arr), Array.size (insertSorted arr { val := j', isLt := isLt }) = Array.size arr arr : Array α i : Fin (Array.size arr) isLt : Nat.succ j' < Array.size arr x✝ : Ordering heq✝ : compare arr[j'] arr[{ val := Nat.succ j', isLt := isLt }] = Ordering.gt ⊢ Array.size (insertSorted (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt }) { val := j', isLt := (_ : j' < Array.size (Array.swap arr { val := j', isLt := (_ : j' < Array.size arr) } { val := Nat.succ j', isLt := isLt })) }) = Array.size arr
しかし、この証明全体は手に負えなくなっていきます。次のステップは置換結果の長さを表す変数を導入し、それが
arr.size
と等しいことを示し、この変数が再帰呼び出しの結果得られる配列の長さとも等しいことを示すことです。これらの等式を連鎖させて、ゴールを証明することができます。しかし、帰納法の仮定が自動的に十分強く、変数もすでに導入されるように定理文を注意深く再定式化する方がはるかに簡単です。再定式化された文は次のようになります:theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → arr.size = len → (insertSorted arr ⟨i, isLt⟩).size = len := by skip
このバージョンの定理文はいくつかの理由から証明が容易です:
- インデックスとその有効性の証明を
Fin
にまとめるのではなく、インデックスを配列の前に持ってくる。これにより帰納法の仮定が配列とi
が範囲内にあることの証明に対して自然に一般化されます。 - 抽象的な長さ
len
がarray.size
の略として導入されました。証明の自動化は明示的な等号を扱う方が得意な場合が多いです。
結果として得られる証明状態は、帰納法の仮定を生成するために使用される文と、基本ケースと帰納法のステップのゴールを示します:
unsolved goals α : Type u_1 inst✝ : Ord α len i : Nat ⊢ ∀ (arr : Array α) (isLt : i < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := i, isLt := isLt }) = len
この文と
induction
タクティクの結果として得られるゴールを比べてみてください:theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → arr.size = len → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => skip | succ i' ih => skip
基本ケースでは、
i
のそれぞれの出現が0
に置き換えられています。各仮定の導入にintro
を使用し、次にinsertSorted
を使用して単純化するとインデックスzero
でのinsertSorted
はその引数を変更せずに返すため、ゴールが証明されます:unsolved goals case zero α : Type u_1 inst✝ : Ord α len : Nat ⊢ ∀ (arr : Array α) (isLt : Nat.zero < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := Nat.zero, isLt := isLt }) = len
帰納法のステップにおいて、帰納法の仮定は実にちょうど良い強さを持ちます。これは配列の長さが
len
である限り どのような 配列に対しても有効です:unsolved goals case succ α : Type u_1 inst✝ : Ord α len i' : Nat ih : ∀ (arr : Array α) (isLt : i' < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len ⊢ ∀ (arr : Array α) (isLt : Nat.succ i' < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := Nat.succ i', isLt := isLt }) = len
基本ケースでは、
simp
によってゴールがarr.size = len
へと簡約されます:theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → arr.size = len → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => intro arr isLt hLen simp [insertSorted] | succ i' ih => skip
unsolved goals case zero α : Type u_1 inst✝ : Ord α len : Nat arr : Array α isLt : Nat.zero < Array.size arr hLen : Array.size arr = len ⊢ Array.size arr = len
これは仮定
hLen
を使って証明できます。simp
に*
パラメータを追加することで仮定を追加で使用するように指示することができます:theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → arr.size = len → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => intro arr isLt hLen simp [insertSorted, *] | succ i' ih => skip
帰納法のステップにおいて、仮定の導入とゴールの単純化によってゴールはふたたびパターンマッチを含んだものとなります:
theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => intro arr isLt hLen simp [insertSorted, *] | succ i' ih => intro arr isLt hLen simp [insertSorted]
unsolved goals case succ α : Type u_1 inst✝ : Ord α len i' : Nat ih : ∀ (arr : Array α) (isLt : i' < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len arr : Array α isLt : Nat.succ i' < Array.size arr hLen : Array.size arr = len ⊢ Array.size (match compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] with | Ordering.lt => arr | Ordering.eq => arr | Ordering.gt => insertSorted (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt }) { val := i', isLt := (_ : i' < Array.size (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt })) }) = len
split
タクティクを使うことで各パターンそれぞれに1つずつゴールが割り当てられます。繰り返しになりますが、最初の2つのゴールは再帰呼び出しのない分岐から得られるため帰納法の仮定は必要ありません:theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => intro arr isLt hLen simp [insertSorted, *] | succ i' ih => intro arr isLt hLen simp [insertSorted] split
unsolved goals case succ.h_1 α : Type u_1 inst✝ : Ord α len i' : Nat ih : ∀ (arr : Array α) (isLt : i' < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len arr : Array α isLt : Nat.succ i' < Array.size arr hLen : Array.size arr = len x✝ : Ordering heq✝ : compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] = Ordering.lt ⊢ Array.size arr = len case succ.h_2 α : Type u_1 inst✝ : Ord α len i' : Nat ih : ∀ (arr : Array α) (isLt : i' < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len arr : Array α isLt : Nat.succ i' < Array.size arr hLen : Array.size arr = len x✝ : Ordering heq✝ : compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] = Ordering.eq ⊢ Array.size arr = len case succ.h_3 α : Type u_1 inst✝ : Ord α len i' : Nat ih : ∀ (arr : Array α) (isLt : i' < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len arr : Array α isLt : Nat.succ i' < Array.size arr hLen : Array.size arr = len x✝ : Ordering heq✝ : compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] = Ordering.gt ⊢ Array.size (insertSorted (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt }) { val := i', isLt := (_ : i' < Array.size (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt })) }) = len
split
で得られる各ゴールでtry assumption
を実行すると非再帰的なゴールは両方とも無くなります:theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => intro arr isLt hLen simp [insertSorted, *] | succ i' ih => intro arr isLt hLen simp [insertSorted] split <;> try assumption
unsolved goals case succ.h_3 α : Type u_1 inst✝ : Ord α len i' : Nat ih : ∀ (arr : Array α) (isLt : i' < Array.size arr), Array.size arr = len → Array.size (insertSorted arr { val := i', isLt := isLt }) = len arr : Array α isLt : Nat.succ i' < Array.size arr hLen : Array.size arr = len x✝ : Ordering heq✝ : compare arr[i'] arr[{ val := Nat.succ i', isLt := isLt }] = Ordering.gt ⊢ Array.size (insertSorted (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt }) { val := i', isLt := (_ : i' < Array.size (Array.swap arr { val := i', isLt := (_ : i' < Array.size arr) } { val := Nat.succ i', isLt := isLt })) }) = len
再帰関数に関係するすべての配列の長さに定数
len
を使用するという新しい証明のゴールについての形式化はsimp
が解決できる類の問題にうまく当てはまります。配列の長さをlen
に関連付ける仮定が重要であるため、この証明の最終的なゴールはsimp [*]
で解くことができます:theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => intro arr isLt hLen simp [insertSorted, *] | succ i' ih => intro arr isLt hLen simp [insertSorted] split <;> try assumption simp [*]
最後に、
simp [*]
は仮定を使うことができるため、try assumption
の行はsimp [*]
に置き換えることができ、証明を短くできます:theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => intro arr isLt hLen simp [insertSorted, *] | succ i' ih => intro arr isLt hLen simp [insertSorted] split <;> simp [*]
これでこの証明を使って
insertionSortLoop
のsorry
を置き換えることができます。定理のlen
引数にarr.size
を与えることで最終的な結論は(insertSorted arr ⟨i, isLt⟩).size = arr.size
となるため、非常に扱いやすい証明のゴールへと書き換えることができます:def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α := if h : i < arr.size then have : (insertSorted arr ⟨i, h⟩).size - (i + 1) < arr.size - i := by rw [insert_sorted_size_eq arr.size i arr h rfl] insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1) else arr termination_by insertionSortLoop arr i => arr.size - i
unsolved goals α : Type ?u.22173 inst✝ : Ord α arr : Array α i : Nat h : i < Array.size arr ⊢ Array.size arr - (i + 1) < Array.size arr - i
証明
Nat.sub_succ_lt_self
はLeanの標準ライブラリの一部です。この型は∀ (a i : Nat), i < a → a - (i + 1) < a - i
であり、これはまさに必要だったものです:def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α := if h : i < arr.size then have : (insertSorted arr ⟨i, h⟩).size - (i + 1) < arr.size - i := by rw [insert_sorted_size_eq arr.size i arr h rfl] simp [Nat.sub_succ_lt_self, *] insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1) else arr termination_by insertionSortLoop arr i => arr.size - i
ドライバ関数
挿入ソート自体は
insertionSortLoop
を呼び出し、配列のソート済み領域と未ソート領域を区切るインデックスを0
に初期化します:def insertionSort [Ord α] (arr : Array α) : Array α := insertionSortLoop arr 0
簡単なテストをいくつかやってみると、この関数は少なくともあからさまに間違ってはいません:
#eval insertionSort #[3, 1, 7, 4]
#[1, 3, 4, 7]
#eval insertionSort #[ "quartz", "marble", "granite", "hematite"]
#["granite", "hematite", "marble", "quartz"]
これは本当に挿入ソート?
挿入ソートはin-placeのソートアルゴリズムとして 定義されています 。最悪実行時間が2倍になるにも関わらず挿入ソートが有用であるのは、余分な領域を割り当てず、ほぼソート済みのデータを効率的に扱う安定したソートアルゴリズムであるからです。もし内部ループの各繰り返しが新しい配列を割り当てるのであれば、このアルゴリズムは 本物の 挿入ソートではありません。
Array.set
やArray.swap
などのLeanの配列操作は対象の配列の参照カウントが1より大きいかどうかをチェックします。もし大きければ、その配列はコードの複数の個所から見えることになり、コピーしなければならないことになります。そうでなければLeanはもはや純粋関数型言語ではなくなってしまいます。しかし、参照カウントがちょうど1の場合、その値に対する潜在的な観測者はほかに存在しません。このような場合、配列のプリミティブはその場で配列を変更します。ここ以外のプログラムが知り得ないことはそれ等自身を傷つけることはありません。Leanの証明論理は純粋関数型プログラムのレベルで機能するのであって、その下にある実装に対しては機能しません。つまりプログラムが不必要にデータをコピーしているかどうかを発見する最善の方法は、それをテストすることです。更新が必要な各ポイントで
dbgTraceIfShared
の呼び出しを追加すると、問題の値が複数の参照を持つ場合に提供されたメッセージがstderr
に出力されます。挿入ソートは変更ではなくコピーをしてしまう恐れのある箇所がまさに一か所あります:
Array.swap
の呼び出しです。arr.swap ⟨i', by assumption⟩ i
を((dbgTraceIfShared "array to swap" arr).swap ⟨i', by assumption⟩ i)
に置き換えることで、プログラムはプログラムを変更できない時は必ずshared RC array to swap
を出力するようになります。しかし、追加の関数呼び出しが加わることの変化によって証明も変わってしまいます。dbgTraceIfShared
は第2引数を直接返すため、simp
の呼び出しに追加するだけで証明は修正されます。挿入ソートの計装用の完全なコードは以下の通りです:
def insertSorted [Ord α] (arr : Array α) (i : Fin arr.size) : Array α := match i with | ⟨0, _⟩ => arr | ⟨i' + 1, _⟩ => have : i' < arr.size := by simp [Nat.lt_of_succ_lt, *] match Ord.compare arr[i'] arr[i] with | .lt | .eq => arr | .gt => insertSorted ((dbgTraceIfShared "array to swap" arr).swap ⟨i', by assumption⟩ i) ⟨i', by simp [dbgTraceIfShared, *]⟩ theorem insert_sorted_size_eq [Ord α] (len : Nat) (i : Nat) : (arr : Array α) → (isLt : i < arr.size) → (arr.size = len) → (insertSorted arr ⟨i, isLt⟩).size = len := by induction i with | zero => intro arr isLt hLen simp [insertSorted, *] | succ i' ih => intro arr isLt hLen simp [insertSorted, dbgTraceIfShared] split <;> simp [*] def insertionSortLoop [Ord α] (arr : Array α) (i : Nat) : Array α := if h : i < arr.size then have : (insertSorted arr ⟨i, h⟩).size - (i + 1) < arr.size - i := by rw [insert_sorted_size_eq arr.size i arr h rfl] simp [Nat.sub_succ_lt_self, *] insertionSortLoop (insertSorted arr ⟨i, h⟩) (i + 1) else arr termination_by insertionSortLoop arr i => arr.size - i def insertionSort [Ord α] (arr : Array α) : Array α := insertionSortLoop arr 0
計装が実際に機能するかどうかをチェックするにはちょっとした工夫が必要です。まずLeanのコンパイラはコンパイル時にすべての引数が分かっている場合、関数呼び出しを積極的に最適化します。そのため計装のために大きな配列に
insertionSort
を適用するプログラムを書くだけでは不十分です。というのもコンパイル結果のコードはソート済みの配列だけが定数として含まれる可能性があるからです。コンパイラがソートルーチンを最適化しないようにする最も簡単な方法はstdin
から配列を読み込むことです。次に、コンパイラはデッドコードの除去を行います。もしlet
に束縛された変数が使われることがなければ、プログラムへの余分なlet
を追加しても実行中のコードの参照が増えるとは限りません。余分な参照が完全に除去されないようにするためには、余分な参照が何らかの形で使われるようにすることが重要です。計装をテストする最初のステップは標準入力から行の配列を読み込む
getLines
を書くことです:def getLines : IO (Array String) := do let stdin ← IO.getStdin let mut lines : Array String := #[] let mut currLine ← stdin.getLine while !currLine.isEmpty do -- Drop trailing newline: lines := lines.push (currLine.dropRight 1) currLine ← stdin.getLine pure lines
IO.FS.Stream.getLine
は末尾の開業を含む完全なテキスト行を返します。ファイル終端記号に到達した場合は""
を返します。次に、2つの別々の
main
ルーチンが必要です。どちらも標準入力からソート対象の配列を読み込み、これによってコンパイル時にinsertionSort
の呼び出しが戻り値に置き換えられないようにします。どちらもコンソールに出力し、これによりinsertionSort
の呼び出しが完全に最適化されることはありません。一方はソートされた配列のみを表示し、もう一方はソートされた配列と元の配列を両方表示します。2番目の関数はArray.swap
が新しい配列を確保しなければならなかったという警告を表示します:def mainUnique : IO Unit := do let lines ← getLines for line in insertionSort lines do IO.println line def mainShared : IO Unit := do let lines ← getLines IO.println "--- Sorted lines: ---" for line in insertionSort lines do IO.println line IO.println "" IO.println "--- Original data: ---" for line in lines do IO.println line
実際の
main
は与えらえたコマンドライン引数に基づいて2つのメインアクションのうち1つを選択するだけです:def main (args : List String) : IO UInt32 := do match args with | ["--shared"] => mainShared; pure 0 | ["--unique"] => mainUnique; pure 0 | _ => IO.println "Expected single argument, either \"--shared\" or \"--unique\"" pure 1
引数無しで実行すると、期待通りの使用情報が得られます:
$ sort Expected single argument, either "--shared" or "--unique"
ファイル
test-data
は以下の岩についての情報を保持します:schist feldspar diorite pumice obsidian shale gneiss marble flint
これらの岩石に計装用の挿入ソートを使うとアルファベット順に印字されます:
$ sort --unique < test-data diorite feldspar flint gneiss marble obsidian pumice schist shale
しかし、元の配列への参照が保持されるバージョンでは、最初に
Array.swap
を呼び出した時からstderr
(つまりshared RC array to swap
)に通知が行われます:$ sort --shared < test-data shared RC array to swap --- Sorted lines: --- diorite feldspar flint gneiss marble obsidian pumice schist shale --- Original data: --- schist feldspar diorite pumice obsidian shale gneiss marble flint
shared RC
が1つしか表示されないということは、配列が1度だけコピーされることを意味します。これは、Array.swap
を呼び出した結果のコピー自体が一意であるためであり、それ以上コピーする必要はありません。命令型言語では、配列を参照渡しする前に明示的にコピーすることを忘れると微妙なバグが発生することがあります。sort --shared
を実行すると、Leanのプログラムの純粋関数的な意味を保つために必要な分だけ配列がコピーされますが、それ以上はコピーされません。その他の更新の機会
参照が一意である場合にコピーの代わりに更新を使用するのは配列更新演算子に限ったことではありません。Leanは参照カウントが0になりそうなコンストラクタを「リサイクル」し、新しいデータを割り当てる代わりに再利用しようとします。これは例えば、
Let.map
が連結リストをその場で更新することを意味します。Leanのコードでホット・ループを最適化する最も重要なステップの1つは、変更されるデータが複数の場所から参照されないようにすることです。演習問題
- 配列を反転させる関数を書いてください。入力配列の参照カウントが1の場合、関数が新しい配列を確保しないことをテストしてください。
- 配列に対してマージソートかクイックソートのどちらかを実装してください。読者の実装が停止することを証明し、期待以上の配列を確保しないことをテストしてください。これは難しい練習問題です!
特別な型
メモリ上でのデータ表現を理解することは非常に重要です。通常、このような表現はデータ型の定義から理解することができます。各コンストラクタはタグと参照カウントを含むヘッダを持つメモリ上のオブジェクトに対応します。コンストラクタの引数はそれぞれ対応するオブジェクトへのポインタで表されます。言い換えると、
List
は本当に連結リストであり、structure
からフィールドを取り出すことはまさにポインタを追跡することになります。しかし、このルールにはいくつかの重要な例外があります。いくつかの型はコンパイラによって特別に扱われます。例えば、
UInt32
型はFin (2 ^ 32)
として定義されていますが、実行時には機械語に基づく実際のネイティブ実装に置き換えられます。同様に、Nat
の定義がList Unit
に似た実装を示唆している一方で、実際の実行時の表現においては、十分に小さい数には即座に機械語を使用し、大きい数には効率的な任意精度の算術ライブラリを使用します。Leanのコンパイラはパターンマッチを使用する定義をこの表現に適した演算に変換し、加算や減算のような演算の呼び出しには基礎となる算術ライブラリの高速な演算にマッピングされます。この結果、足し算は加算する値の大きさに比例して時間がかかるようなものになるべくもありません。いくつかの型が特別な表現を持っているということは、それらを扱う際に注意が必要だということでもあります。これらの型のほとんどは、コンパイラによって特別に扱われる
structure
からなります。これらの構造体では、コンストラクタやフィールドアクセサを直接使用すると効率的な表現から証明に便利な遅い表現への高価な変換が引き起こされる可能性があります。例えば、String
は文字のリストを含む構造体として定義されていますが、文字列の実行時の表現ではUTF-8が使用され、文字へのポインタの連結リストは使用されません。文字のリストにコンストラクタを適用すると、UTF-8でエンコードされたバイト文字列が作成され、構造体のフィールドにアクセスすると、UTF-8表現をデコードして連結リストを割り当てるために、文字列に対して線形に時間がかかります。配列も同じように表現されます。論理的な観点からは、配列は配列要素を含む構造体ですが、実行時の表現は動的配列です。実行時にはコンストラクタがリストを配列に変換し、フィールドアクセサが配列から連結リストを割り当てます。さまざまな配列操作はコンパイラによって効率的なバージョンに置き換えられ、新しい配列を割り当てる代わりに可能な限り配列を更新するようにします。型自体と命題の証明のどちらもコンパイルされたコードからは完全に消去されます。言い換えればそれらはスペースを取らず、証明の一部として実行されていただろう計算も同様に消去されます。つまり、証明はプログラム実行中に遅い変換ステップを課されることなく、帰納的に定義されたリストとしての文字列や配列に対して、帰納法による証明などの便利なインタフェースを利用することができます。これらの組み込み型では、データの便利な論理表現によって、プログラムが遅くなることはありません。
構造体型が非型で非証明のフィールドを1つだけ持つ場合、コンストラクタ自体が実行時に消滅し、その1つだけの引数に置き換えられます。つまり、部分型はその基本の型に対して間接的に参照する追加のレイヤーを持たずに、基本型と同じように表現されます。同様に、
Fin
はメモリ上では単なるNat
であり、Nat
やString
の異なる使用を追跡するための単一のフィールドを持つ構造体をパフォーマンスを落とすことなく作成することができます。コンストラクタに非型で非証明の引数が無い場合、コンストラクタも消滅し、ポインタが使用される定数値に置き換えられます。つまり、true
・false
・none
はヒープに割り当てられたオブジェクトへのポインタではなく、定数値となります。以下の型は特別な表現を持ちます:
型 論理的表現 実行時の表現 Nat
各 Nat.succ
からのポインタを1つ持つ単項式効率的な任意精度整数 Int
それぞれ Nat
を含む正負のコンストラクタからなる直和型効率的な任意精度整数 UInt8
,UInt16
,UInt32
,UInt64
それぞれの範囲に応じた Fin
固定精度の機械整数 Char
正当なコードポイントであることの証明付きの UInt32
通常の文字型 String
List Char
型でdata
という名前のフィールドを持つ構造体UTF-8エンコードされた文字列 Array α
List α
型でdata
という名前のフィールドを持つ構造体α
型の値へのポインタからなる配列Sort u
型 完全に削除 命題の証明 データの中身が何であれ根拠の一種と見なされれば命題が示唆するもの何でも 完全に削除 演習問題
Pos
の定義 はLeanがNat
を効率的な型にコンパイルすることを利用していません。実行時において、これは本質的に連結リストとなってしまいます。別の方法として、部分型に関する最初の節 で説明したように、Leanの高速なNat
型を内部で使用できるようにする部分型を定義することができます。実行時にはこの証明は消去されます。結果の構造体はたった一つのデータフィールドを持つため、これはPos
の新しい表現がNat
の表現と同じであることを意味します。定理
∀ {n k : Nat}, n ≠ 0 → k ≠ 0 → n + k ≠ 0
を証明したのちに、このPos
の新しい表現へのToString
とAdd
のインスタンスを定義してください。その次に、必要な定理を証明しながらMul
のインスタンスを定義してください。まとめ
末尾再帰
末尾再帰とは再帰の一種で、再帰呼び出しの結果を他の何かで使用するのではなく、即座にそれを返すものを指します。このような再帰呼び出しは 末尾呼び出し と呼ばれます。末尾呼び出しはCALL命令ではなくJUMP命令にコンパイルでき、新しいフレームをプッシュする代わりに現在のスタックフレームを再利用できるという興味深い存在です。言い換えれば、末尾再帰関数は実際にはループなのです。
再帰関数を高速化する一般的な方法はアキュムレータを渡すスタイルに書き換えることです。呼び出しスタックを使って再帰呼び出しの結果を記憶する代わりに、アキュムレータ と呼ばれる追加の引数を使用して情報を収集します。例えば、リストを反転させる末尾再帰関数のアキュムレータには、再帰中にすでに見てきたリストの要素が逆順で格納されます。
Leanではループに最適化されるのは自分自身の末尾呼び出しだけです。言い換えると、2つの関数の最後がそれぞれもう一方の関数への末尾呼び出しで終わっている場合は最適化されません。
参照カウントとin-placeな更新
Java・C#・JavaScriptなどのほとんどの実装で行われているようなガベージコレクションの追跡を使用するのではなく、Leanではメモリ管理に参照カウントを使用します。つまり、メモリ上の各値はその値を参照している他の値がいくつあるかを追跡するフィールドを含んでおり、ランタイムシステムは参照が現れたり消えたりするたびにこれらのカウントを管理します。参照カウントはPython・PHP・Swiftでも使われています。
新しいオブジェクトを割り当てるよう要求されると、Leanのランタイムシステムは参照カウントが0になってしまった既存のオブジェクトを再利用することができます。さらに、
Array.set
やArray.swap
などの配列操作は参照カウントが1であれば、変更されたコピーを割り当てるのではなく、配列を更新します。もしArray.swap
が配列への唯一の参照を保持している場合プログラムの他の部分からは、その配列がコピーされずに変更されたことを認識できません。Leanにおいて効率的なコードを書くには末尾再帰を利用し、大きな配列が一意に使用されるように注意を払う必要があります。末尾呼び出しは関数の定義を調べることで識別できますが、値が一意に参照されているかどうかを理解するには、プログラム全体を読む必要がある可能性があります。デバッグ用の補助関数
dbgTraceIfShared
をプログラムの重要な場所で使用すると、値が共有されていないことを確認できます。プログラムの正しさの証明
プログラムをアキュムレータを渡すスタイルに書き換えたり、より高速に実行できるように他の変換を加えたりすると理解しづらい実装になることもあります。こうした実装よりも正しさがより明確なオリジナルバージョンのプログラムを保持し、最適化バージョンの実行可能な仕様として使用することは有用です。単体テストのようなテクニックはLeanでも他の言語と同様に機能しますが、Leanでは関数の両方のバージョンが 可能な限りすべての 入力に対して同じ結果を返すことを完全に保証する数学的証明を使用することもできます。
通常、2つの関数が等しいことを証明するためには、関数の外延性(
funext
タクティク)を用います。これは2つの関数がすべての入力に対して同じ値を返すなら等しいという原則です。もし関数が再帰的であれば、その出力が同じであることの証明には帰納法を用いることが大抵良いでしょう。通常、関数の再帰的定義はある特定の引数に対して再帰的な呼び出しを行います;このような引数が帰納法の格好の対象になります。場合によっては帰納法の仮定が十分に強くないこともあります。この問題を解決するには通常、十分に強い帰納法の仮定を提供する定理文に対してより一般的なバージョンを構築する方法を考える必要があります。特に、ある関数がアキュムレータを渡すバージョンと等価であることを証明するには、任意の初期アキュムレータ値と元の関数の最終結果と関連していることを示す定理文が必要です。安全な配列のインデックス
型
Fin n
はn
未満の自然数を表します。Fin
は「有限(finite)」の略です。部分型と同様に、Fin n
はあるNat
とそのNat
がn
未満であるという証明を含む構造体です。Fin 0
型の値は存在しません。もし
arr
がArray α
であれば、Fin arr.size
は常にarr
への適切なインデックスとなる数値を含みます。Array.swap
などの組み込みの配列演算子の多くは、個別の証明オブジェクトではなく、Fin
の値を引数に取ります。Leanは
Fin
用の便利な数値型クラスのインスタンスを提供しています。Fin
用のOfNat
インスタンスは、提供された数値がFin
が受け付ける数値よりも大きい場合にコンパイル時に失敗するのではなく、剰余演算を実行します。暫定的な証明
ある文を実際に証明する作業を行わずに、証明されたフリをすることが役に立つことがあります。これはある文の証明が、他の証明の書き換えや、配列アクセスが安全であることの判断、再帰呼び出しが元の引数よりも小さな値で行われることの証明など、何らかのタスクに適していることを確認する時に便利です。ある文を証明するのに時間を費やした結果、他の証明の方がより有用であったことがということだけが判明するのは非常にフラストレーションがたまるものです。
sorry
タクティクはある文をLeanにあたかも本当の証明であるかのように暫定的に承認させます。これはC#でNotImplementedException
を投げるスタブメソッドに似ています。sorry
に依存する証明はLeanからの警告を含みます。気を付けましょう!
sorry
タクティクはたとえ偽の文であっても、どんな 文でも証明することができてしまいます。3 < 2
を証明すると、範囲外の配列アクセスが実行時まで持続し、予期せずプログラムがクラッシュすることがありえます。開発中にsorry
を使うのは便利ですが、コードに残しておくのは危険です。停止性の証明
再帰関数が構造的再帰を使用していない場合、Leanは自動的にその関数の終了を判断することができません。このような状況において、関数に単に
partial
とマークすることも可能ではあります。しかし、関数が終了することを証明することもできます。部分関数には重要な欠点があります:それは型検査中や証明中で展開できないことです。これはLeanの対話的定理証明器としての価値が適用できないことを意味します。さらに、停止することが期待される関数が実際には常に停止することを示すことでバグの潜在的な原因を1つ取り除くことができます。
関数の最後に指定できる
termination_by
節は再帰関数が停止する理由を指定するために使うことができます。この節は、関数の引数を再帰的に呼び出されるたびに小さくなることが予想される式にマップします。小さくなる可能性のある式の例としては、配列のインデックスと配列のサイズの差、再帰呼び出しのたびに半分になるリストの長さ、再帰呼び出しのたびに片方が小さくなるリストのペアなどがあります。Leanには証明の自動化機能があり、場合によっては呼び出しごとに式が縮小することを自動的に判断することができますが、多くの興味深いプログラムでは手作業の証明が必要になります。これらの証明は
have
を使って提供することができます。これはlet
の亜種で、値ではなく証明をローカルに提供することを目的としています。再帰関数を書く良い方法は、まず
partial
と宣言し、正しい答えを返すまでテストしながらデバッグすることです。次に、partial
を削除してtermination_by
節に置き換えます。Leanは証明が必要な各再帰呼び出しに証明が必要な文を含むエラーをハイライトします。これらの文はそれぞれ証明の中身をsorry
にしたhave
に設定することができます。Leanがプログラムを受け入れテストに合格したら、最後のステップはLeanがプログラムを受け入れるための定義を実際に証明するこです。このアプローチで、バグのあるプログラムに対して停止することの証明に時間を浪費してしまうことを防ぐことができます。次のステップ
本書は、ごくわずかの対話的定理証明を含めたLeanによる関数型プログラミングのごく基本的なことについて紹介しています。Leanのような依存型関数型言語の使用は深いトピックであり、語るべきことはまだまだあります。読者の興味に応じて、以下のリソースがLean4の学習に役立つでしょう。
Lean自体の学習
Lean4そのものについては以下のリソースで記述されています:
- Theorem Proving in Lean 4 はLeanで証明を書くためのチュートリアルです。1
- The Lean 4 Manual はLean言語とその機能のリファレンスを提供しています。本書執筆時点ではまだ不完全ですが、本書よりもLeanの多くの側面が詳細に記述されています。
- How To Prove It With Lean は How To Prove It という定評のある教科書のLeanベースの付録であり、紙と鉛筆で数学的証明を書くための入門書です。
- Metaprogramming in Lean 4 は中置演算子や記法などからマクロ・カスタムのタクティク・完全にカスタムな組み込み言語まで、Leanの拡張メカニズムの概要を提供しています。
- Functional Programming in Lean は再帰に関するジョークが好きな読者には面白いかもしれません。 2
しかし、Leanを学び続ける最善の方法はコードを読み、実際に書いてみて、行き詰った時にはドキュメントを参照することです。さらに、Lean Zulip はほかのLeanユーザに会ったり、助けを求めたり、他の人を助けたりするのに最適な場所です。
標準ライブラリ
いざふたを開けてみると、Lean自体にはかなり最小限のライブラリしか含まれていません。Leanはセルフホスティッドであり、含まれているコードはLean自身を十分実装しうるものです。多くのアプリケーションではより大きな標準ライブラリが必要です。
batteries 3 は現在進行形で開発されている標準ライブラリであり、Leanのコンパイラ自体のスコープから外れた多くのデータ構造・タクティク・型クラスのインスタンス・関数を保持しています。
batteries
を使用するにはまず、使用しているLean4のバージョンと互換性のあるコミットを探します(つまり、lean-toolchain
ファイルが読者のプロジェクトのものと一致するものです)。次に、lakefile.lean
のトップレベルに以下を追加します。ここでCOMMIT_HASH
は適切なバージョンを指します:require batteries from git "https://github.com/leanprover-community/batteries" @ "COMMIT_HASH"
Leanによる数学
数学者のためのリソースのほとんどはLean3用に書かれています。コミュニティサイト では幅広い分野があります。Lean4で数学を始めるには、数学ライブラリ
mathlib
をLean3からLean4に移植するプロセスに参加することが一番簡単でしょう。詳しくはmathlib4
README を参照してください。計算機科学における依存型の利用
CoqはLeanと多くの共通点を持つ言語です。計算機科学者にとっては対話的な教科書 Software Foundations シリーズは計算機科学におけるCoqの応用について優れた入門書を提供しています。LeanとCoqの基本的な考え方は非常に似ており、言語に対するスキルはシステム間で容易に移行可能です。
依存型によるプログラミング
プログラムを構造化するために添字族と依存型を使うことを学びたいと思っているプログラマにとって、Edwin Bradyの Type Driven Development with Idris は素晴らしい入門書となります。Coqと同様、IdrisはLeanの親戚ですが、タクティクはありません。
依存型の理解
The Little Typer は論理学やプログラミング言語の理論を正式に学んだことはないが、依存型理論の核となる考え方について理解を深めたいと考えているプログラマのための本です。上記のすべてのリソースが可能な限り実用的であることを目指しているのに対し、The Little Typer はプログラミングの概念のみを用いて0から基本を構築する依存型理論へのアプローチを提示しています。免責事項:Functional Programming in Lean の著者は The Little Typer の著者でもあります。
1日本語訳は https://aconite-ac.github.io/theorem_proving_in_lean4_ja/
3原文が書かれた当時は
std4
という名前でしたが、改名されたことに合わせて日本語訳の文章を修正しています。2非再帰版は https://leanprover.github.io/functional_programming_in_lean/
- 適切な