Lean으로 배우는 함수형 프로그래밍

1.6. 다형성🔗

대부분의 언어와 마찬가지로, Lean의 타입도 인자를 받을 수 있습니다. 예를 들어, List Nat 타입은 자연수의 리스트를, List String은 문자열의 리스트를, List (List Point)는 점의 리스트로 이루어진 리스트를 나타냅니다. 이는 C#이나 Java 같은 언어의 List<Nat>, List<String>, List<List<Point>>와 매우 비슷합니다. Lean은 함수에 인자를 전달할 때 공백을 사용하듯이, 타입에 인자를 전달할 때도 공백을 사용합니다.

함수형 프로그래밍에서 다형성이라는 용어는 일반적으로 타입을 인자로 받는 데이터 타입과 정의를 가리킵니다. 이는 객체 지향 프로그래밍 커뮤니티와는 다릅니다. 객체 지향 프로그래밍 커뮤니티에서 이 용어는 일반적으로 상위 클래스의 일부 동작을 재정의할 수 있는 하위 클래스를 가리킵니다. 이 책에서 “다형성”은 항상 첫 번째 의미로 사용됩니다. 이러한 타입 인자는 데이터 타입이나 정의 안에서 사용할 수 있습니다. 따라서 인자의 이름을 다른 타입으로 바꾸어 얻은 어떤 타입에 대해서도 같은 데이터 타입이나 정의를 사용할 수 있습니다.

Point 구조체에서는 x 필드와 y 필드가 모두 Float여야 합니다. 그러나 점이라는 개념 자체에는 각 좌표를 특정한 방식으로 표현해야 할 이유가 전혀 없습니다. Point의 다형적 버전인 PPoint는 타입을 인자로 받아 두 필드 모두에 그 타입을 사용할 수 있습니다:

structure PPoint (α : Type) where x : α y : α

함수 정의에서 인자를 정의하는 이름 바로 뒤에 쓰는 것과 마찬가지로, 구조체의 인자도 구조체 이름 바로 뒤에 씁니다. Lean에서는 더 구체적인 이름이 딱히 떠오르지 않을 때 타입 인자의 이름으로 그리스 문자를 쓰는 것이 관례입니다. Type은 다른 타입을 기술하는 타입이므로, Nat, List String, PPoint Int는 모두 Type 타입을 가집니다.

List와 마찬가지로 PPoint도 특정 타입을 인자로 제공하여 사용할 수 있습니다:

def natOrigin : PPoint Nat := { x := Nat.zero, y := Nat.zero }

이 예제에서는 두 필드가 모두 Nat이어야 합니다. 함수를 호출할 때 인자 변수가 인자 값으로 치환되는 것과 마찬가지로, PPoint에 타입 Nat을 인자로 제공하면 필드 x와 y의 타입이 Nat인 구조체가 만들어집니다. 인자 이름 α가 인자 타입 Nat으로 치환되었기 때문입니다. Lean에서 타입은 평범한 표현식이므로, (PPoint와 같은) 다형 타입에 인자를 전달할 때 특별한 문법이 필요하지 않습니다.

정의는 타입을 인자로 받을 수도 있으며, 이렇게 하면 정의가 다형적이 됩니다. replaceX 함수는 PPoint의 x 필드를 새 값으로 바꿉니다. replaceX가 어떤 다형적 점에도 동작하게 하려면 이 함수 자체도 다형적이어야 합니다. 이는 첫 번째 인자를 점의 필드 타입으로 두고, 이후 인자들이 첫 번째 인자의 이름을 다시 참조하게 함으로써 이루어집니다.

def replaceX (α : Type) (point : PPoint α) (newX : α) : PPoint α := { point with x := newX }

다시 말해, 인자 point와 newX의 타입에 α가 나오면, 이는 첫 번째 인자로 제공된 타입이 무엇이든 바로 그 타입을 가리킵니다. 이는 함수 본문에 나타나는 함수 인자 이름이 호출할 때 제공된 값을 가리키는 방식과 비슷합니다.

이는 Lean에게 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를 pos에 적용하면 함수 본문과 반환 타입 양쪽에 있는 인자 이름 s가 모두 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

1.6.1. 연결 리스트🔗

Lean 표준 라이브러리에는 List라는 표준 연결 리스트 데이터 타입과 이를 더 편리하게 사용할 수 있게 해 주는 특별한 문법이 포함되어 있습니다. 리스트는 대괄호로 감싸서 작성합니다. 예를 들어, 10보다 작은 소수들을 담은 리스트는 다음과 같이 작성할 수 있습니다:

def primesUnder10 : List Nat := [2, 3, 5, 7]

내부적으로 List는 다음과 같이 정의된 귀납적 데이터 타입입니다:

inductive List (α : Type) where | nil : List α | cons : α → List α → List α

표준 라이브러리의 실제 정의는 아직 소개하지 않은 기능을 사용하기 때문에 조금 다르지만, 본질적으로는 비슷합니다. 이 정의는 PPoint와 마찬가지로 List가 하나의 타입을 인자로 받는다는 것을 나타냅니다. 이 타입은 리스트에 저장되는 항목의 타입입니다. 생성자에 따르면 List α는 nil이나 cons 중 하나로 만들 수 있습니다. 생성자 nil은 빈 리스트를 나타내고, 생성자 cons는 비어 있지 않은 리스트에 사용됩니다. cons의 첫 번째 인자는 리스트의 머리(head)이고, 두 번째 인자는 꼬리(tail)입니다. n개의 항목을 담은 리스트에는 n개의 cons 생성자가 들어 있으며, 그중 마지막 생성자는 nil을 꼬리로 가집니다.

primesUnder10 예제는 List의 생성자를 직접 사용하여 더 명시적으로 작성할 수 있습니다:

def explicitPrimesUnder10 : List Nat := List.cons 2 (List.cons 3 (List.cons 5 (List.cons 7 List.nil)))

이 두 정의는 완전히 동등하지만, primesUnder10이 explicitPrimesUnder10보다 훨씬 읽기 쉽습니다.

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)

1.6.2. 암시적 인자🔗

replaceX와 length는 둘 다 사용하기가 다소 번거롭습니다. 타입 인자는 대개 뒤에 오는 값들에 의해 유일하게 결정되기 때문입니다. 실제로 대부분의 언어에서 컴파일러는 타입 인자를 스스로 충분히 결정할 수 있으며, 사용자의 도움이 필요한 경우는 가끔뿐입니다. 이는 Lean에서도 마찬가지입니다. 함수를 정의할 때 인자를 소괄호 대신 중괄호로 감싸면 암시적으로 선언할 수 있습니다. 예를 들어, 암시적 타입 인자를 사용하는 replaceX는 다음과 같습니다:

def replaceX {α : Type} (point : PPoint α) (newX : α) : PPoint α := { point with x := newX }

Lean은 뒤따르는 인자들로부터 α의 값을 추론할 수 있으므로, Nat을 명시적으로 제공하지 않고도 natOrigin과 함께 사용할 수 있습니다:

{ x := 5, y := 0 }#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에 바로 적용할 수 있습니다:

4#eval length primesUnder10
4

표준 라이브러리에서 Lean은 이 함수를 List.length라고 부릅니다. 즉, 구조체 필드에 접근할 때 쓰는 점(dot) 문법으로 리스트의 길이도 구할 수 있습니다:

4#eval primesUnder10.length
4

C#과 Java에서 때때로 타입 인자를 명시적으로 제공해야 하는 것처럼, Lean도 암시적 인자를 항상 찾아낼 수 있는 것은 아닙니다. 이러한 경우에는 인자의 이름을 사용하여 제공할 수 있습니다. 예를 들어, 정수 리스트에만 동작하는 List.length 버전은 α를 Int로 설정하여 지정할 수 있습니다:

List.length : List Int → Nat#check List.length (α := Int)
List.length : List Int → Nat

1.6.3. 더 많은 내장 데이터 타입🔗

리스트 외에도 Lean 표준 라이브러리에는 다양한 상황에서 사용할 수 있는 여러 구조체와 귀납적 데이터 타입이 포함되어 있습니다.

1.6.3.1. Option🔗

모든 리스트에 첫 번째 항목이 있는 것은 아닙니다. 어떤 리스트는 비어 있습니다. 컬렉션에 대한 많은 연산은 찾으려는 대상을 찾지 못할 수 있습니다. 예를 들어, 리스트에서 첫 번째 항목을 찾는 함수는 그러한 항목을 찾지 못할 수도 있습니다. 따라서 이 함수에는 첫 번째 항목이 없었음을 알리는 방법이 있어야 합니다.

많은 언어에는 값이 없음을 나타내는 null 값이 있습니다. Lean은 기존 타입에 특별한 null 값을 부여하는 대신, 다른 타입에 값이 없음을 나타내는 표시를 덧붙이는 Option이라는 데이터 타입을 제공합니다. 예를 들어, null이 될 수 있는 Int는 Option Int로 표현되고, null이 될 수 있는 문자열 리스트는 Option (List String) 타입으로 표현됩니다. null 가능성을 나타내는 새로운 타입을 도입하면 Option Int는 Int가 필요한 문맥에서 사용할 수 없으므로, null 검사를 빠뜨릴 수 없도록 타입 시스템이 보장하게 됩니다.

Option에는 some과 none이라는 두 생성자가 있으며, 각각 기반 타입의 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 검사를 잊지 않도록 도와줄 수 있고, 타입 시그니처로 널 허용 여부를 명시적으로 드러내는 API는 그렇지 않은 API보다 더 많은 정보를 줍니다. 그러나 이러한 nullable 타입은 Lean의 Option과 매우 중요한 한 가지 점에서 다른데, 바로 여러 겹의 선택성을 허용하지 않는다는 점입니다. Option (Option Int)는 none, some none, 또는 some (some 360)으로 생성할 수 있습니다. 반면 Kotlin은 T??를 T?와 같은 것으로 취급합니다. 이 미묘한 차이가 실제로 문제가 되는 경우는 드물지만, 가끔은 중요할 수 있습니다.

리스트의 첫 번째 원소가 존재한다면 그것을 찾기 위해 List.head?를 사용하십시오. 물음표는 이름의 일부이며, C#이나 Kotlin에서 nullable 타입을 나타내기 위해 물음표를 사용하는 것과는 관련이 없습니다. List.head?의 정의에서 밑줄은 리스트의 꼬리를 나타내는 데 사용됩니다. 패턴에서 밑줄은 무엇이든 매칭하지만, 매칭된 데이터를 참조하는 변수를 도입하지는 않습니다. 이름 대신 밑줄을 사용하면 입력의 일부가 무시된다는 사실을 독자에게 명확하게 전달할 수 있습니다.

def List.head? {α : Type} (xs : List α) : Option α := match xs with | [] => none | y :: _ => some y

Lean의 명명 관례에 따르면, 실패할 수 있는 연산은 접미사를 붙여 여러 버전을 한 묶음으로 정의합니다. Option을 반환하는 버전에는 ?를, 잘못된 입력이 주어지면 프로그램을 중단시키는 버전에는 !를, 연산이 실패할 경우 대신 기본값을 반환하는 버전에는 D를 붙입니다. 이 패턴에 따라 List.head는 호출자에게 리스트가 비어 있지 않다는 수학적 증거를 요구하고, List.head?는 Option을 반환하며, List.head!는 빈 리스트를 받으면 프로그램을 중단시키고, List.headD는 리스트가 비어 있을 때 반환할 기본값을 인자로 받습니다. Lean의 명명 규칙은 여러 다른 언어보다 자유롭기 때문에, 물음표와 느낌표는 특별한 문법이 아니라 이름의 일부입니다.

head?는 List 네임스페이스에 정의되어 있으므로 접근자 표기법으로 사용할 수 있습니다:

some 2#eval primesUnder10.head?
some 2

하지만 빈 리스트에 대해 테스트해 보려고 하면 두 가지 오류가 발생합니다:

#eval don't know how to synthesize implicit argument `α` @_root_.List.head? ?m.3 [] context: ⊢ Type ?u.3don't know how to synthesize implicit argument `α` @List.nil ?m.3 context: ⊢ Type ?u.3[].head?
don't know how to synthesize implicit argument `α`
  @List.nil ?m.3
context:
⊢ Type ?u.3
don't know how to synthesize implicit argument `α`
  @_root_.List.head? ?m.3 []
context:
⊢ Type ?u.3

이는 Lean이 표현식의 타입을 완전히 결정할 수 없었기 때문입니다. 구체적으로 Lean은 List.head?의 암시적 타입 인자도, List.nil의 암시적 타입 인자도 찾을 수 없었습니다. Lean의 출력에서 ?m.XYZ는 추론할 수 없었던 프로그램의 일부분을 나타냅니다. 이러한 미지의 부분을 메타변수(metavariable)라고 부르며, 일부 오류 메시지에 나타납니다. 표현식을 평가하려면 Lean이 그 타입을 찾을 수 있어야 하는데, 빈 리스트에는 타입을 알아낼 수 있는 항목이 하나도 없기 때문에 타입을 알 수 없었습니다. 타입을 명시적으로 제공하면 Lean이 계속 진행할 수 있습니다:

none#eval [].head? (α := Int)
none

타입 표기를 사용하여 타입을 지정할 수도 있습니다:

none#eval ([] : List Int).head?
none

오류 메시지에서 유용한 단서를 얻을 수 있습니다. 두 메시지 모두 누락된 암시적 인자를 나타내는 데 같은 메타변수를 사용합니다. 이는 Lean이 해의 실제 값을 결정하지는 못했지만, 누락된 두 부분이 같은 해를 공유한다는 것은 결정했다는 뜻입니다.

1.6.3.2. Prod🔗

“Product(곱)”의 줄임말인 Prod 구조체는 두 값을 하나로 묶는 범용적인 방법입니다. 예를 들어, 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))

다시 말해, 세 개 이상의 타입으로 이루어진 모든 곱 타입과 그에 대응하는 생성자는 내부적으로는 사실 중첩된 곱 타입과 중첩된 쌍입니다.

1.6.3.3. Sum🔗

Sum 데이터 타입은 서로 다른 두 타입의 값 중 하나를 선택할 수 있게 해 주는 범용적인 방법입니다. 예를 들어, 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"]

패턴 매칭을 사용하면 두 생성자를 구별할 수 있습니다. 예를 들어, 동물 이름 리스트에서 개의 수(즉, 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과 같습니다. 예상대로 3#eval howManyDogs animals의 결과는 3입니다.

1.6.3.4. Unit🔗

Unit은 인자가 없는 생성자 unit 하나만을 가지는 타입입니다. 다시 말해, 이 타입은 단 하나의 값만을 나타내며, 그 값은 이 생성자에 인자를 전혀 적용하지 않은 것입니다. 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은 표기(annotation)를 의미하며, 각 생성자에는 표기가 붙어 있습니다. 파서에서 나온 표현식에는 소스 위치가 표기되어 있을 수 있으므로, 반환 타입이 ArithExpr SourcePos이면 파서가 각 하위 표현식에 SourcePos를 넣었음이 보장됩니다. 하지만 파서에서 나오지 않은 표현식에는 소스 위치가 없으므로, 그 타입은 ArithExpr Unit이 될 수 있습니다.

또한 Lean의 모든 함수는 인자를 가지므로, 다른 언어에서 인자가 없는 함수는 Unit 인자를 받는 함수로 표현할 수 있습니다. 반환 위치에서 Unit 타입은 C에서 파생된 언어의 void와 비슷합니다. C 계열 언어에서 void를 반환하는 함수는 호출자에게 제어를 돌려주지만, 의미 있는 값은 반환하지 않습니다. Unit은 의도적으로 의미 없는 값이므로, 타입 시스템에 특수 목적의 void 기능을 두지 않고도 이를 표현할 수 있게 해 줍니다. Unit의 생성자는 빈 괄호로 쓸 수 있습니다: () : Unit.

1.6.3.5. Empty🔗

Empty 데이터 타입에는 생성자가 하나도 없습니다. 따라서 이 타입은 도달할 수 없는 코드를 나타냅니다. 어떤 일련의 호출도 Empty 타입의 값을 내며 종료될 수 없기 때문입니다.

Empty는 Unit만큼 자주 쓰이지는 않습니다. 하지만 몇몇 특수한 상황에서는 유용합니다. 다형적 데이터 타입 중에는 모든 생성자에서 타입 인자를 전부 사용하지는 않는 경우가 많습니다. 예를 들어 Sum.inl과 Sum.inr은 각각 Sum의 타입 인자 중 하나만 사용합니다. Sum의 타입 인자 중 하나로 Empty를 사용하면 프로그램의 특정 지점에서 생성자 중 하나를 배제할 수 있습니다. 이를 통해 추가적인 제약이 있는 상황에서도 제네릭 코드를 사용할 수 있습니다.

1.6.3.6. 이름 짓기: 합, 곱, 유닛🔗

일반적으로 생성자를 여러 개 제공하는 타입을 합 타입이라고 부르며, 단일 생성자가 여러 인자를 받는 타입을 곱 타입이라고 부릅니다. 이 용어들은 일반적인 산술에서 사용하는 합 및 곱과 관련이 있습니다. 이 관계는 관련된 타입이 유한한 개수의 값을 포함할 때 가장 쉽게 확인할 수 있습니다. α와 β가 각각 n개와 k개의 서로 다른 값을 포함하는 타입이라면, α ⊕ β는 n + k개의 서로 다른 값을 포함하고 α × β는 n \times k개의 서로 다른 값을 포함합니다. 예를 들어, Bool에는 true와 false라는 두 값이 있고, Unit에는 Unit.unit이라는 하나의 값이 있습니다. 곱 Bool × Unit에는 (true, Unit.unit)과 (false, Unit.unit)이라는 두 값이 있고, 합 Bool ⊕ Unit에는 Sum.inl true, Sum.inl false, Sum.inr Unit.unit이라는 세 값이 있습니다. 마찬가지로 2 \times 1 = 2이고, 2 + 1 = 3입니다.

1.6.4. 마주칠 수 있는 메시지🔗

정의할 수 있는 구조체나 귀납적 타입이 모두 Type 타입을 가질 수 있는 것은 아닙니다. 특히 생성자가 임의의 타입을 인자로 받는다면, 그 귀납적 타입은 다른 타입을 가져야 합니다. 이러한 오류는 대개 “유니버스 레벨”에 관한 내용을 언급합니다. 예를 들어, 다음 귀납적 타입을 봅시다:

inductive MyType : Type where Invalid universe level in constructor `MyType.ctor`: Parameter `α` has type Type at universe level 2 which is not less than or equal to the inductive type's resulting universe level 1| ctor : (α : Type) → α → MyType

Lean은 다음과 같은 오류를 표시합니다:

Invalid universe level in constructor `MyType.ctor`: Parameter `α` has type
  Type
at universe level
  2
which is not less than or equal to the inductive type's resulting universe level
  1

그 이유와 정의를 어떻게 수정해야 제대로 동작하는지는 이후의 장에서 설명합니다. 지금은 타입을 생성자의 인자가 아니라 귀납적 타입 전체의 인자로 만들어 보십시오.

마찬가지로, 생성자의 인자가 현재 정의 중인 데이터 타입을 인자로 받는 함수이면 그 정의는 거부됩니다. 예를 들면 다음과 같습니다.

(kernel) arg #1 of 'MyType.ctor' has a non positive occurrence of the datatypes being declaredinductive MyType : Type where | ctor : (MyType → Int) → MyType

다음 메시지를 출력합니다:

(kernel) arg #1 of 'MyType.ctor' has a non positive occurrence of the datatypes being declared

기술적인 이유로, 이러한 데이터 타입을 허용하면 Lean의 내부 논리를 무너뜨릴 수 있게 되어 Lean을 정리 증명기로 사용하기에 적합하지 않게 될 수 있습니다.

매개변수를 두 개 받는 재귀 함수는 두 매개변수를 쌍으로 묶어 매칭하지 말고, 각 매개변수를 독립적으로 매칭해야 합니다. 그렇지 않으면 재귀 호출이 더 작은 값에 대해 이루어지는지 검사하는 Lean의 메커니즘이 입력값과 재귀 호출의 인자 사이의 연관성을 파악하지 못합니다. 예를 들어, 두 리스트의 길이가 같은지 판별하는 다음 함수는 거부됩니다:

def fail to show termination for sameLength with errors failed to infer structural recursion: Not considering parameter α of sameLength: it is unchanged in the recursive calls Not considering parameter β of sameLength: it is unchanged in the recursive calls Cannot use parameter xs: failed to eliminate recursive application sameLength xs' ys' Cannot use parameter ys: failed to eliminate recursive application sameLength xs' ys' Could not find a decreasing measure. The basic measures relate at each recursive call as follows: (<, ≤, =: relation proved, ? all proofs failed, _: no proof attempted) xs ys 1) 1816:28-46 ? ? Please use `termination_by` to specify a decreasing measure.sameLength (xs : List α) (ys : List β) : Bool := match (xs, ys) with | ([], []) => true | (x :: xs', y :: ys') => sameLength xs' ys' | _ => false

오류 메시지는 다음과 같습니다:

fail to show termination for
  sameLength
with errors
failed to infer structural recursion:
Not considering parameter α of sameLength:
  it is unchanged in the recursive calls
Not considering parameter β of sameLength:
  it is unchanged in the recursive calls
Cannot use parameter xs:
  failed to eliminate recursive application
    sameLength xs' ys'
Cannot use parameter ys:
  failed to eliminate recursive application
    sameLength xs' ys'


Could not find a decreasing measure.
The basic measures relate at each recursive call as follows:
(<, ≤, =: relation proved, ? all proofs failed, _: no proof attempted)
              xs ys
1) 1816:28-46  ?  ?
Please use `termination_by` to specify a decreasing measure.

이 문제는 중첩 패턴 매칭으로 해결할 수 있습니다:

def sameLength (xs : List α) (ys : List β) : Bool := match xs with | [] => match ys with | [] => true | _ => false | x :: xs' => match ys with | y :: ys' => sameLength xs' ys' | _ => false

다음 절에서 설명할 동시 매칭은 이 문제를 해결하는 또 다른 방법이며, 대개 더 우아합니다.

귀납적 타입에 인자를 빠뜨려도 혼란스러운 메시지가 나올 수 있습니다. 예를 들어, ctor의 타입에서 MyType에 인자 α를 전달하지 않으면 다음과 같습니다:

inductive MyType (α : Type) : Type where | ctor : α → type expected, got (MyType : Type → Type)MyType

Lean은 다음과 같은 오류를 출력합니다:

type expected, got
  (MyType : Type → Type)

이 오류 메시지는 MyType의 타입인 Type → Type은 그 자체로는 타입을 기술하지 않는다는 뜻입니다. MyType이 진짜 제대로 된 타입이 되려면 인자가 필요합니다.

정의의 타입 시그니처와 같이 다른 맥락에서 타입 인자를 생략한 경우에도 같은 메시지가 나타날 수 있습니다:

inductive MyType (α : Type) : Type where | ctor : α → MyType αdef ofFive : type expected, got (MyType : Type → Type)MyType := ctor 5
type expected, got
  (MyType : Type → Type)

다형 타입을 사용하는 표현식을 평가하면 Lean이 값을 표시할 수 없는 상황이 생길 수 있습니다. #eval 명령은 주어진 표현식을 평가하며, 결과를 어떻게 표시할지 결정할 때 그 표현식의 타입을 사용합니다. 함수와 같은 일부 타입에서는 이 과정이 실패하지만, 그 밖의 대부분의 타입에 대해서는 Lean이 표시 코드를 충분히 자동으로 생성할 수 있습니다. 예를 들어, WoodSplittingTool을 위한 별도의 표시 코드를 Lean에 제공할 필요가 없습니다:

inductive WoodSplittingTool where | axe | maul | froeWoodSplittingTool.axe#eval WoodSplittingTool.axe
WoodSplittingTool.axe

하지만 여기서 Lean이 사용하는 자동화에는 한계가 있습니다. allTools는 세 가지 도구를 모두 담은 리스트입니다:

def allTools : List WoodSplittingTool := [ WoodSplittingTool.axe, WoodSplittingTool.maul, WoodSplittingTool.froe ]

이를 평가하면 오류가 발생합니다:

Could not synthesize a `ToExpr`, `Repr`, or `ToString` instance for type List WoodSplittingTool#eval allTools
Could not synthesize a `ToExpr`, `Repr`, or `ToString` instance for type
  List WoodSplittingTool

이는 Lean이 리스트를 표시하기 위해 내장 테이블에 있는 코드를 사용하려 하는데, 이 코드는 WoodSplittingTool을 표시하는 코드가 이미 존재해야 하기 때문입니다. 이 오류는 데이터 타입의 정의에 deriving Repr를 추가하여 우회할 수 있습니다. 이렇게 하면 Lean은 #eval의 일부로 마지막 순간에 표시 코드를 생성하는 대신, 데이터 타입이 정의될 때 표시 코드를 생성합니다:

inductive Firewood where | birch | pine | beech deriving Repr

Firewood의 리스트를 평가하면 성공합니다:

def allFirewood : List Firewood := [ Firewood.birch, Firewood.pine, Firewood.beech ][Firewood.birch, Firewood.pine, Firewood.beech]#eval allFirewood
[Firewood.birch, Firewood.pine, Firewood.beech]

1.6.5. 연습 문제🔗

  • 리스트의 마지막 항목을 찾는 함수를 작성하십시오. 이 함수는 Option을 반환해야 합니다.

  • 주어진 술어를 만족하는 리스트의 첫 번째 항목을 찾는 함수를 작성하십시오. 정의는 def List.findFirst? {α : Type} (xs : List α) (predicate : α → Bool) : Option α := …로 시작하십시오.

  • 쌍의 두 필드를 서로 맞바꾸는 함수 Prod.switch를 작성하십시오. 정의는 def Prod.switch {α β : Type} (pair : α × β) : β × α := …로 시작하십시오.

  • PetName 예제를 사용자 정의 데이터 타입을 사용하도록 다시 작성하고, Sum을 사용하는 버전과 비교해 보십시오.

  • 두 리스트를 쌍의 리스트로 결합하는 함수 zip을 작성하십시오. 결과 리스트의 길이는 입력 리스트 중 가장 짧은 것의 길이와 같아야 합니다. 정의는 def zip {α β : Type} (xs : List α) (ys : List β) : List (α × β) := …로 시작하십시오.

  • 리스트의 처음 n개 항목을 반환하는 다형 함수 take를 작성하십시오. 여기서 n은 Nat입니다. 리스트의 항목이 n개보다 적으면 결과 리스트는 입력 리스트 전체가 되어야 합니다. #eval take 3 ["bolete", "oyster"]는 ["bolete", "oyster"]를 내놓아야 하고, ["bolete"]#eval take 1 ["bolete", "oyster"]는 ["bolete"]를 내놓아야 합니다.

  • 타입과 산술 사이의 유사성을 이용하여, 곱을 합에 대해 분배하는 함수를 작성하십시오. 다시 말해, 이 함수는 α × (β ⊕ γ) → (α × β) ⊕ (α × γ) 타입을 가져야 합니다.

  • 타입과 산술 사이의 유사성을 이용하여, 2를 곱하는 것을 합으로 바꾸는 함수를 작성하십시오. 다시 말해, 이 함수는 Bool × α → α ⊕ α 타입을 가져야 합니다.