4.3. Example: Arithmetic in Monads
모나드는 부수 효과가 없는 언어에 부수 효과가 있는 프로그램을 인코딩하는 방법이다.
이를 순수 함수형 프로그램에 중요한 무언가가 빠져 있어서 정상적인 프로그램을 작성하려면 프로그래머가 온갖 우회 작업을 해야 한다는 일종의 인정으로 읽기 쉽다.
하지만 Monad API를 사용하면 프로그램의 문법적 비용이 생기는 대신 두 가지 중요한 이점이 있다.
-
프로그램은 어떤 효과를 사용하는지 타입에 정직하게 드러내야 한다. 타입 서명을 잠깐만 살펴봐도 프로그램이 받아들이고 반환하는 것뿐 아니라 프로그램이 할 수 있는 모든 것을 알 수 있다.
-
모든 언어가 같은 효과를 제공하는 것은 아니다. 예를 들어 예외가 있는 언어는 일부뿐이다. 다른 언어는 Icon의 여러 값에 대한 검색이나 Scheme과 Ruby의 연속(continuation)처럼 독특하고 특수한 효과를 제공한다. 모나드는 어떤 효과든 인코딩할 수 있으므로 프로그래머는 언어 개발자가 제공한 것에 얽매이지 않고 주어진 애플리케이션에 가장 잘 맞는 효과를 선택할 수 있다.
여러 모나드에서 의미 있게 사용할 수 있는 프로그램의 한 예는 산술 표현식 평가기다.
4.3.1. Arithmetic Expressions
4.3.2. Evaluating Expressions
표현식에 나눗셈이 포함되고 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. Further Effects
평가기를 사용할 때 흥미로운 효과는 실패와 예외만이 아니다. 나눗셈의 유일한 부작용은 실패지만, 표현식에 다른 원시 연산자를 추가하면 다른 효과도 표현할 수 있다.
첫 단계는 원시 연산자 데이터 타입에서 나눗셈을 추출하는 추가 리팩터링이다.
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. No Effects
Empty 타입에는 생성자가 없으므로 값도 없다. Scala나 Kotlin의 Nothing 타입과 같다.
Scala와 Kotlin에서 Nothing은 프로그램을 중단하거나 예외를 던지거나 항상 무한 루프에 빠지는 함수처럼 결과를 반환하지 않는 계산을 나타낼 수 있다.
Nothing 타입의 함수나 메서드에 전달하는 인수는 적합한 인수 값이 절대 존재하지 않으므로 도달 불가능한 코드임을 나타낸다.
Lean은 무한 루프와 예외를 지원하지 않지만 Empty는 함수를 호출할 수 없음을 타입 시스템에 알리는 데 여전히 유용하다.
E가 타입에 생성자가 없는 식일 때 nomatch E 문법을 사용하면, 해당 식은 호출될 수 없으므로 현재 식이 결과를 반환할 필요가 없다고 Lean에 알린다.
Empty를 Prim의 매개변수로 사용하면 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. Nondeterministic Search
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. Custom Environments
문자열을 연산자로 사용할 수 있게 하고 문자열에서 이를 구현하는 함수로 가는 매핑을 제공하면 평가기를 사용자가 확장할 수 있다. 예를 들어 사용자는 나머지 연산자나 두 인수 중 최댓값을 반환하는 연산자로 평가기를 확장할 수 있다. 함수 이름에서 함수 구현으로 가는 매핑을 환경이라고 한다.
환경은 재귀 호출마다 전달해야 한다.
처음에는 evaluateM에 환경을 담을 추가 인수가 필요하고 이 인수를 각 재귀 호출에 전달해야 할 것처럼 보인다.
하지만 이런 인수를 전달하는 것은 모나드의 또 다른 형태이므로, 적절한 Monad 인스턴스를 사용하면 평가기를 변경하지 않고 사용할 수 있다.
함수를 모나드로 사용하는 것을 보통 리더 모나드라고 한다. 리더 모나드에서 표현식을 평가할 때는 다음 규칙을 사용한다.
-
상수
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
관례적으로 “로(rho)”라고 읽는 그리스 문자 ρ를 환경에 사용한다.
산술 표현식의 상수가 상수 함수로 평가된다는 사실은 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의 매개변수 객체는 Reader에 정확히 대응하는 효과다.
Kotlin의 컨텍스트 객체 관용구도 비슷한 문제를 해결할 수 있지만, 근본적으로 함수 인수를 자동으로 전달하는 수단이다. 따라서 이 관용구는 언어의 효과라기보다 리더 모나드로 인코딩하는 방식에 가깝다.
4.3.3.4. Exercises
4.3.3.4.1. Checking Contracts
State σ와 Except ε의 Monad 계약을 확인하라.
4.3.3.4.2. Readers with Failure
리더 Monad 예제를 수정하여 사용자 정의 연산자가 정의되지 않았을 때 단순히 0을 반환하는 대신 실패도 나타내게 하라. 다시 말해 다음 정의가 주어졌다고 하자.
def ReaderOption (ρ : Type) (α : Type) : Type := ρ → Option α
def ReaderExcept (ε : Type) (ρ : Type) (α : Type) : Type := ρ → Except ε α다음을 수행하라.
4.3.3.4.3. A Tracing Evaluator
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