4.3. 예제: 모나드에서의 산술 연산
모나드는 부작용이 없는 언어에 부작용이 있는 프로그램을 인코딩하는 방법입니다. 이는 순수 함수형 프로그램이 무언가 중요한 것을 결여하고 있어서 프로그래머가 평범한 프로그램을 작성하기 위해서도 갖은 수단을 동원해야 함을 일종으로 인정하는 것처럼 읽기 쉽습니다. 하지만 Monad API를 사용하면 프로그램에 구문적 비용이 부과되기는 하지만, 두 가지 중요한 이점을 가져다줍니다.
-
프로그램은 타입에서 자신이 사용하는 효과가 무엇인지 정직하게 드러내야 합니다. 타입 시그니처를 잠깐만 살펴보아도 프로그램이 받아들이는 것과 반환하는 것뿐만 아니라, 프로그램이 할 수 있는 모든 것을 알 수 있습니다.
-
모든 언어가 동일한 효과를 제공하지는 않습니다. 예를 들어, 일부 언어만 예외를 갖고 있습니다. 다른 언어들은 Icon의 여러 값에 대한 탐색이나 Scheme 또는 Ruby의 continuation과 같은 독특하고 특이한 효과를 갖고 있습니다. 모나드는 어떤 효과든 인코딩할 수 있으므로, 프로그래머는 언어 개발자가 제공한 것에 얽매이지 않고 주어진 애플리케이션에 가장 적합한 것을 선택할 수 있습니다.
다양한 모나드에서 의미가 통할 수 있는 프로그램의 한 예로 산술 표현식 평가기가 있습니다.
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는 함수가 호출될 수 없음을 타입 시스템에 나타내는 지표로서 여전히 유용합니다. nomatch E 구문을 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를 추가하여, 평가기가 실패하지 않는 결과를 탐색하는 동안 두 인자 중 하나를 선택하도록 지시하는 것입니다.
이제 평가기의 결과는 단일 값이 아니라 값들의 멀티셋(multiset)입니다. 멀티셋으로 평가하는 규칙은 다음과 같습니다:
-
상수
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 연산자를 필요로 합니다. 비결정적 탐색(nondeterministic search)에서 두 연산을 순차 실행한다는 것은 첫 번째 단계에서 나온 모든 가능성을 취하여 각각에 대해 나머지 프로그램을 실행한 뒤, 그 결과들의 합집합을 취하는 것으로 이루어집니다. 다시 말해, 첫 번째 단계가 세 가지 가능한 답을 반환한다면, 두 번째 단계는 그 세 가지 모두에 대해 시도되어야 합니다. 두 번째 단계는 각 입력에 대해 임의의 개수의 답을 반환할 수 있으므로, 그 합집합을 취하면 전체 탐색 공간을 나타내게 됩니다.
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일 때 빈 리스트는 성공적인 탐색이며, 그렇지 않으면 실패합니다. 리스트가 비어 있지 않은 경우 두 가지 가능성이 있습니다: 리스트의 머리가 목표보다 크다면 성공적인 탐색에 참여할 수 없고, 그렇지 않다면 참여할 수 있습니다. 리스트의 머리(head)가 후보가 아니라면, 탐색은 리스트의 꼬리(tail)로 진행됩니다. head가 후보라면, Many.union으로 결합할 두 가지 가능성이 있습니다: 찾은 해가 head를 포함하거나, 포함하지 않는 경우입니다. head를 포함하지 않는 해는 tail에 대한 재귀 호출로 찾을 수 있으며, head를 포함하는 해는 목표에서 head를 뺀 다음, 재귀 호출의 결과로 나온 해에 head를 붙임으로써 얻을 수 있습니다.
헬퍼 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의 언폴딩(unfolding)을 되돌리고 명시적인 세부 사항을 정리함으로써 얻을 수 있습니다:
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