4.3. 예제: 모나드에서의 산술 연산
모나드는 부수 효과가 있는 프로그램을 부수 효과가 없는 언어로 인코딩하는 방법입니다. 이를 두고 순수 함수형 프로그램에는 중요한 무언가가 빠져 있어서, 프로그래머가 평범한 프로그램 하나를 작성하는 데에도 온갖 번거로운 절차를 거쳐야 한다고 인정하는 것으로 받아들이기 쉽습니다. 그러나 Monad API를 사용하면 프로그램에 문법적 비용이 생기기는 하지만, 두 가지 중요한 이점이 있습니다.
-
프로그램은 자신이 어떤 효과를 사용하는지 타입에 정직하게 드러내야 합니다. 타입 시그니처를 잠깐 훑어보기만 해도 프로그램이 무엇을 받고 무엇을 반환하는지뿐만 아니라, 프로그램이 할 수 있는 모든 것을 알 수 있습니다.
-
모든 언어가 같은 효과를 제공하는 것은 아닙니다. 예를 들어, 예외를 지원하는 언어는 일부에 불과합니다. 또 어떤 언어에는 Icon의 여러 값에 대한 탐색이나 Scheme 또는 Ruby의 컨티뉴에이션처럼 독특하고 색다른 효과가 있습니다. 모나드는 어떤 효과든 인코딩할 수 있으므로, 프로그래머는 언어 개발자가 제공한 것에 얽매이지 않고 주어진 애플리케이션에 가장 잘 맞는 효과를 선택할 수 있습니다.
다양한 모나드에서 의미를 가질 수 있는 프로그램의 한 예로 산술 표현식 평가기가 있습니다.
4.3.1. 산술 표현식
4.3.2. 표현식 평가하기
표현식에는 나눗셈이 포함되고 0으로 나누는 것은 정의되지 않으므로, 평가가 실패할 수 있습니다. 실패를 표현하는 한 가지 방법은 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 인스턴스를 사용하여 이항 연산자의 양쪽 분기를 평가할 때 발생하는 실패를 전파합니다. 그러나 이 함수는 하위 표현식을 평가하는 일과 그 결과에 이항 연산자를 적용하는 일이라는 두 가지 관심사를 뒤섞고 있습니다. 이 함수는 두 개의 함수로 나누어 개선할 수 있습니다.
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를 사용한다는 점입니다. 평가기를 모나드에 대해 다형적으로 만들고 applyPrim을 인자로 넘기면, 하나의 평가기로 두 가지 형태의 오류 보고를 모두 처리할 수 있습니다:
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과 함께 사용하면 첫 번째 평가기와 똑같이 작동합니다:
#eval evaluateM applyPrimOption fourteenDivided
마찬가지로, applyPrimExcept와 함께 사용하면 오류 메시지가 있는 버전과 똑같이 작동합니다:
#eval evaluateM applyPrimExcept fourteenDivided
이 코드는 아직 더 개선할 수 있습니다. 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이렇게 리팩터링한 코드에서는 두 코드 경로가 실패를 처리하는 방식에서만 다르다는 사실이 완전히 드러납니다.
4.3.3. 추가 효과
평가기를 다룰 때 흥미로울 수 있는 효과가 실패와 예외만 있는 것은 아닙니다. 나눗셈의 유일한 부수 효과는 실패이지만, 표현식에 다른 기본 연산자를 추가하면 다른 효과도 표현할 수 있습니다.
첫 번째 단계는 리팩터링을 한 번 더 하는 것으로, 기본 연산(primitive)의 데이터 타입에서 나눗셈을 분리해 냅니다:
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 v24.3.3.1. 효과 없음
Empty 타입에는 생성자가 없으므로 값도 없습니다. 이는 Scala나 Kotlin의 Nothing 타입과 비슷합니다. Scala와 Kotlin에서 Nothing은 결과를 결코 반환하지 않는 계산을 나타낼 수 있습니다. 프로그램을 비정상 종료시키는 함수, 예외를 던지는 함수, 항상 무한 루프에 빠지는 함수 등이 여기에 해당합니다. 함수나 메서드가 Nothing 타입의 인자를 받는다면 그 코드는 죽은 코드입니다. 그 인자로 쓸 수 있는 값이 결코 존재하지 않기 때문입니다. Lean은 무한 루프와 예외를 지원하지 않지만, Empty는 어떤 함수가 호출될 수 없다는 것을 타입 시스템에 알리는 표시로서 여전히 유용합니다. E가 생성자가 없는 타입의 표현식일 때 nomatch E 문법을 사용하면, 현재 표현식은 결코 호출될 수 없었으므로 결과를 반환할 필요가 없다는 것을 Lean에 알려 줍니다.
Prim의 매개변수로 Empty를 사용하면 Prim.plus, Prim.minus, Prim.times 외에 다른 경우가 없다는 뜻이 됩니다. Prim.other 생성자에 넣을 Empty 타입의 값을 만들어 내는 것이 불가능하기 때문입니다. 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)))4.3.3.2. 비결정적 탐색
0으로 나누는 상황을 만났을 때 그냥 실패하는 대신, 되돌아가서 다른 입력을 시도하는 것도 합리적입니다. 알맞은 모나드가 주어지면, 똑같은 evaluateM으로 실패로 끝나지 않는 답들의 집합을 찾는 비결정적 탐색을 수행할 수 있습니다. 이를 위해서는 나눗셈 외에도 여러 결과 중 하나를 선택하도록 지정하는 수단이 필요합니다. 한 가지 방법은 표현식 언어에 choose 함수를 추가하는 것입니다. 이 함수는 평가기가 실패하지 않는 결과를 탐색하는 동안 두 인자 중 어느 쪽이든 고르도록 지시합니다.
이제 평가기의 결과는 단일 값이 아니라 값들의 다중집합입니다. 다중집합으로 평가하는 규칙은 다음과 같습니다:
-
상수
n은 단일 원소 집합\{n\}으로 평가됩니다. -
나눗셈을 제외한 산술 연산자는 피연산자들의 데카르트 곱에 속하는 각 쌍에 대해 호출되므로,
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 \}로 평가됩니다. 진짜 집합 대신 다중집합을 사용하면 원소의 유일성을 검사할 필요가 없어지므로 코드가 단순해집니다.
이러한 비결정적 효과를 나타내는 모나드는 답이 하나도 없는 상황과, 적어도 하나의 답이 나머지 답들과 함께 있는 상황을 모두 나타낼 수 있어야 합니다:
inductive Many (α : Type) where
| none : Many α
| more : α → (Unit → Many α) → Many α
이 데이터 타입은 List와 매우 비슷해 보입니다. 차이점은 List.cons는 리스트의 나머지를 저장하는 반면, more는 남은 값들을 필요할 때 계산하는 함수를 저장한다는 것입니다. 이는 Many를 사용하는 쪽에서 일정한 수의 결과를 찾으면 탐색을 멈출 수 있다는 뜻입니다.
하나의 결과는 더 이상 결과를 반환하지 않는 more 생성자로 표현됩니다:
def Many.one (x : α) : Many α := Many.more x (fun () => Many.none)두 결과 다중집합의 합집합은 첫 번째 다중집합이 비어 있는지 확인하여 계산할 수 있습니다. 비어 있다면 두 번째 다중집합이 곧 합집합입니다. 비어 있지 않다면 합집합은 첫 번째 다중집합의 첫 번째 원소 뒤에 첫 번째 다중집합의 나머지와 두 번째 다중집합의 합집합이 이어진 것입니다:
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 연산자가 필요합니다. 비결정적 탐색에서 두 연산을 순차적으로 실행한다는 것은 첫 번째 단계에서 나온 모든 가능성을 가져와 각각에 대해 프로그램의 나머지 부분을 실행하고, 그 결과들의 합집합을 취하는 것입니다. 다시 말해, 첫 번째 단계가 가능한 답을 세 개 반환한다면 두 번째 단계는 그 세 개 모두에 대해 시도되어야 합니다. 두 번째 단계는 각 입력에 대해 몇 개의 답이든 반환할 수 있으므로, 그 답들의 합집합을 취하면 전체 탐색 공간을 나타낼 수 있습니다.
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) fMany.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와 같은지 확인하려면, Many.bind가 v의 각 원소에 Many.one을 적용한 결과들의 합집합을 취한다는 점을 생각해 보십시오. 다시 말해, v가 {v₁, v₂, v₃, …, vₙ} 형태라면 Many.bind v Many.one은 {v₁} ∪ {v₂} ∪ {v₃} ∪ … ∪ {vₙ}이고, 이는 곧 {v₁, v₂, v₃, …, vₙ}입니다.
마지막으로, Many.bind가 결합 법칙을 만족하는지 확인하려면 Many.bind (Many.bind v f) g가 Many.bind v (fun x => Many.bind (f x) g)와 같은지 확인하십시오. v가 {v₁, v₂, v₃, …, vₙ} 형태라면 다음이 성립합니다:
Many.bind v ff v₁ ∪ f v₂ ∪ f v₃ ∪ … ∪ f vₙ이는 다음을 의미합니다
Many.bind (Many.bind v f) gMany.bind (f v₁) g ∪
Many.bind (f v₂) g ∪
Many.bind (f v₃) g ∪
… ∪
Many.bind (f vₙ) g마찬가지로,
Many.bind v (fun x => Many.bind (f x) g)(fun x => Many.bind (f x) g) v₁ ∪
(fun x => Many.bind (f x) g) v₂ ∪
(fun x => Many.bind (f x) g) v₃ ∪
… ∪
(fun x => Many.bind (f x) g) vₙMany.bind (f v₁) g ∪
Many.bind (f v₂) g ∪
Many.bind (f v₃) g ∪
… ∪
Many.bind (f vₙ) 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이면 빈 리스트는 성공한 검색이 되고, 그렇지 않으면 검색은 실패합니다. 리스트가 비어 있지 않을 때는 두 가지 경우가 있습니다. 리스트의 머리가 목표보다 크면 어떤 성공적인 검색에도 참여할 수 없고, 그렇지 않으면 참여할 수 있습니다. 리스트의 머리가 후보가 아니라면 검색은 리스트의 꼬리로 넘어갑니다. 머리가 후보라면 Many.union으로 합쳐야 할 두 가지 경우가 있습니다. 찾은 해들이 머리를 포함하는 경우와 포함하지 않는 경우입니다. 머리를 포함하지 않는 해들은 꼬리에 대한 재귀 호출로 찾습니다. 반면 머리를 포함하는 해들은 목표에서 머리를 뺀 값으로 재귀 호출을 한 뒤, 그 결과로 나온 해들에 머리를 붙여서 얻습니다.
보조 함수 printList는 결과가 한 줄에 하나씩 표시되도록 합니다:
def printList [ToString α] : List α → IO Unit
| [] => pure ()
| x :: xs => do
IO.println x
printList xs#eval printList (addsTo 15 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]).takeAll
결과의 다중집합을 산출하는 산술 평가기로 돌아가 보면, choose 연산자를 사용하여 값을 비결정적으로 선택할 수 있으며, 0으로 나누는 경우 그 이전의 선택들은 무효가 됩니다.
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#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)))).takeAll4.3.3.3. 사용자 정의 환경
문자열을 연산자로 쓸 수 있게 하고 각 문자열을 해당 연산자를 구현하는 함수에 대응시키는 매핑을 제공하면, 사용자가 평가기를 확장할 수 있게 됩니다. 예를 들어, 사용자는 나머지 연산자나 두 인자 중 최댓값을 반환하는 연산자를 추가하여 평가기를 확장할 수 있습니다. 함수 이름을 함수 구현에 대응시키는 매핑을 환경이라고 합니다.
환경은 재귀 호출마다 전달되어야 합니다. 처음에는 evaluateM에 환경을 담을 추가 인자가 필요하고, 이 인자를 각 재귀 호출에 전달해야 하는 것처럼 보일 수 있습니다. 하지만 이렇게 인자를 전달하는 것도 모나드의 또 다른 형태이므로, 적절한 Monad 인스턴스를 사용하면 평가기를 변경 없이 그대로 사용할 수 있습니다.
함수를 모나드로 사용하는 것을 일반적으로 리더(reader) 모나드라고 합니다. 리더 모나드에서 표현식을 평가할 때는 다음 규칙이 사용됩니다.
-
상수
n은 상수 함수λ e . n으로 평가되며, -
산술 연산자는 자신의 인자를 그대로 넘겨주는 함수로 평가되므로,
f + g는λ e . f(e) + g(e)로 평가되며, -
사용자 정의 연산자는 인자에 그 사용자 정의 연산자를 적용한 결과로 평가됩니다. 따라서
f \ \mathrm{OP}\ g는λ e . \begin{cases} h(f(e), g(e)) & \mathrm{if}\ e\ \mathrm{contains}\ (\mathrm{OP}, h) \\ 0 & \mathrm{otherwise} \end{cases}로 평가되며, 여기서0은 알 수 없는 연산자가 적용되는 경우를 위한 대체값 역할을 합니다.
Lean에서 리더 모나드를 정의하려면 먼저 Reader 타입과, 사용자가 환경에 접근할 수 있게 해 주는 효과를 정의해야 합니다:
def Reader (ρ : Type) (α : Type) : Type := ρ → α
def read : Reader ρ ρ := fun env => env
관례상 환경에는 "로"라고 읽는 그리스 문자 ρ를 사용합니다.
산술 표현식의 상수가 상수 함수로 평가된다는 사실로 보아, Reader에 대한 pure의 적절한 정의는 상수 함수임을 알 수 있습니다:
def Reader.pure (x : α) : Reader ρ α := fun _ => x
반면에 bind는 조금 더 까다롭습니다. 그 타입은 Reader ρ α → (α → Reader ρ β) → Reader ρ β입니다. Reader의 정의를 펼치면 (ρ → α) → (α → ρ → β) → (ρ → β)가 되므로, 이 타입을 더 쉽게 이해할 수 있습니다. 첫 번째 인자로는 환경을 받는 함수를 받아야 하고, 두 번째 인자는 환경을 받는 그 함수의 결과를 환경을 받는 또 다른 함수로 변환해야 합니다. 이 둘을 결합한 결과도 그 자체로 환경을 기다리는 함수입니다.
Lean을 대화형으로 사용하면 이 함수를 작성하는 데 도움을 받을 수 있습니다. 첫 단계는 인자와 반환 타입을 적는 것입니다. 이때 최대한 많은 도움을 받을 수 있도록 아주 명시적으로 적고, 정의의 본문 자리에는 밑줄을 둡니다.
def Reader.bind {ρ : Type} {α : Type} {β : Type}
(result : ρ → α) (next : α → ρ → β) : ρ → β :=
_
Lean은 스코프에서 어떤 변수를 사용할 수 있는지, 그리고 결과에 어떤 타입이 기대되는지를 설명하는 메시지를 제공합니다. 지하철 입구와 닮아서 턴스타일이라고 불리는 ⊢ 기호는 지역 변수와 원하는 타입을 구분합니다. 이 메시지에서 원하는 타입은 ρ → β입니다:
반환 타입이 함수이므로, 밑줄을 fun으로 감싸는 것이 좋은 첫 단계입니다:
def Reader.bind {ρ : Type} {α : Type} {β : Type}
(result : ρ → α) (next : α → ρ → β) : ρ → β :=
fun env => _이제 결과 메시지에는 함수의 인자가 지역 변수로 표시됩니다:
컨텍스트에서 β를 만들어 낼 수 있는 것은 next뿐이며, 그러려면 인자 두 개가 필요합니다. 각 인자 자체도 밑줄로 둘 수 있습니다.
def Reader.bind {ρ : Type} {α : Type} {β : Type}
(result : ρ → α) (next : α → ρ → β) : ρ → β :=
fun env => next _ _두 밑줄에는 각각 다음과 같은 메시지가 연결되어 있습니다:
첫 번째 밑줄부터 공략해 보면, 컨텍스트에서 α를 만들어 낼 수 있는 것은 result 하나뿐입니다.
def Reader.bind {ρ : Type} {α : Type} {β : Type}
(result : ρ → α) (next : α → ρ → β) : ρ → β :=
fun env => next (result _) _이제 두 밑줄 모두 같은 오류 메시지가 나타납니다:
다행히 두 밑줄 모두 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) ffun env => f ((Reader.pure v) env) envfun env => f ((fun _ => v) env) envfun env => f v envf v
모든 함수 f에 대해 fun x => f x는 f와 같으므로, 계약의 첫 번째 부분이 충족됩니다. Reader.bind r Reader.pure가 r과 같은지 확인할 때도 비슷한 기법을 사용할 수 있습니다:
Reader.bind r Reader.purefun env => Reader.pure (r env) envfun env => (fun _ => (r env)) envfun 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) gfun env => g ((Reader.bind r f) env) envfun env => g ((fun env' => f (r env') env') env) envfun env => g (f (r env) env) env
Reader.bind r (fun x => Reader.bind (f x) g)도 동일한 표현식으로 축약됩니다:
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) envfun env => (fun env' => g (f (r env) env') env') envfun 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)
evaluateM을 applyPrimReader 및 표현식과 함께 사용하면 환경을 기대하는 함수가 만들어집니다. 다행히 exampleEnv를 사용할 수 있습니다:
open Expr Prim in
#eval
evaluateM applyPrimReader
(prim (other "max") (prim plus (const 5) (const 4))
(prim times (const 3)
(const 2)))
exampleEnv
Many와 마찬가지로 Reader도 대부분의 언어에서는 인코딩하기 어려운 효과의 한 예이지만, 타입 클래스와 모나드를 사용하면 다른 효과와 똑같이 편리하게 다룰 수 있습니다. Common Lisp, Clojure, Emacs Lisp에 있는 동적 변수 또는 특수 변수는 Reader처럼 사용할 수 있습니다. 마찬가지로 Scheme과 Racket의 매개변수 객체(parameter object)는 Reader에 정확히 대응하는 효과입니다. Kotlin의 컨텍스트 객체(context object) 관용구로도 비슷한 문제를 해결할 수 있지만, 컨텍스트 객체는 근본적으로 함수 인자를 자동으로 전달하는 수단입니다. 따라서 이 관용구는 언어에 내장된 효과라기보다는 리더 모나드로 인코딩한 방식에 더 가깝습니다.
4.3.3.4. 연습 문제
4.3.3.4.1. 계약 검사하기
State σ와 Except ε에 대해 모나드 계약이 성립하는지 확인하십시오.
4.3.3.4.2. 실패할 수 있는 리더
리더 모나드 예제를 수정하여 사용자 정의 연산자가 정의되지 않았을 때 단순히 0을 반환하는 대신 실패도 나타낼 수 있도록 하십시오. 다시 말해, 다음과 같은 정의가 주어졌다고 합시다.
def ReaderOption (ρ : Type) (α : Type) : Type := ρ → Option α
def ReaderExcept (ε : Type) (ρ : Type) (α : Type) : Type := ρ → Except ε α다음을 수행하십시오:
4.3.3.4.3. 추적 평가기
WithLog 타입을 평가기와 함께 사용하면 일부 연산에 선택적인 추적 기능을 추가할 수 있습니다. 특히 ToTrace 타입은 주어진 연산자를 추적하라는 신호 역할을 할 수 있습니다:
inductive ToTrace (α : Type) : Type where
| trace : α → ToTrace α
추적 평가기에서 표현식은 Expr (Prim (ToTrace (Prim Empty))) 타입을 가져야 합니다. 이는 표현식의 연산자가 덧셈, 뺄셈, 곱셈과 이들 각각을 추적하는 버전으로 이루어져 있다는 뜻입니다. 가장 안쪽 인자가 Empty인 것은 trace 안에는 더 이상의 특수 연산자 없이 세 가지 기본 연산자만 있다는 것을 나타냅니다.
다음을 수행하십시오:
-
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)))다음과 같은 결과가 나와야 합니다
힌트: Prim Empty 타입의 값이 결과 로그에 나타납니다. 이 값들을 #eval의 결과로 표시하려면 다음 인스턴스가 필요합니다:
deriving instance Repr for WithLog
deriving instance Repr for Empty
deriving instance Repr for Prim