8. 귀납법과 재귀
이전 장에서 우리는 귀납적 정의가 Lean에서 새로운 타입을 도입하는 강력한 수단이 된다는 것을 살펴보았습니다. 더욱이 생성자와 재귀자는 이러한 타입 위에서 함수를 정의하는 유일한 수단입니다. 명제-타입 대응에 따르면, 이는 귀납법이 증명의 근본적인 방법이라는 것을 의미합니다.
Lean은 재귀 함수를 정의하고, 패턴 매칭을 수행하고, 귀납적 증명을 작성하는 자연스러운 방법을 제공합니다. Lean에서는 함수가 만족해야 하는 등식을 명시하여 함수를 정의할 수 있고, 발생할 수 있는 여러 경우를 각각 어떻게 처리할지 명시하여 정리를 증명할 수 있습니다. 내부적으로 이러한 서술은 우리가 “등식 컴파일러”라고 부르는 절차를 통해 원시 재귀자로 “컴파일”됩니다. 등식 컴파일러는 신뢰 코드 기반에 속하지 않으며, 그 출력은 커널이 독립적으로 검사하는 항들로 이루어집니다.
8.1. 패턴 매칭
도식적 패턴의 해석은 컴파일 과정의 첫 단계입니다. 앞에서 casesOn 재귀자를 사용하면 귀납적으로 정의된 타입에 관련된 생성자에 따라 경우별로 함수를 정의하고 정리를 증명할 수 있음을 살펴보았습니다. 그러나 복잡한 정의에서는 casesOn 적용이 여러 겹으로 중첩될 수 있으며, 그러면 읽고 이해하기 어려울 수 있습니다. 패턴 매칭은 더 편리하며 함수형 프로그래밍 언어 사용자에게 익숙한 접근 방식을 제공합니다.
귀납적으로 정의된 타입인 자연수를 생각해 봅시다. 모든 자연수는 zero이거나 succ x이므로, 각 경우에 해당하는 값을 지정하여 자연수에서 임의의 타입으로 가는 함수를 정의할 수 있습니다:
set_option linter.unusedVariables false
open Nat
def sub1 : Nat → Nat
| zero => zero
| succ x => x
def isZero : Nat → Bool
| zero => true
| succ x => false
이 함수들을 정의하는 데 사용된 등식은 정의에 의해 성립합니다:
open Nat
def sub1 : Nat → Nat
| zero => zero
| succ x => x
def isZero : Nat → Bool
| zero => true
| succ x => false
example : sub1 0 = 0 := rfl
example (x : Nat) : sub1 (succ x) = x := rfl
example : isZero 0 = true := rfl
example (x : Nat) : isZero (succ x) = false := rfl
example : sub1 7 = 6 := rfl
example (x : Nat) : isZero (x + 3) = false := rfl
zero와 succ 대신 더 익숙한 표기법을 사용할 수 있습니다:
set_option linter.unusedVariables false
def sub1 : Nat → Nat
| 0 => 0
| x + 1 => x
def isZero : Nat → Bool
| 0 => true
| x + 1 => false
덧셈과 0 표기법에는 [match_pattern] 속성이 지정되어 있으므로 패턴 매칭에 사용할 수 있습니다. Lean은 생성자 zero와 succ가 드러날 때까지 이 표현식들을 정규화할 뿐입니다.
패턴 매칭은 곱 타입이나 옵션 타입 같은 모든 귀납적 타입에 사용할 수 있습니다:
def swap : α × β → β × α
| (a, b) => (b, a)
def foo : Nat × Nat → Nat
| (m, n) => m + n
def bar : Option Nat → Nat
| some n => n + 1
| none => 0
여기서는 이것을 함수를 정의하는 데뿐만 아니라 경우에 따른 증명을 수행하는 데에도 사용합니다:
namespace Hidden
def not : Bool → Bool
| true => false
| false => true
theorem not_not : ∀ (b : Bool), not (not b) = b
| true => show not (not true) = true from rfl
| false => show not (not false) = false from rfl
end Hidden
패턴 매칭은 귀납적으로 정의된 명제를 분해하는 데에도 사용할 수 있습니다:
example (p q : Prop) : p ∧ q → q ∧ p
| And.intro h₁ h₂ => And.intro h₂ h₁
example (p q : Prop) : p ∨ q → q ∨ p
| Or.inl hp => Or.inr hp
| Or.inr hq => Or.inl hq
이는 논리 연결사를 사용하는 가설을 간결하게 풀어내는 방법을 제공합니다.
이 모든 예제에서 패턴 매칭은 경우를 한 번만 나누는 데 사용되었습니다. 더 흥미로운 점은, 다음 예제에서처럼 패턴에 중첩된 생성자를 사용할 수 있다는 것입니다.
등식 컴파일러는 먼저 입력이 zero인지 succ x 형태인지에 따라 경우를 나눕니다. 그런 다음 x가 zero 형태인지 succ x 형태인지에 따라 경우 분할을 수행합니다. 등식 컴파일러는 주어진 패턴으로부터 필요한 경우 분할을 결정하며, 패턴이 모든 경우를 망라하지 못하면 오류를 발생시킵니다. 이번에도 아래 버전에서처럼 산술 표기법을 사용할 수 있습니다. 어느 경우든 정의 방정식은 정의적으로 성립합니다.
example : sub2 0 = 0 := rfl
example : sub2 1 = 0 := rfl
example : sub2 (x+2) = x := rfl
example : sub2 5 = 3 := rfl
#print sub2를 작성하면 함수가 재귀자로 어떻게 컴파일되었는지 확인할 수 있습니다. (Lean은 sub2가 내부 보조 함수 sub2.match_1을 사용하는 항으로 정의되었다고 알려 줄 것이지만, 그것도 출력해 볼 수 있습니다.) Lean은 이러한 보조 함수를 사용하여 match 표현식을 컴파일합니다. 실제로 위의 정의는 다음과 같이 전개됩니다
다음은 중첩된 패턴 매칭의 예시를 몇 가지 더 보여 줍니다:
set_option linter.unusedVariables false
example (p q : α → Prop) :
(∃ x, p x ∨ q x) →
(∃ x, p x) ∨ (∃ x, q x)
| Exists.intro x (Or.inl px) => Or.inl (Exists.intro x px)
| Exists.intro x (Or.inr qx) => Or.inr (Exists.intro x qx)
def foo : Nat × Nat → Nat
| (0, n) => 0
| (m+1, 0) => 1
| (m+1, n+1) => 2
등식 컴파일러는 여러 인자를 순차적으로 처리할 수 있습니다. 예를 들어, 앞의 예제는 두 개의 인자를 받는 함수로 정의하는 편이 더 자연스럽습니다:
set_option linter.unusedVariables false
def foo : Nat → Nat → Nat
| 0, n => 0
| m + 1, 0 => 1
| m + 1, n + 1 => 2
다음은 또 다른 예입니다
set_option linter.unusedVariables false
def bar : List Nat → List Nat → Nat
| [], [] => 0
| a :: as, [] => a
| [], b :: bs => b
| a :: as, b :: bs => a + b
패턴은 쉼표로 구분된다는 점에 유의하십시오.
다음 각 예제에서는 나머지 인자들도 패턴 목록에 포함되어 있지만, 분할은 첫 번째 인자에 대해서만 일어납니다.
set_option linter.unusedVariables false
namespace Hidden
def and : Bool → Bool → Bool
| true, a => a
| false, _ => false
def or : Bool → Bool → Bool
| true, _ => true
| false, a => a
def cond : Bool → α → α → α
| true, x, y => x
| false, x, y => y
end Hidden
또한 정의에서 인자의 값이 필요하지 않을 때는 그 대신 밑줄을 사용할 수 있다는 점에 유의하십시오. 이 밑줄은 와일드카드 패턴 또는 익명 변수라고 합니다. 등식 컴파일러 밖에서 쓰일 때와 달리, 여기서 밑줄은 암시적 인자를 나타내지 않습니다. 와일드카드에 밑줄을 사용하는 것은 함수형 프로그래밍 언어에서 흔한 관례이므로, Lean도 그 표기법을 채택합니다. 와일드카드와 겹치는 패턴에 관한 절에서는 와일드카드의 개념을 더 자세히 다루며, 접근 불가능한 패턴에 대한 설명에서는 패턴에서도 암시적 인자를 사용하는 방법을 설명합니다.
귀납적 타입에서 설명한 대로, 귀납적 데이터 타입은 매개변수에 의존할 수 있습니다. 다음 예제는 패턴 매칭을 사용하여 tail 함수를 정의합니다. 인자 α : Type u는 매개변수이며, 패턴 매칭에 참여하지 않음을 나타내기 위해 콜론 앞에 위치합니다. Lean에서는 매개변수가 : 뒤에 오는 것도 허용하지만, 이러한 매개변수에 대해 패턴 매칭을 하려면 명시적인 match가 필요합니다.
set_option linter.unusedVariables false
def tail1 {α : Type u} : List α → List α
| [] => []
| a :: as => as
def tail2 : {α : Type u} → List α → List α
| α, [] => []
| α, a :: as => as
이 두 예제에서 매개변수 α의 위치는 서로 다르지만, 두 경우 모두 이 매개변수는 경우 분할에 참여하지 않는다는 점에서 같은 방식으로 다루어집니다.
Lean은 의존 타입의 인자가 각 경우에 추가 제약을 가하는, 더 복잡한 형태의 패턴 매칭도 처리할 수 있습니다. 이러한 의존 패턴 매칭의 예는 의존 패턴 매칭 절에서 다룹니다.
8.2. 와일드카드와 겹치는 패턴
이전 절의 예제 중 하나를 살펴봅시다:
set_option linter.unusedVariables false
def foo : Nat → Nat → Nat
| 0, n => 0
| m + 1, 0 => 1
| m + 1, n + 1 => 2
다른 방식으로 나타내면 다음과 같습니다:
set_option linter.unusedVariables false
def foo : Nat → Nat → Nat
| 0, n => 0
| m, 0 => 1
| m, n => 2
두 번째 표현에서는 패턴이 서로 겹칩니다. 예를 들어 인자 쌍 0, 0은 세 경우 모두와 매칭됩니다. 하지만 Lean은 적용 가능한 첫 번째 등식을 사용하여 이 모호성을 처리하므로, 이 예에서 최종 결과는 동일합니다. 특히 다음 등식들은 정의적으로 성립합니다:
example : foo 0 0 = 0 := rfl
example : foo 0 (n + 1) = 0 := rfl
example : foo (m + 1) 0 = 1 := rfl
example : foo (m + 1) (n + 1) = 2 := rfl
m과 n의 값은 필요하지 않으므로, 대신 와일드카드 패턴을 사용해도 됩니다.
이 foo 정의도 이전과 같은 정의적 항등식을 만족한다는 것을 확인할 수 있습니다.
일부 함수형 프로그래밍 언어는 불완전한 패턴을 지원합니다. 이러한 언어에서는 불완전한 경우에 인터프리터가 예외를 발생시키거나 임의의 값을 반환합니다. Inhabited 타입 클래스를 사용하면 임의의 값을 반환하는 방식을 흉내 낼 수 있습니다. 대략적으로 말하면, Inhabited α의 원소는 α에 원소가 존재한다는 사실의 증거입니다. 타입 클래스에 관한 장에서는 적절한 기본 타입에 원소가 존재한다는 것을 Lean에 알려 줄 수 있으며, 다른 방식으로 구성된 타입에도 원소가 존재한다는 것을 Lean이 자동으로 추론할 수 있음을 보게 될 것입니다. 이를 바탕으로 표준 라이브러리는 원소가 존재하는 모든 타입에 대해 기본 원소 default를 제공합니다.
Option α 타입을 사용하여 불완전한 패턴을 흉내 낼 수도 있습니다. 주어진 패턴에 대해서는 some a를 반환하고, 불완전한 경우에는 none을 사용하는 것입니다. 다음 예제는 두 가지 방식을 모두 보여 줍니다.
def f1 : Nat → Nat → Nat
| 0, _ => 1
| _, 0 => 2
| _, _ => default -- the "incomplete" case
example : f1 0 0 = 1 := rfl
example : f1 0 (a+1) = 1 := rfl
example : f1 (a+1) 0 = 2 := rfl
example : f1 (a+1) (b+1) = default := rfl
def f2 : Nat → Nat → Option Nat
| 0, _ => some 1
| _, 0 => some 2
| _, _ => none -- the "incomplete" case
example : f2 0 0 = some 1 := rfl
example : f2 0 (a+1) = some 1 := rfl
example : f2 (a+1) 0 = some 2 := rfl
example : f2 (a+1) (b+1) = none := rfl
등식 컴파일러는 영리합니다. 다음 정의에서 케이스를 하나라도 빠뜨리면, 오류 메시지가 무엇이 처리되지 않았는지 알려 줍니다.
def bar : Nat → List Nat → Bool → Nat
| 0, _, false => 0
| 0, b :: _, _ => b
| 0, [], true => 7
| a+1, [], false => a
| a+1, [], true => a + 1
| a+1, b :: _, _ => a + b
또한 적절한 상황에서는 casesOn 대신 if ... then ... else를 사용합니다.
8.3. 구조적 재귀와 귀납법
등식 컴파일러를 강력하게 만드는 것은 재귀 정의도 지원한다는 점입니다. 다음 세 절에서는 각각 다음 내용을 설명합니다:
-
구조적 재귀 정의
-
정초 재귀 정의
-
상호 재귀 정의
일반적으로 등식 컴파일러는 다음과 같은 형태의 입력을 처리합니다:
def foo (a : α) : (b : β) → γ | [patterns₁] => t₁ ... | [patternsₙ] => tₙ
여기서 (a : α)는 매개변수의 나열이고, (b : β)는 패턴 매칭이 이루어지는 인자의 나열이며, γ는 a와 b에 의존할 수 있는 임의의 타입입니다. 각 줄에는 β의 각 원소마다 하나씩, 같은 개수의 패턴이 들어 있어야 합니다. 앞에서 보았듯이 패턴은 변수이거나, 다른 패턴에 적용된 생성자이거나, 그러한 형태로 정규화되는 표현식입니다(이때 생성자가 아닌 것들은 [match_pattern] 속성으로 표시됩니다). 생성자가 나타나면 경우 분할이 일어나며, 생성자의 인자는 주어진 변수들로 표현됩니다. 의존 패턴 매칭 절에서는 패턴 안의 일부 명시적 항이 패턴 매칭에서는 아무 역할도 하지 않지만, 표현식의 타입 검사가 통과하도록 특정한 형태로 강제된다는 것을 보게 될 것입니다. 이러한 이유로 이것들을 “접근 불가능한 패턴”이라고 부릅니다. 하지만 의존 패턴 매칭을 다루기 전까지는 이러한 접근 불가능한 패턴을 사용할 필요가 없을 것입니다.
앞 절에서 보았듯이, 항 t₁, ..., tₙ은 매개변수 a 중 어느 것이든 사용할 수 있으며, 대응하는 패턴에서 도입된 변수 중 어느 것이든 사용할 수 있습니다. 재귀와 귀납법이 가능한 것은 이 항들이 foo에 대한 재귀 호출도 포함할 수 있기 때문입니다. 이 절에서는 구조적 재귀를 다룹니다. 구조적 재귀에서는 =>의 우변에 나타나는 foo의 인자가 좌변 패턴의 부분 항입니다. 이는 그 인자들이 구조적으로 더 작고, 따라서 귀납적 타입에서 더 이른 단계에 나타난다는 발상에 기반합니다. 다음은 앞 장에서 다룬 구조적 재귀의 몇 가지 예를 이번에는 등식 컴파일러를 사용하여 정의한 것입니다.
open Nat
def add : Nat → Nat → Nat
| m, zero => m
| m, succ n => succ (add m n)
theorem add_zero (m : Nat) : add m zero = m := rfl
theorem add_succ (m n : Nat) : add m (succ n) = succ (add m n) := rfl
theorem zero_add : ∀ n, add zero n = n
| zero => rfl
| succ n => congrArg succ (zero_add n)
def mul : Nat → Nat → Nat
| n, zero => zero
| n, succ m => add (mul n m) n
zero_add의 증명을 보면 귀납법에 의한 증명이 사실상 Lean에서 재귀의 한 형태라는 것이 분명히 드러납니다.
위의 예제는 add의 정의 방정식이 정의적으로 성립함을 보여 주며, mul도 마찬가지입니다. 등식 컴파일러는 단순한 구조적 귀납법의 경우처럼, 가능한 한 언제나 이 성질이 성립하도록 보장하려고 합니다. 그러나 다른 상황에서는 환원이 명제적으로만 성립하는데, 이는 곧 그것들이 명시적으로 적용해야 하는 방정식 정리라는 뜻입니다. 등식 컴파일러는 이러한 정리를 내부적으로 생성합니다. 이 정리들은 사용자가 직접 사용하도록 의도된 것이 아니며, 대신 simp 택틱이 필요할 때 이를 사용하도록 설정되어 있습니다. 다음의 zero_add 증명은 이런 방식으로 동작합니다:
theorem zero_add : ∀ n, add zero n = n
⊢ add zero zero = zero ⊢ add zero zero = zero All goals completed! 🐙
n:Nat⊢ add zero n.succ = n.succ n:Nat⊢ add zero n.succ = n.succ All goals completed! 🐙
패턴 매칭을 사용한 정의와 마찬가지로, 구조적 재귀나 귀납법의 매개변수는 콜론 앞에 올 수 있습니다. 이러한 매개변수는 정의를 처리하기 전에 로컬 컨텍스트에 그냥 추가됩니다. 예를 들어, 덧셈의 정의는 다음과 같이 작성할 수도 있습니다:
위의 예제는 match를 사용하여 작성할 수도 있습니다.
구조적 재귀의 더 흥미로운 예는 피보나치 함수 fib에서 볼 수 있습니다.
def fib : Nat → Nat
| 0 => 1
| 1 => 1
| n+2 => fib (n+1) + fib n
example : fib 0 = 1 := rfl
example : fib 1 = 1 := rfl
example : fib (n + 2) = fib (n + 1) + fib n := rfl
example : fib 7 = 21 := rfl
여기서 n + 2(이는 succ (succ n)과 정의적으로 같습니다)에서의 fib 함수의 값은 n + 1(이는 succ n과 정의적으로 동치입니다)에서의 값과 n에서의 값의 항으로 정의됩니다. 그러나 이는 피보나치 함수를 계산하는 방법으로서는 비효율적이기로 악명이 높으며, 실행 시간이 n에 대해 지수적으로 증가합니다. 더 나은 방법은 다음과 같습니다:
def fibFast (n : Nat) : Nat :=
(loop n).2
where
loop : Nat → Nat × Nat
| 0 => (0, 1)
| n+1 => let p := loop n; (p.2, p.1 + p.2)
#eval fibFast 100
다음은 where 대신 let rec을 사용한 동일한 정의입니다.
def fibFast (n : Nat) : Nat :=
let rec loop : Nat → Nat × Nat
| 0 => (0, 1)
| n+1 => let p := loop n; (p.2, p.1 + p.2)
(loop n).2
두 경우 모두 Lean은 보조 함수 fibFast.loop를 생성합니다.
구조적 재귀를 처리하기 위해 등식 컴파일러는 값 경과 재귀를 사용하며, 이때 각 귀납적으로 정의된 타입과 함께 자동으로 생성되는 상수 below와 brecOn을 사용합니다. Nat.below와 Nat.brecOn의 타입을 살펴보면 이것이 어떻게 동작하는지 감을 잡을 수 있습니다:
variable (C : Nat → Type u)
#check (@Nat.below C : Nat → Type u)
#reduce @Nat.below C (3 : Nat)
#check (@Nat.brecOn C : (n : Nat) → ((n : Nat) → @Nat.below C n → C n) → C n)
@Nat.below C (3 : Nat) 타입은 C 0, C 1, C 2의 원소를 저장하는 자료 구조입니다. 값 경과 재귀(course-of-values recursion)는 Nat.brecOn으로 구현됩니다. 이를 통해 (n : Nat) → C n 타입의 의존 함수가 특정 입력 n에서 갖는 값을, @Nat.below C n의 원소로 주어지는 그 함수의 이전 모든 값들에 대한 항으로 정의할 수 있습니다.
값 경과 재귀를 사용하는 것은 등식 컴파일러가 함수가 종료된다는 것을 Lean 커널에 정당화하기 위해 사용하는 기법 중 하나입니다. 이 기법은 다른 함수형 프로그래밍 언어 컴파일러와 마찬가지 방식으로 재귀 함수를 컴파일하는 코드 생성기에는 영향을 주지 않습니다. #eval fib <n>의 실행 시간이 <n>에 대해 지수적이라는 점을 떠올려 보십시오. 반면에 #reduce fib <n>은 커널로 전송된, brecOn 구성에 기반한 정의를 사용하기 때문에 효율적입니다.
def fib : Nat → Nat
| 0 => 1
| 1 => 1
| n+2 => fib (n+1) + fib n
-- Slow:
-- #eval fib 50
-- Fast:
#reduce fib 50
#print fib
재귀 정의의 또 다른 좋은 예는 리스트의 append 함수입니다.
def append : List α → List α → List α
| [], bs => bs
| a::as, bs => a :: append as bs
example : append [1, 2, 3] [4, 5] = [1, 2, 3, 4, 5] := rfl
또 다른 예를 들어 보겠습니다. 이 함수는 두 리스트 중 하나가 끝날 때까지 첫 번째 리스트의 원소를 두 번째 리스트의 원소에 더합니다.
def listAdd [Add α] : List α → List α → List α
| [], _ => []
| _, [] => []
| a :: as, b :: bs => (a + b) :: listAdd as bs
#eval listAdd [1, 2, 3] [4, 5, 6, 6, 9, 10]아래 연습 문제에서 비슷한 예제를 직접 실험해 보시기를 권합니다.
8.4. 지역 재귀 선언
let rec 키워드를 사용하면 지역 재귀 선언을 정의할 수 있습니다.
def replicate (n : Nat) (a : α) : List α :=
let rec loop : Nat → List α → List α
| 0, as => as
| n+1, as => loop n (a::as)
loop n []
#check @replicate.loop
Lean은 각 let rec마다 보조 선언을 하나씩 생성합니다. 위 예제에서는 replicate에 나타나는 let rec loop에 대해 replicate.loop 선언을 생성했습니다. Lean은 let rec 선언에 나타나는 모든 지역 변수를 추가 매개변수로 덧붙여 선언을 “닫는다”는 점에 유의하십시오. 예를 들어, 지역 변수 a는 let rec loop에 나타납니다.
let rec은 택틱 모드에서도 사용할 수 있으며, 귀납법으로 증명을 작성할 때에도 사용할 수 있습니다.
def replicate (n : Nat) (a : α) : List α :=
let rec loop : Nat → List α → List α
| 0, as => as
| n+1, as => loop n (a::as)
loop n []
theorem length_replicate (n : Nat) (a : α) :
(replicate n a).length = n := α:Type u_1n:Nata:α⊢ (replicate n a).length = n
let rec aux (n : Nat) (as : List α) :
(replicate.loop a n as).length = n + as.length := α:Type u_1n✝:Nata:αn:Natas:List α⊢ (replicate.loop a n as).length = n + as.length
match n with
α:Type u_1n✝:Nata:αn:Natas:List α⊢ (replicate.loop a 0 as).length = 0 + as.length All goals completed! 🐙
α:Type u_1n✝¹:Nata:αn✝:Natas:List αn:Nat⊢ (replicate.loop a (n + 1) as).length = n + 1 + as.length All goals completed! 🐙
All goals completed! 🐙
정의 뒤에 where 절을 사용하여 보조 재귀 선언을 도입할 수도 있습니다. Lean은 이를 let rec으로 변환합니다.
def replicate (n : Nat) (a : α) : List α :=
loop n []
where
loop : Nat → List α → List α
| 0, as => as
| n+1, as => loop n (a::as)
theorem length_replicate (n : Nat) (a : α) :
(replicate n a).length = n := α:Type u_1n:Nata:α⊢ (replicate n a).length = n
All goals completed! 🐙
where
aux (n : Nat) (as : List α) :
(replicate.loop a n as).length = n + as.length := α:Type u_1n✝:Nata:αn:Natas:List α⊢ (replicate.loop a n as).length = n + as.length
match n with
α:Type u_1n✝:Nata:αn:Natas:List α⊢ (replicate.loop a 0 as).length = 0 + as.length All goals completed! 🐙
α:Type u_1n✝¹:Nata:αn✝:Natas:List αn:Nat⊢ (replicate.loop a (n + 1) as).length = n + 1 + as.length All goals completed! 🐙
8.5. 정초 재귀와 귀납법
구조적 재귀를 사용할 수 없을 때에는 정초 재귀(well-founded recursion)를 사용하여 종료를 증명할 수 있습니다. 이를 위해서는 정초 관계(well-founded relation)와 함께, 각 재귀 적용이 이 관계에 대해 감소한다는 증명이 필요합니다. 의존 타입 이론은 정초 재귀를 인코딩하고 정당화할 수 있을 만큼 강력합니다. 먼저 정초 재귀가 어떻게 작동하는지 이해하는 데 필요한 논리적 배경부터 살펴봅시다.
Lean의 표준 라이브러리는 두 술어 Acc r a와 WellFounded r을 정의합니다. 여기서 r은 타입 α 위의 이항 관계이고, a는 타입 α의 원소입니다.
variable (α : Sort u)
variable (r : α → α → Prop)
#check (Acc r : α → Prop)#check (WellFounded r : Prop)
첫 번째인 Acc는 귀납적으로 정의된 술어입니다. 그 정의에 따르면 Acc r x는 ∀ y, r y x → Acc r y와 동치입니다. r y x가 일종의 순서 관계 y ≺ x를 나타낸다고 생각하면, Acc r x는 x의 모든 선행자가 접근 가능하다는 의미에서 해당 원소가 아래로부터 접근 가능하다는 것을 뜻합니다. 특히 선행자가 없는 x는 접근 가능합니다. 임의의 타입 α가 주어지면, α의 접근 가능한 각 원소에 대해 그 모든 선행자에 먼저 값을 할당하는 방식으로 재귀적으로 값을 할당할 수 있어야 합니다.
WellFounded r로 표기하는, r이 정초적(well-founded)이라는 명제는 정확히 그 타입의 모든 원소가 접근 가능(accessible)하다는 명제입니다. 위의 고찰에 따르면, r이 타입 α 위의 정초 관계라면 관계 r에 대한 α 위의 정초 재귀 원리가 있어야 합니다. 실제로 그러한 원리가 있습니다. 표준 라이브러리는 바로 그 목적을 위해 WellFounded.fix를 정의합니다.
noncomputable
def f {α : Sort u}
(r : α → α → Prop)
(h : WellFounded r)
(C : α → Sort v)
(F : (x : α) → ((y : α) → r y x → C y) → C x) :
(x : α) → C x :=
WellFounded.fix h F
여기에는 등장하는 요소가 많지만, 첫 번째 묶음은 이미 살펴본 것입니다. 타입 α, 관계 r, 그리고 r이 정초적(well-founded)이라는 가정 h입니다. 변수 C는 재귀 정의의 동기(motive)를 나타냅니다. 즉, 각 원소 x : α에 대해 C x의 원소를 구성하고자 합니다. 함수 F는 이를 위한 귀납적 방법을 제공합니다. 즉, x의 각 선행자 y에 대해 C y의 원소가 주어졌을 때 원소 C x를 어떻게 구성하는지 알려 줍니다.
WellFounded.fix는 귀납법 원리로도 똑같이 잘 작동한다는 점에 유의하십시오. 이는 ≺가 정초적(well-founded)이고 ∀ x, C x를 증명하고자 할 때, 임의의 x에 대해 ∀ y, r y x → C y가 성립하면 C x가 성립함을 보이는 것으로 충분하다는 뜻입니다.
위 예제에서는 현재 코드 생성기가 WellFounded.fix를 지원하지 않기 때문에 noncomputable 수식어를 사용합니다. WellFounded.fix 함수는 Lean이 함수의 종료를 정당화하는 데 사용하는 또 다른 도구입니다.
Lean은 자연수에 대한 일반적인 순서 <가 정초적(well founded)임을 알고 있습니다. 또한 Lean은 사전식 순서를 사용하는 방법 등, 기존의 정초적 순서로부터 새로운 정초적 순서를 구성하는 여러 방법도 알고 있습니다.
다음은 표준 라이브러리에 있는 자연수 나눗셈의 정의를 사실상 그대로 옮긴 것입니다.
open Nat
theorem div_lemma {x y : Nat} : 0 < y ∧ y ≤ x → x - y < x :=
fun h => sub_lt (Nat.lt_of_lt_of_le h.left h.right) h.left
def div.F (x : Nat) (f : (x₁ : Nat) → x₁ < x → Nat → Nat) (y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
f (x - y) (div_lemma h) y + 1
else
zero
noncomputable def div := WellFounded.fix (measure id).wf div.F
이 정의는 다소 이해하기 어렵습니다. 여기서 재귀는 x에 대해 이루어지며, div.F x f : Nat → Nat는 그 고정된 x에 대한 “y로 나누기” 함수를 반환합니다. 재귀의 방법을 정해 주는 div.F의 두 번째 인자는 x보다 작은 모든 값 x₁에 대해 y로 나누기 함수를 반환해야 하는 함수라는 점을 기억해야 합니다.
읽기 어렵다는 점 외에도, 이 div 정의로는 div 8 2와 4가 정의적으로 같지 않습니다:
open Nat
theorem div_lemma {x y : Nat} : 0 < y ∧ y ≤ x → x - y < x :=
fun h => sub_lt (Nat.lt_of_lt_of_le h.left h.right) h.left
def div.F (x : Nat) (f : (x₁ : Nat) → x₁ < x → Nat → Nat) (y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
f (x - y) (div_lemma h) y + 1
else
zero
noncomputable def div := WellFounded.fix (measure id).wf div.F
example : div 8 2 = 4 := ⊢ div 8 2 = 4 ⊢ div 8 2 = 4대신 이 등식은 명제적으로만 성립하며, 접근 가능 관계(accessible relation)에 관한 표준 라이브러리 정리들을 사용하여 증명해야 합니다:
open Nat
theorem div_lemma {x y : Nat} : 0 < y ∧ y ≤ x → x - y < x :=
fun h => sub_lt (Nat.lt_of_lt_of_le h.left h.right) h.left
def div.F (x : Nat) (f : (x₁ : Nat) → x₁ < x → Nat → Nat) (y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
f (x - y) (div_lemma h) y + 1
else
zero
noncomputable def div := WellFounded.fix (measure id).wf div.F
example : div 8 2 = 4 := ⊢ div 8 2 = 4
All goals completed! 🐙
정교화기는 이와 같은 정의를 더 편리하게 작성할 수 있도록 설계되었습니다. 정교화기는 다음을 받아들입니다:
def div (x y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
have : x - y < x := Nat.sub_lt (Nat.lt_of_lt_of_le h.1 h.2) h.1
div (x - y) y + 1
else
0
Lean은 재귀 정의를 만나면 먼저 구조적 재귀를 시도하고, 그것이 실패할 때에만 정초 재귀(well-founded recursion)로 넘어갑니다. Lean은 재귀 적용이 더 작다는 것을 보이기 위해 택틱 decreasing_tactic을 사용합니다. 위 예제의 보조 명제 x - y < x는 이 택틱을 위한 힌트로 보아야 합니다. 정교화기는 또한 재귀 정의에 대한 일련의 등식 보조정리를 증명하므로, 접근 가능 관계에 대한 저수준 보조정리에 의존하지 않고도 그 정의를 다룰 수 있습니다.
div의 정의 방정식은 정의적으로 성립하지 않지만, unfold 택틱을 사용하여 div를 펼칠 수 있습니다. conv를 사용하여 펼치려는 div 적용을 선택합니다.
def div (x y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
have : x - y < x := Nat.sub_lt (Nat.lt_of_lt_of_le h.1 h.2) h.1
div (x - y) y + 1
else
0
example (x y : Nat) :
div x y =
if 0 < y ∧ y ≤ x then
div (x - y) y + 1
else 0 := x:Naty:Nat⊢ div x y = if 0 < y ∧ y ≤ x then div (x - y) y + 1 else 0
-- unfold occurrence in the left-hand-side of the equation:
x:Naty:Nat| div x y = if 0 < y ∧ y ≤ x then div (x - y) y + 1 else 0 x:Naty:Nat| div x y; x:Naty:Nat| if h : 0 < y ∧ y ≤ x then
have this := ⋯;
div (x - y) y + 1
else 0
All goals completed! 🐙
example (x y : Nat) (h : 0 < y ∧ y ≤ x) :
div x y = div (x - y) y + 1 := x:Naty:Nath:0 < y ∧ y ≤ x⊢ div x y = div (x - y) y + 1
x:Naty:Nath:0 < y ∧ y ≤ x| div x y = div (x - y) y + 1 x:Naty:Nath:0 < y ∧ y ≤ x| div x y; x:Naty:Nath:0 < y ∧ y ≤ x| if h : 0 < y ∧ y ≤ x then
have this := ⋯;
div (x - y) y + 1
else 0
All goals completed! 🐙
다음 예제도 비슷합니다. 이 예제는 임의의 자연수를 0과 1의 리스트로 표현되는 이진 표현식으로 변환합니다. 재귀 호출이 감소한다는 근거를 제시해야 하는데, 여기서는 sorry로 이를 대신합니다. sorry가 있어도 인터프리터는 함수를 문제없이 평가할 수 있습니다. 다만 항에 sorry가 포함되어 있으면 #eval 대신 #eval!을 사용해야 합니다.
def natToBin : Nat → List Nat
| 0 => [0]
| 1 => [1]
| n + 2 =>
have : (n + 2) / 2 < n + 2 := sorry
natToBin ((n + 2) / 2) ++ [n % 2]
#eval! natToBin 1234567
마지막 예로, 아커만 함수는 자연수에 대한 사전식 순서의 정초성으로 정당화되므로 직접 정의할 수 있음을 확인할 수 있습니다. termination_by 절은 Lean에게 사전식 순서를 사용하도록 지시합니다. 이 절은 실제로 함수 인자들을 Nat × Nat 타입의 원소로 사상합니다. 그런 다음 Lean은 타입 클래스 해결을 사용하여 WellFoundedRelation (Nat × Nat) 타입의 원소를 합성합니다.
def ack : Nat → Nat → Nat
| 0, y => y+1
| x+1, 0 => ack x 1
| x+1, y+1 => ack x (ack (x+1) y)
termination_by x y => (x, y)
많은 경우 Lean은 적절한 사전식 순서를 자동으로 결정할 수 있습니다. 아커만 함수도 그러한 경우에 해당하므로 termination_by 절은 선택 사항입니다:
위 예제에서 사전식 순서가 사용된 것은 인스턴스 WellFoundedRelation (α × β)가 사전식 순서를 사용하기 때문이라는 점에 유의하십시오. Lean은 다음 인스턴스도 정의합니다.
instance (priority := low) [SizeOf α] : WellFoundedRelation α :=
sizeOfWFRel
다음 예제에서는 재귀 적용에서 as.size - i가 감소함을 보여 종료를 증명합니다.
def takeWhile (p : α → Bool) (as : Array α) : Array α :=
go 0 #[]
where
go (i : Nat) (r : Array α) : Array α :=
if h : i < as.size then
let a := as[i]
if p a then
go (i+1) (r.push a)
else
r
else
r
termination_by as.size - i
이 예제에서 보조 함수 go는 재귀적이지만 takeWhile은 재귀적이지 않다는 점에 유의하십시오. 이번에도 Lean이 이 패턴을 자동으로 인식할 수 있으므로 termination_by 절은 필요하지 않습니다:
def takeWhile (p : α → Bool) (as : Array α) : Array α :=
go 0 #[]
where
go (i : Nat) (r : Array α) : Array α :=
if h : i < as.size then
let a := as[i]
if p a then
go (i+1) (r.push a)
else
r
else
r
기본적으로 Lean은 재귀 적용이 감소함을 증명하기 위해 decreasing_tactic 택틱을 사용합니다. decreasing_by 수식어를 사용하면 직접 작성한 택틱을 제공할 수 있습니다. 다음은 예시입니다.
theorem div_lemma {x y : Nat} : 0 < y ∧ y ≤ x → x - y < x :=
fun ⟨ypos, ylex⟩ => Nat.sub_lt (Nat.lt_of_lt_of_le ypos ylex) ypos
def div (x y : Nat) : Nat :=
if h : 0 < y ∧ y ≤ x then
div (x - y) y + 1
else
0
decreasing_by y:Natx:Nath:0 < y ∧ y ≤ x⊢ 0 < y ∧ y ≤ x; All goals completed! 🐙
decreasing_by는 termination_by를 대체하는 것이 아니라 서로 보완하는 관계라는 점에 유의하십시오. termination_by는 정초 관계(well-founded relation)를 지정하는 데 쓰이고, decreasing_by는 재귀 적용이 감소함을 보이는 우리 자신의 택틱을 제공하는 데 쓰입니다. 다음 예제에서는 이 둘을 모두 사용합니다.
def ack : Nat → Nat → Nat
| 0, y => y+1
| x+1, 0 => ack x 1
| x+1, y+1 => ack x (ack (x+1) y)
termination_by x y => (x, y)
decreasing_by
-- unfolds well-founded recursion auxiliary definitions:
all_goals x:Naty:Natx✝:(y_1 : (_ : Nat) ×' Nat) →
(invImage (fun x => PSigma.casesOn x fun a a_1 => (a, a_1)) Prod.instWellFoundedRelation).1 y_1 ⟨x.succ, y.succ⟩ → Nat⊢ Prod.Lex (fun a₁ a₂ => a₁ < a₂) (fun a₁ a₂ => a₁ < a₂) (x, x✝ ⟨x + 1, y⟩ ⋯) (x + 1, y + 1)
x:Nat⊢ Prod.Lex (fun a₁ a₂ => a₁ < a₂) (fun a₁ a₂ => a₁ < a₂) (x, 1) (x + 1, 0) x:Nat⊢ x < x + 1; All goals completed! 🐙
x:Naty:Nat⊢ Prod.Lex (fun a₁ a₂ => a₁ < a₂) (fun a₁ a₂ => a₁ < a₂) (x + 1, y) (x + 1, y + 1) x:Naty:Nat⊢ y < y + 1; All goals completed! 🐙
x:Naty:Natx✝:(y_1 : (_ : Nat) ×' Nat) →
(invImage (fun x => PSigma.casesOn x fun a a_1 => (a, a_1)) Prod.instWellFoundedRelation).1 y_1 ⟨x.succ, y.succ⟩ → Nat⊢ Prod.Lex (fun a₁ a₂ => a₁ < a₂) (fun a₁ a₂ => a₁ < a₂) (x, x✝ ⟨x + 1, y⟩ ⋯) (x + 1, y + 1) x:Naty:Natx✝:(y_1 : (_ : Nat) ×' Nat) →
(invImage (fun x => PSigma.casesOn x fun a a_1 => (a, a_1)) Prod.instWellFoundedRelation).1 y_1 ⟨x.succ, y.succ⟩ → Nat⊢ x < x + 1; All goals completed! 🐙
decreasing_by sorry를 사용하면 함수가 종료된다는 우리의 말을 “믿도록” Lean에 지시할 수 있습니다.
def natToBin : Nat → List Nat
| 0 => [0]
| 1 => [1]
| n + 2 => natToBin ((n + 2) / 2) ++ [n % 2]
decreasing_by All goals completed! 🐙
#eval! natToBin 1234567
sorry를 사용하는 것은 새로운 공리를 사용하는 것과 같으므로 피해야 한다는 점을 떠올리십시오. 다음 예제에서는 sorry를 사용하여 False를 증명했습니다. #print axioms unsound 명령은 unsound가 sorry를 구현하는 데 사용된 불건전한 공리 sorryAx에 의존한다는 것을 보여 줍니다.
def unsound (x : Nat) : False :=
unsound (x + 1)
decreasing_by All goals completed! 🐙
#check unsound 0-- `unsound 0` is a proof of `False`
#print axioms unsound요약:
-
termination_by가 없으면, 인자 하나를 선택한 다음 타입 클래스 해결을 사용하여 그 인자의 타입에 대한 정초 관계를 합성함으로써 (가능한 경우) 정초 관계가 도출됩니다. -
termination_by가 지정되면, 이는 함수의 인자를 타입α로 사상하며 다시 타입 클래스 해소가 사용됩니다.β × γ에 대한 기본 인스턴스는β와γ에 대한 정초 관계에 기반한 사전식 순서라는 점을 상기하십시오. -
Nat의 기본 정초 관계(well-founded relation) 인스턴스는(· < ·)입니다. -
기본적으로, 재귀 적용이 선택된 정초 관계(well-founded relation)에 대해 더 작다는 것을 보이는 데에는
decreasing_tactic택틱이 사용됩니다.decreasing_tactic이 실패하면, 오류 메시지에 남은 목표... |- G가 포함됩니다.decreasing_tactic은assumption을 사용한다는 점에 유의하십시오. 따라서have-표현식을 포함하여 목표G를 증명할 수 있습니다. 또한decreasing_by를 사용하여 직접 작성한 택틱을 제공할 수도 있습니다.
8.6. 함수적 귀납법
Lean은 재귀 함수를 위한 맞춤형 귀납법 원리를 생성합니다. 이러한 귀납법 원리는 데이터 타입의 구조가 아니라 함수 정의의 재귀 구조를 따릅니다. 함수에 대한 증명은 일반적으로 함수 자체의 재귀 구조를 따르므로, 이러한 귀납법 원리를 사용하면 함수에 관한 진술을 더 편리하게 증명할 수 있습니다.
예를 들어, ack의 함수적 귀납법 원리를 사용하여 결과가 항상 0보다 크다는 것을 증명하려면 ack의 패턴 매칭에 있는 각 갈래마다 하나의 경우가 필요합니다:
def ack : Nat → Nat → Nat
| 0, y => y+1
| x+1, 0 => ack x 1
| x+1, y+1 => ack x (ack (x+1) y)
theorem ack_gt_zero : ack n m > 0 := n:Natm:Nat⊢ ack n m > 0
fun_induction ack with
y:Nat⊢ y + 1 > 0
All goals completed! 🐙
x:Natih:ack x 1 > 0⊢ ack x 1 > 0
All goals completed! 🐙
x:Naty:Natih1:ack (x + 1) y > 0ih2:ack x (ack (x + 1) y) > 0⊢ ack x (ack (x + 1) y) > 0
All goals completed! 🐙
case1y:Nat⊢ y + 1 > 0에서 목표는 다음과 같습니다:
목표에 있는 y + 1은 ack의 첫 번째 경우에서 반환되는 값에 해당합니다.
case2x:Natih:ack x 1 > 0⊢ ack x 1 > 0에서 목표는 다음과 같습니다:
목표에 있는 ack x 1은 ack의 두 번째 경우에서 반환되는 값, 즉 패턴 변수 x + 1과 0에 ack을 적용한 값에 해당합니다. 이 항은 자동으로 우변으로 단순화됩니다. 다행히도 귀납 가설 ih : ack x 1 > 0은 재귀 호출에 해당하며, 이 재귀 호출이 바로 이 경우에 반환되는 답입니다.
case3x:Naty:Natih1:ack (x + 1) y > 0ih2:ack x (ack (x + 1) y) > 0⊢ ack x (ack (x + 1) y) > 0에서 목표는 다음과 같습니다:
목표의 ack x (ack (x + 1) y)는 x + 1과 y + 1에 적용된 ack이 환원되었을 때 ack의 세 번째 경우에서 반환되는 값에 해당합니다. 귀납 가설 ih1 : ack (x + 1) y > 0과 ih2 : ack x (ack (x + 1) y) > 0은 재귀 호출에 해당하며, ih1은 중첩된 재귀 호출에 대응합니다. 이번에도 귀납법 가설이 적합합니다.
fun_induction ack을 사용하면 ack의 재귀 구조와 일치하는 목표와 귀납법 가설이 만들어집니다. 그 결과, 증명을 한 줄로 작성할 수 있습니다:
theorem ack_gt_zero : ack n m > 0 := n:Natm:Nat⊢ ack n m > 0
y✝:Nat⊢ y✝ + 1 > 0x✝:Natih1✝:ack x✝ 1 > 0⊢ ack x✝ 1 > 0x✝:Naty✝:Natih2✝:ack (x✝ + 1) y✝ > 0ih1✝:ack x✝ (ack (x✝ + 1) y✝) > 0⊢ ack x✝ (ack (x✝ + 1) y✝) > 0 y✝:Nat⊢ y✝ + 1 > 0x✝:Natih1✝:ack x✝ 1 > 0⊢ ack x✝ 1 > 0x✝:Naty✝:Natih2✝:ack (x✝ + 1) y✝ > 0ih1✝:ack x✝ (ack (x✝ + 1) y✝) > 0⊢ ack x✝ (ack (x✝ + 1) y✝) > 0 All goals completed! 🐙
cases 택틱과 유사한 fun_cases 택틱도 있습니다. 이 택틱은 함수의 제어 흐름에 있는 각 분기마다 하나의 케이스를 생성합니다. 이 택틱과 fun_induction은 모두 선택되지 않은 경로를 배제하는 가정을 추가로 제공합니다.
이 함수 f는 다섯 개의 항으로 이루어진 불리언 논리합을 나타냅니다:
def f : Bool → Bool → Bool → Bool → Bool → Bool
| true, _, _, _ , _ => true
| _, true, _, _ , _ => true
| _, _, true, _ , _ => true
| _, _, _, true, _ => true
| _, _, _, _, x => x
이것이 논리합임을 증명하려면, 마지막 경우에는 어떤 인자도 true가 아니라는 사실을 알아야 합니다. 이 사실은 다음 택틱이 제공합니다:
def f : Bool → Bool → Bool → Bool → Bool → Bool
| true, _, _, _ , _ => true
| _, true, _, _ , _ => true
| _, _, true, _ , _ => true
| _, _, _, true, _ => true
| _, _, _, _, x => x
theorem f_or : f b1 b2 b3 b4 b5 = (b1 || b2 || b3 || b4 || b5) := b1:Boolb2:Boolb3:Boolb4:Boolb5:Bool⊢ f b1 b2 b3 b4 b5 = (b1 || b2 || b3 || b4 || b5)
b2:Boolb3:Boolb4:Boolb5:Bool⊢ true = (true || b2 || b3 || b4 || b5)b1:Boolb3:Boolb4:Boolb5:Boolx✝:b1 = true → False⊢ true = (b1 || true || b3 || b4 || b5)b1:Boolb2:Boolb4:Boolb5:Boolx✝¹:b1 = true → Falsex✝:b2 = true → False⊢ true = (b1 || b2 || true || b4 || b5)b1:Boolb2:Boolb3:Boolb5:Boolx✝²:b1 = true → Falsex✝¹:b2 = true → Falsex✝:b3 = true → False⊢ true = (b1 || b2 || b3 || true || b5)b1:Boolb2:Boolb3:Boolb4:Boolb5:Boolx✝³:b1 = true → Falsex✝²:b2 = true → Falsex✝¹:b3 = true → Falsex✝:b4 = true → False⊢ b5 = (b1 || b2 || b3 || b4 || b5)
all_goals All goals completed! 🐙
각 경우에는 앞선 경우들을 배제하는 가정이 포함되어 있습니다:
모든 가정과 목표를 함께 단순화하는 simp_all 택틱으로 모든 경우를 처리할 수 있습니다:
def f : Bool → Bool → Bool → Bool → Bool → Bool
| true, _, _, _ , _ => true
| _, true, _, _ , _ => true
| _, _, true, _ , _ => true
| _, _, _, true, _ => true
| _, _, _, _, x => x
theorem f_or : f b1 b2 b3 b4 b5 = (b1 || b2 || b3 || b4 || b5) := b1:Boolb2:Boolb3:Boolb4:Boolb5:Bool⊢ f b1 b2 b3 b4 b5 = (b1 || b2 || b3 || b4 || b5)
b2:Boolb3:Boolb4:Boolb5:Bool⊢ true = (true || b2 || b3 || b4 || b5)b1:Boolb3:Boolb4:Boolb5:Boolx✝:b1 = true → False⊢ true = (b1 || true || b3 || b4 || b5)b1:Boolb2:Boolb4:Boolb5:Boolx✝¹:b1 = true → Falsex✝:b2 = true → False⊢ true = (b1 || b2 || true || b4 || b5)b1:Boolb2:Boolb3:Boolb5:Boolx✝²:b1 = true → Falsex✝¹:b2 = true → Falsex✝:b3 = true → False⊢ true = (b1 || b2 || b3 || true || b5)b1:Boolb2:Boolb3:Boolb4:Boolb5:Boolx✝³:b1 = true → Falsex✝²:b2 = true → Falsex✝¹:b3 = true → Falsex✝:b4 = true → False⊢ b5 = (b1 || b2 || b3 || b4 || b5) b2:Boolb3:Boolb4:Boolb5:Bool⊢ true = (true || b2 || b3 || b4 || b5)b1:Boolb3:Boolb4:Boolb5:Boolx✝:b1 = true → False⊢ true = (b1 || true || b3 || b4 || b5)b1:Boolb2:Boolb4:Boolb5:Boolx✝¹:b1 = true → Falsex✝:b2 = true → False⊢ true = (b1 || b2 || true || b4 || b5)b1:Boolb2:Boolb3:Boolb5:Boolx✝²:b1 = true → Falsex✝¹:b2 = true → Falsex✝:b3 = true → False⊢ true = (b1 || b2 || b3 || true || b5)b1:Boolb2:Boolb3:Boolb4:Boolb5:Boolx✝³:b1 = true → Falsex✝²:b2 = true → Falsex✝¹:b3 = true → Falsex✝:b4 = true → False⊢ b5 = (b1 || b2 || b3 || b4 || b5) All goals completed! 🐙
8.7. 상호 재귀
Lean은 상호 재귀 정의도 지원합니다. 구문은 상호 귀납적 타입의 구문과 비슷합니다. 다음은 예시입니다:
mutual
def even : Nat → Bool
| 0 => true
| n+1 => odd n
def odd : Nat → Bool
| 0 => false
| n+1 => even n
end
example : even (a + 1) = odd a := a:Nat⊢ even (a + 1) = odd a
All goals completed! 🐙
example : odd (a + 1) = even a := a:Nat⊢ odd (a + 1) = even a
All goals completed! 🐙
theorem even_eq_not_odd : ∀ a, even a = not (odd a) := ⊢ ∀ (a : Nat), even a = !odd a
a:Nat⊢ even a = !odd a; ⊢ even 0 = !odd 0n✝:Nata✝:even n✝ = !odd n✝⊢ even (n✝ + 1) = !odd (n✝ + 1)
⊢ even 0 = !odd 0 All goals completed! 🐙
n✝:Nata✝:even n✝ = !odd n✝⊢ even (n✝ + 1) = !odd (n✝ + 1) All goals completed! 🐙
이것을 상호 정의로 만드는 것은 even이 odd를 사용한 항으로 재귀적으로 정의되고, odd는 even을 사용한 항으로 재귀적으로 정의된다는 점입니다. 내부적으로 이것은 하나의 재귀 정의로 컴파일됩니다. 내부적으로 정의된 함수는 합 타입의 원소, 즉 even에 대한 입력 또는 odd에 대한 입력을 인수로 받습니다. 그런 다음 그 입력에 맞는 출력을 반환합니다. 이 함수를 정의하기 위해 Lean은 적절한 정초 측도(well-founded measure)를 사용합니다. 이러한 내부 구현은 사용자에게 감추어지도록 되어 있습니다. 이러한 정의를 활용하는 표준적인 방법은 앞에서 한 것처럼 simp(또는 unfold)를 사용하는 것입니다.
상호 재귀 정의를 사용하면 상호 귀납적 타입과 중첩 귀납적 타입도 자연스럽게 다룰 수 있습니다. 앞에서 상호 귀납적 술어로 제시한 Even과 Odd의 정의를 다시 떠올려 봅시다.
mutual
inductive Even : Nat → Prop where
| even_zero : Even 0
| even_succ : ∀ n, Odd n → Even (n + 1)
inductive Odd : Nat → Prop where
| odd_succ : ∀ n, Even n → Odd (n + 1)
end
생성자 even_zero, even_succ, odd_succ는 어떤 수가 짝수이거나 홀수임을 보이는 긍정적인 수단을 제공합니다. 0이 홀수가 아니라는 것과 뒤의 두 함의가 역으로도 성립한다는 것을 알려면, 이 귀납적 타입이 이 생성자들로 생성된다는 사실을 이용해야 합니다. 늘 그렇듯이 생성자들은 정의되는 타입의 이름을 딴 네임스페이스에 들어 있으며, open Even Odd 명령을 사용하면 이들에 더 편리하게 접근할 수 있습니다.
mutual
inductive Even : Nat → Prop where
| even_zero : Even 0
| even_succ : ∀ n, Odd n → Even (n + 1)
inductive Odd : Nat → Prop where
| odd_succ : ∀ n, Even n → Odd (n + 1)
end
open Even Odd
theorem not_odd_zero : ¬ Odd 0 :=
fun h => nomatch h
theorem even_of_odd_succ : ∀ n, Odd (n + 1) → Even n
| _, odd_succ n h => h
theorem odd_of_even_succ : ∀ n, Even (n + 1) → Odd n
| _, even_succ n h => h
또 다른 예로, 중첩 귀납적 타입을 사용하여 항(term)의 집합을 귀납적으로 정의한다고 가정해 봅시다. 이때 항은 (문자열로 이름이 주어지는) 상수이거나, 상수를 상수들의 리스트에 적용한 결과입니다.
그러면 상호 재귀 정의를 사용하여 항에 나타나는 상수의 개수와 항 목록에 나타나는 상수의 개수를 셀 수 있습니다.
namespace Term
mutual
def numConsts : Term → Nat
| const _ => 1
| app _ cs => numConstsLst cs
def numConstsLst : List Term → Nat
| [] => 0
| c :: cs => numConsts c + numConstsLst cs
end
def sample := app "f" [app "g" [const "x"], const "y"]
#eval numConsts sample
end Term
마지막 예제로, 항 e에서 상수 a를 b로 치환하는 함수 replaceConst a b e를 정의한 다음, 상수의 개수가 같다는 것을 증명합니다. 이 증명은 상호 재귀(귀납법이라고도 합니다)를 사용한다는 점에 유의하십시오.
inductive Term where
| const : String → Term
| app : String → List Term → Term
namespace Term
mutual
def numConsts : Term → Nat
| const _ => 1
| app _ cs => numConstsLst cs
def numConstsLst : List Term → Nat
| [] => 0
| c :: cs => numConsts c + numConstsLst cs
end
mutual
def replaceConst (a b : String) : Term → Term
| const c => if a == c then const b else const c
| app f cs => app f (replaceConstLst a b cs)
def replaceConstLst (a b : String) : List Term → List Term
| [] => []
| c :: cs => replaceConst a b c :: replaceConstLst a b cs
end
mutual
theorem numConsts_replaceConst (a b : String) (e : Term) :
numConsts (replaceConst a b e) = numConsts e := a:Stringb:Stringe:Term⊢ (replaceConst a b e).numConsts = e.numConsts
match e with
a:Stringb:Stringe:Termc:String⊢ (replaceConst a b (const c)).numConsts = (const c).numConsts
a:Stringb:Stringe:Termc:String⊢ (if a = c then const b else const c).numConsts = (const c).numConsts; a:Stringb:Stringe:Termc:Stringh✝:a = c⊢ (const b).numConsts = (const c).numConstsa:Stringb:Stringe:Termc:Stringh✝:¬a = c⊢ (const c).numConsts = (const c).numConsts a:Stringb:Stringe:Termc:Stringh✝:a = c⊢ (const b).numConsts = (const c).numConstsa:Stringb:Stringe:Termc:Stringh✝:¬a = c⊢ (const c).numConsts = (const c).numConsts All goals completed! 🐙
a:Stringb:Stringe:Termf:Stringcs:List Term⊢ (replaceConst a b (app f cs)).numConsts = (app f cs).numConsts
All goals completed! 🐙
theorem numConsts_replaceConstLst (a b : String) (es : List Term) :
numConstsLst (replaceConstLst a b es) = numConstsLst es := a:Stringb:Stringes:List Term⊢ numConstsLst (replaceConstLst a b es) = numConstsLst es
match es with
a:Stringb:Stringes:List Term⊢ numConstsLst (replaceConstLst a b []) = numConstsLst [] All goals completed! 🐙
a:Stringb:Stringes:List Termc:Termcs:List Term⊢ numConstsLst (replaceConstLst a b (c :: cs)) = numConstsLst (c :: cs)
All goals completed! 🐙
end
8.8. 의존 패턴 매칭
패턴 매칭 절에서 살펴본 패턴 매칭 예제는 모두 casesOn과 recOn을 사용하여 쉽게 작성할 수 있습니다. 그러나 Vect α n과 같이 인덱스가 있는 귀납적 족(inductive family)에서는 그렇지 않은 경우가 많습니다. 경우 분할이 인덱스 값에 제약을 가하기 때문입니다. 등식 컴파일러가 없다면 map, zip, unzip과 같은 매우 간단한 함수를 재귀자로 정의하는 데에도 많은 상용구 코드가 필요합니다. 이것이 왜 어려운지 이해하기 위해, 벡터 v : Vect α (n + 1)을 받아 첫 번째 원소를 삭제하는 함수 tail을 정의하려면 무엇이 필요한지 생각해 봅시다.
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n + 1)
먼저 Vect.casesOn 함수를 사용하는 방법을 떠올릴 수 있습니다:
Vect.casesOn.{u, v}
{α : Type v} {motive : (a : Nat) → Vect α a → Sort u}
{a : Nat}
(t : Vect α a)
(nil : motive 0 nil)
(cons : (a : α) → {n : Nat} → (a_1 : Vect α n) →
motive (n + 1) (cons a a_1)) :
motive a t
그렇다면 nil 경우에는 어떤 값을 반환해야 합니까? 뭔가 묘한 일이 벌어지고 있습니다. v의 타입이 Vect α (n + 1)이라면 이는 nil일 수 없지만, 이 사실을 Vect.casesOn에 어떻게 알려야 할지는 분명하지 않습니다.
한 가지 해결책은 보조 함수를 정의하는 것입니다:
set_option linter.unusedVariables false
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
namespace Vect
def tailAux (v : Vect α m) : m = n + 1 → Vect α n :=
Vect.casesOn (motive := fun x _ => x = n + 1 → Vect α n) v
(fun h : 0 = n + 1 => Nat.noConfusion h)
(fun (a : α) (m : Nat) (as : Vect α m) =>
fun (h : m + 1 = n + 1) =>
Nat.noConfusion h (fun h1 : m = n => h1 ▸ as))
def tail (v : Vect α (n+1)) : Vect α n :=
tailAux v rfl
end Vect
nil 경우에는 m이 0으로 인스턴스화되며, Nat.noConfusion은 0 = n + 1이 성립할 수 없다는 사실을 이용합니다. 그렇지 않은 경우 v는 cons a as 형태이므로, as를 길이가 m인 벡터에서 길이가 n인 벡터로 캐스팅한 뒤 그대로 반환하면 됩니다.
tail을 정의할 때 어려운 점은 인덱스 사이의 관계를 유지하는 것입니다. tailAux의 가설 m = n + 1은 n과 소전제(minor premise)에 연관된 인덱스 사이의 관계를 전달하는 데 사용됩니다. 또한 0 = n + 1 경우는 도달할 수 없으며, 이러한 경우를 배제하는 표준적인 방법은 Nat.noConfusion을 사용하는 것입니다.
하지만 tail 함수는 재귀 방정식을 사용하면 쉽게 정의할 수 있으며, 등식 컴파일러가 모든 상용구 코드를 자동으로 생성해 줍니다. 다음은 이와 비슷한 몇 가지 예시입니다:
set_option linter.unusedVariables false
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
namespace Vect
def head : {n : Nat} → Vect α (n+1) → α
| n, cons a as => a
def tail : {n : Nat} → Vect α (n+1) → Vect α n
| n, cons a as => as
theorem eta : ∀ {n : Nat} (v : Vect α (n+1)), cons (head v) (tail v) = v
| n, cons a as => rfl
def map (f : α → β → γ) : {n : Nat} → Vect α n → Vect β n → Vect γ n
| 0, nil, nil => nil
| n+1, cons a as, cons b bs => cons (f a b) (map f as bs)
def zip : {n : Nat} → Vect α n → Vect β n → Vect (α × β) n
| 0, nil, nil => nil
| n+1, cons a as, cons b bs => cons (a, b) (zip as bs)
end Vect
head nil과 같은 “도달 불가능한” 경우에 대해서는 재귀 방정식을 생략할 수 있다는 점에 유의하십시오. 인덱스 族(indexed family)에 대해 자동으로 생성되는 정의는 결코 간단하지 않습니다. 예를 들면 다음과 같습니다.
set_option linter.unusedVariables false
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
namespace Vect
def zipWith (f : α → β → γ) : {n : Nat} → Vect α n → Vect β n → Vect γ n
| 0, nil, nil => nil
| n+1, cons a as, cons b bs => cons (f a b) (zipWith f as bs)
#print zipWith#print zipWith.match_1end Vect
zipWith 함수는 tail 함수보다도 직접 정의하기가 훨씬 더 번거롭습니다. Vect.recOn, Vect.casesOn, Vect.noConfusion을 사용하여 직접 시도해 보시기를 권합니다.
8.9. 접근 불가능한 패턴
의존 매칭 패턴의 인수가 정의에 필수적이지는 않지만, 표현식의 타입을 적절히 특수화하기 위해 포함해야 하는 경우가 있습니다. Lean에서는 사용자가 이러한 부분 항을 패턴 매칭에서 접근 불가능하다고 표시할 수 있습니다. 예를 들어 좌변에 나타나는 항이 변수도 아니고 생성자 적용도 아닌 경우에는 이러한 표기가 필수적입니다. 이런 항은 패턴 매칭의 대상으로 적합하지 않기 때문입니다. 이러한 접근 불가능한 패턴은 패턴에서 “신경 쓰지 않는(don't care)” 구성 요소로 볼 수 있습니다. .(t)라고 작성하여 부분 항을 접근 불가능하다고 선언할 수 있습니다. 접근 불가능한 패턴을 추론할 수 있는 경우에는 _라고 작성할 수도 있습니다.
다음 예제에서는 “f의 상(image)에 속한다”는 성질을 정의하는 귀납적 타입을 선언합니다. ImageOf f b 타입의 원소는 b가 f의 상에 속한다는 근거로 볼 수 있으며, 이러한 근거를 만드는 데는 생성자 imf를 사용합니다. 그러면 임의의 함수 f에 대해 “역함수”를 정의할 수 있습니다. 이 역함수는 f의 상에 속한 임의의 값을 그 값으로 사상되는 원소로 보냅니다. 타입 규칙 때문에 첫 번째 인자로 f a를 써야 하지만, 이 항은 변수도 아니고 생성자 적용도 아니며, 패턴 매칭 정의에서 아무런 역할도 하지 않습니다. 아래의 함수 inverse를 정의하려면 f a를 접근 불가능한 것으로 반드시 표시해야 합니다.
inductive ImageOf {α β : Type u} (f : α → β) : β → Type u where
| imf : (a : α) → ImageOf f (f a)
open ImageOf
def inverse {f : α → β} : (b : β) → ImageOf f b → α
| .(f a), imf a => a
def inverse' {f : α → β} : (b : β) → ImageOf f b → α
| _, imf a => a
위 예제에서 접근 불가능 표기는 f가 패턴 매칭 변수가 아님을 분명히 보여 줍니다.
접근 불가능한 패턴은 의존 패턴 매칭을 사용하는 정의를 명확히 하고 제어하는 데 사용할 수 있습니다. 어떤 타입에 연관된 덧셈 함수가 있다고 가정하고, 그 타입의 원소로 이루어진 두 벡터를 더하는 함수 Vect.add의 다음 정의를 살펴봅시다.
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
def Vect.add [Add α] : {n : Nat} → Vect α n → Vect α n → Vect α n
| 0, nil, nil => nil
| n+1, cons a as, cons b bs => cons (a + b) (add as bs)
인수 {n : Nat}는 정의 전체에서 고정된 채로 유지될 수 없으므로 콜론 뒤에 나타납니다. 이 정의를 구현할 때 등식 컴파일러는 첫 번째 인자가 0인지 아니면 n+1 형태인지에 따른 경우 구분으로 시작합니다. 그다음 나머지 두 인자에 대한 중첩된 경우 분할이 이어지며, 각 경우에서 등식 컴파일러는 첫 번째 패턴과 양립할 수 없는 경우들을 배제합니다.
그러나 실제로는 첫 번째 인수에 대한 경우 분할이 필요하지 않습니다. 두 번째 인수에 대해 경우 분할을 하면 Vect의 casesOn 제거자가 이 인수를 자동으로 추상화하여 0과 n + 1로 대체하기 때문입니다. 접근 불가능한 패턴을 사용하면 등식 컴파일러가 n에 대한 경우 분할을 하지 않도록 유도할 수 있습니다.
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
namespace Vect
def add [Add α] : {n : Nat} → Vect α n → Vect α n → Vect α n
| .(_), nil, nil => nil
| .(_), cons a as, cons b bs => cons (a + b) (add as bs)
end Vect
해당 위치를 접근 불가능한 패턴으로 표시하면 등식 컴파일러에 두 가지를 알려 줍니다. 첫째, 인자의 형태는 다른 인자들이 부과하는 제약으로부터 추론되어야 합니다. 둘째, 첫 번째 인자는 패턴 매칭에 참여해서는 안 됩니다.
접근 불가능한 패턴 .(_)는 편의상 _로 쓸 수 있습니다.
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
namespace Vect
def add [Add α] : {n : Nat} → Vect α n → Vect α n → Vect α n
| _, nil, nil => nil
| _, cons a as, cons b bs => cons (a + b) (add as bs)
end Vect
위에서 언급했듯이, 인자 {n : Nat}는 정의 전체에 걸쳐 고정된 채로 유지될 수 없으므로 패턴 매칭의 일부가 됩니다. Lean은 이러한 판별 대상을 명시적으로 제공하도록 요구하지 않고, 이 추가 판별 대상을 암묵적으로 자동 포함해 줍니다.
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
namespace Vect
def add [Add α] {n : Nat} : Vect α n → Vect α n → Vect α n
| nil, nil => nil
| cons a as, cons b bs => cons (a + b) (add as bs)
end Vect
auto bound implicits 기능과 함께 사용하면 선언을 더욱 단순화하여 다음과 같이 작성할 수 있습니다:
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
namespace Vect
def add [Add α] : Vect α n → Vect α n → Vect α n
| nil, nil => nil
| cons a as, cons b bs => cons (a + b) (add as bs)
end Vect
이 새로운 기능들을 사용하면 앞 절에서 정의한 다른 벡터 함수들을 다음과 같이 더 간결하게 작성할 수 있습니다.
set_option linter.unusedVariables false
inductive Vect (α : Type u) : Nat → Type u
| nil : Vect α 0
| cons : α → {n : Nat} → Vect α n → Vect α (n+1)
namespace Vect
def head : Vect α (n+1) → α
| cons a as => a
def tail : Vect α (n+1) → Vect α n
| cons a as => as
theorem eta : (v : Vect α (n+1)) → cons (head v) (tail v) = v
| cons a as => rfl
def map (f : α → β → γ) : Vect α n → Vect β n → Vect γ n
| nil, nil => nil
| cons a as, cons b bs => cons (f a b) (map f as bs)
def zip : Vect α n → Vect β n → Vect (α × β) n
| nil, nil => nil
| cons a as, cons b bs => cons (a, b) (zip as bs)
end Vect
8.10. 매치 표현식
Lean은 또한 많은 함수형 언어에서 볼 수 있는 match-with 표현식을 위한 컴파일러를 제공합니다:
set_option linter.unusedVariables false
def isNotZero (m : Nat) : Bool :=
match m with
| 0 => false
| n + 1 => true
이것은 일반적인 패턴 매칭 정의와 크게 달라 보이지 않지만, 핵심은 match를 표현식의 어느 곳에서나, 그리고 임의의 인자와 함께 사용할 수 있다는 점입니다.
set_option linter.unusedVariables false
def isNotZero (m : Nat) : Bool :=
match m with
| 0 => false
| n + 1 => true
def filter (p : α → Bool) : List α → List α
| [] => []
| a :: as =>
match p a with
| true => a :: filter p as
| false => filter p as
example : filter isNotZero [1, 0, 0, 3, 0] = [1, 3] := rfl
다음은 또 다른 예입니다:
def foo (n : Nat) (b c : Bool) :=
5 + match n - 5, b && c with
| 0, true => 0
| m + 1, true => m + 7
| 0, false => 5
| m + 1, false => m + 3
#eval foo 7 true false
example : foo 7 true false = 9 := rfl
Lean은 시스템의 모든 부분에서 패턴 매칭을 구현하기 위해 내부적으로 match 구문을 사용합니다. 따라서 다음 네 가지 정의는 모두 최종적으로 같은 효과를 냅니다:
def bar₁ : Nat × Nat → Nat
| (m, n) => m + n
def bar₂ (p : Nat × Nat) : Nat :=
match p with
| (m, n) => m + n
def bar₃ : Nat × Nat → Nat :=
fun (m, n) => m + n
def bar₄ (p : Nat × Nat) : Nat :=
let (m, n) := p; m + n
이러한 변형들은 명제를 분해할 때에도 똑같이 유용합니다:
variable (p q : Nat → Prop)
example : (∃ x, p x) → (∃ y, q y) → ∃ x y, p x ∧ q y
| ⟨x, px⟩, ⟨y, qy⟩ => ⟨x, y, px, qy⟩
example (h₀ : ∃ x, p x) (h₁ : ∃ y, q y)
: ∃ x y, p x ∧ q y :=
match h₀, h₁ with
| ⟨x, px⟩, ⟨y, qy⟩ => ⟨x, y, px, qy⟩
example : (∃ x, p x) → (∃ y, q y) → ∃ x y, p x ∧ q y :=
fun ⟨x, px⟩ ⟨y, qy⟩ => ⟨x, y, px, qy⟩
example (h₀ : ∃ x, p x) (h₁ : ∃ y, q y)
: ∃ x y, p x ∧ q y :=
let ⟨x, px⟩ := h₀
let ⟨y, qy⟩ := h₁
⟨x, y, px, qy⟩
8.11. 연습 문제
-
이름 충돌을 피하기 위해
Hidden네임스페이스를 열고, 등식 컴파일러를 사용하여 자연수에 대한 덧셈, 곱셈, 거듭제곱을 정의하십시오. 그런 다음 등식 컴파일러를 사용하여 이들의 기본 성질 몇 가지를 유도하십시오. -
마찬가지로, 등식 컴파일러를 사용하여 리스트에 대한 몇 가지 기본 연산(
reverse함수 등)을 정의하고, 리스트에 관한 정리를 귀납법으로 증명하십시오(예를 들어 임의의 리스트xs에 대해reverse (reverse xs) = xs라는 사실). -
자연수에 대해 값 경과 재귀를 수행하는 함수를 직접 정의해 보십시오. 마찬가지로,
WellFounded.fix를 직접 정의하는 방법을 알아낼 수 있는지 시도해 보십시오. -
의존 패턴 매칭 절의 예제를 따라 두 벡터를 이어 붙이는 함수를 정의하십시오. 이 작업은 까다롭습니다. 보조 함수를 정의해야 합니다.
-
다음과 같은 산술 표현식 타입을 생각해 봅시다. 여기서 의도는
varn이 변수vₙ을 나타내고,constn이 값이n인 상수를 나타낸다는 것입니다.inductive Expr where | const : Nat → Expr | var : Nat → Expr | plus : Expr → Expr → Expr | times : Expr → Expr → Expr deriving Repropen Exprdef sampleExpr : Expr := plus (times (var 0) (const 7)) (times (const 2) (var 1))여기서
sampleExpr는(v₀ * 7) + (2 * v₁)을 나타냅니다.이러한 표현식을 평가하는 함수를 작성하십시오. 이때 각
var n은v n으로 평가되어야 합니다.inductive Expr where | const : Nat → Expr | var : Nat → Expr | plus : Expr → Expr → Expr | times : Expr → Expr → Expr deriving Repr open Expr def sampleExpr : Expr := plus (times (var 0) (const 7)) (times (const 2) (var 1))def eval (v : Nat → Nat) : Expr → Nat | const n => sorry | var n => v n | plus e₁ e₂ => sorry | times e₁ e₂ => sorrydef sampleVal : Nat → Nat | 0 => 5 | 1 => 6 | _ => 0-- Try it out. You should get 47 here. -- #eval eval sampleVal sampleExpr5 + 7과 같은 부분 항을12로 단순화하는 절차인 “상수 융합(constant fusion)”을 구현하십시오. 보조 함수simpConst를 사용하여 “fuse”라는 함수를 정의하십시오. 덧셈이나 곱셈을 단순화할 때는 먼저 인자들을 재귀적으로 단순화한 다음, 그 결과에simpConst를 적용하여 단순화를 시도하십시오.inductive Expr where | const : Nat → Expr | var : Nat → Expr | plus : Expr → Expr → Expr | times : Expr → Expr → Expr deriving Repr open Expr def eval (v : Nat → Nat) : Expr → Nat | const n => sorry | var n => v n | plus e₁ e₂ => sorry | times e₁ e₂ => sorrydef simpConst : Expr → Expr | plus (const n₁) (const n₂) => const (n₁ + n₂) | times (const n₁) (const n₂) => const (n₁ * n₂) | e => edef fuse : Expr → Expr := sorrytheorem simpConst_eq (v : Nat → Nat) : ∀ e : Expr, eval v (simpConst e) = eval v e := sorrytheorem fuse_eq (v : Nat → Nat) : ∀ e : Expr, eval v (fuse e) = eval v e := sorry마지막 두 정리는 이 정의들이 값을 보존한다는 것을 보여 줍니다.