Functional Programming in Lean

6.2. A Monad Construction Kit🔗

ReaderT는 유용한 모나드 변환기의 한 예일 뿐이다. 이 절에서는 몇 가지 추가 변환기를 설명한다. 각 모나드 변환기는 다음으로 구성된다.

  1. 모나드를 인수로 받는 정의 또는 데이터 타입 T. 타입은 (Type u Type v) Type u Type v와 같아야 하지만, 모나드 앞에 추가 인수를 받을 수도 있다.

  2. Monad m 인스턴스에 의존하는 T mMonad 인스턴스. 이를 통해 변환된 모나드를 모나드로 사용할 수 있다.

  3. MonadLift 인스턴스는 m α 타입의 동작을 T m α 타입의 동작으로 변환하며, 임의의 모나드 m에 대해 성립한다. 이를 통해 기반 모나드의 동작을 변환된 모나드에서 사용할 수 있다.

또한 변환기의 Monad 인스턴스는 Monad 계약을 지켜야 하며, 적어도 기반 Monad 인스턴스가 계약을 지킬 때 그렇다. 그리고 변환된 모나드에서 monadLift (pure x : m α)pure x와 같아야 하며, monadLiftbind에 대해 분배되어 monadLift (x >>= f : m α)(monadLift x : m α) >>= fun y => monadLift (f y)와 같아야 한다.

많은 모나드 변환기는 모나드에서 실제로 사용할 수 있는 효과를 설명하는 MonadReader 형태의 타입 클래스를 추가로 정의한다. 이는 더 큰 유연성을 제공한다. 프로그램이 인터페이스에만 의존하도록 작성할 수 있고 기반 모나드가 특정 변환기로 구현되어야 한다고 제한하지 않기 때문이다. 타입 클래스는 프로그램이 요구 사항을 표현하는 방법이고, 모나드 변환기는 이러한 요구 사항을 충족하는 편리한 방법이다.

6.2.1. Failure with OptionT🔗

Option 모나드로 표현하는 실패와 Except 모나드로 표현하는 예외에는 각각 대응하는 변환기가 있다. Option의 경우 모나드가 Option α 타입의 값을 담게 하며, 원래는 α 타입의 값을 담을 자리다. 이렇게 하면 모나드에 실패를 추가할 수 있다. 예를 들어 IO (Option α)는 항상 α 타입의 값을 반환하지는 않는 IO 동작을 나타낸다. 이는 다음과 같은 모나드 변환기 OptionT의 정의를 제안한다:

def OptionT (m : Type u Type v) (α : Type u) : Type v := m (Option α)

OptionT의 작동 예로 사용자에게 질문하는 프로그램을 생각해 보자. getSomeInput 함수는 입력 한 줄을 받아 양 끝의 공백을 제거한다. 그 결과인 다듬은 입력이 비어 있지 않으면 반환하지만 공백이 아닌 문자가 없으면 함수가 실패한다:

def getSomeInput : OptionT IO String := do let input ( IO.getStdin).getLine let trimmed := input.trimAscii if trimmed.isEmpty then failure else pure trimmed.copy

이 애플리케이션은 사용자의 이름과 가장 좋아하는 딱정벌레 종을 기록한다:

structure UserInfo where name : String favoriteBeetle : String

사용자에게 입력을 요청하는 코드는 IO만 사용하는 함수보다 더 장황하지 않다:

def getUserInfo : OptionT IO UserInfo := do IO.println "What is your name?" let name getSomeInput IO.println "What is your favorite species of beetle?" let beetle getSomeInput pure name, beetle

하지만 함수가 단순히 IO에서 실행되는 것이 아니라 OptionT IO 문맥에서 실행되므로, 첫 번째 getSomeInput 호출이 실패하면 전체 getUserInfo가 실패하고 딱정벌레에 관한 질문까지 제어가 도달하지 않는다. 주 함수 interact는 순수한 IO 문맥에서 getUserInfo를 호출하므로 내부 Option에 패턴 매칭해 호출의 성공 여부를 확인할 수 있다:

def interact : IO Unit := do match getUserInfo with | none => IO.eprintln "Missing info" | some name, beetle => IO.println s!"Hello {name}, whose favorite beetle is {beetle}."

6.2.1.1. The Monad Instance🔗

모나드 인스턴스를 작성하면 어려움이 드러난다. 타입에 따르면 pure는 기반 모나드 mpuresome을 사용해야 한다. Option에 대한 bind가 첫 번째 인수에 분기해 none을 전달하는 것처럼, OptionT에 대한 bind도 첫 번째 인수를 이루는 모나드 동작을 실행하고 결과에 따라 분기한 다음 none을 전달해야 한다. 이 개요를 따라 작성한 다음 정의를 Lean은 받아들이지 않는다:

instance [Monad m] : Monad (OptionT m) where pure x := failed to synthesize instance of type class Pure (OptionT m) Hint: Type class instance resolution failures can be inspected with the `set_option trace.Meta.synthInstance true` command.pure Application type mismatch: The argument some x has type Option α✝ but is expected to have type α✝ in the application pure (some x)(some x) bind action next := do match ( action) with | Type mismatch none has type Option ?m.18 but is expected to have type α✝none => pure none | some v => next v

오류 메시지는 난해한 타입 불일치를 보여 준다:

Application type mismatch: The argument
  some x
has type
  Option α✝
but is expected to have type
  α✝
in the application
  pure (some x)

문제는 Lean이 바깥쪽 pure 사용에 대해 잘못된 Monad 인스턴스를 선택한다는 것이다. bind 정의에서도 비슷한 오류가 발생한다. 한 가지 해결책은 타입 주석을 사용해 Lean이 올바른 Monad 인스턴스를 선택하도록 이끄는 것이다:

instance [Monad m] : Monad (OptionT m) where pure x := (pure (some x) : m (Option _)) bind action next := (do match ( action) with | none => pure none | some v => next v : m (Option _))

이 해결책은 작동하지만 우아하지 않고 코드가 조금 장황해진다.

또 다른 해결책은 타입 서명이 Lean을 올바른 인스턴스로 이끄는 함수를 정의하는 것이다. 실제로 OptionT를 구조체로 정의할 수도 있다:

structure OptionT (m : Type u Type v) (α : Type u) : Type v where run : m (Option α)

이렇게 하면 생성자 OptionT.mk와 필드 접근자 OptionT.run이 타입 클래스 추론을 올바른 인스턴스로 이끌어 문제를 해결한다. 이렇게 하면 결과 코드가 더 복잡해지고 이런 구조체는 증명을 읽기 어렵게 만들 수 있다는 단점이 있다. 생성자 OptionT.mk와 필드 OptionT.run과 같은 역할을 하지만 직접 정의와 함께 작동하는 함수를 정의하면 두 방식의 장점을 모두 얻을 수 있다:

def OptionT.mk (x : m (Option α)) : OptionT m α := x def OptionT.run (x : OptionT m α) : m (Option α) := x

두 함수는 입력을 바꾸지 않고 반환하지만, OptionT의 인터페이스를 드러내려는 코드와 기반 모나드 m의 인터페이스를 드러내려는 코드 사이의 경계를 나타낸다. 이 도우미를 사용하면 Monad 인스턴스를 더 읽기 쉽게 만들 수 있다:

instance [Monad m] : Monad (OptionT m) where pure x := OptionT.mk (pure (some x)) bind action next := OptionT.mk do match action with | none => pure none | some v => next v

여기서 OptionT.mk를 사용하면 그 인수를 m의 인터페이스를 사용하는 코드로 취급하므로 Lean이 올바른 Monad 인스턴스를 선택할 수 있다.

모나드 인스턴스를 정의한 뒤 모나드 계약을 만족하는지 확인하는 것이 좋다. 첫 단계는 bind (pure v) ff v와 같음을 보이는 것이다. 단계는 다음과 같다.

bind (pure v) f

bindpure의 정의를 전개한다

OptionT.mk do match pure (some v) with | none => pure none | some x => f x

중첩 동작 구문의 문법 설탕을 제거한다

OptionT.mk do let y pure (some v) match y with | none => pure none | some x => f x

do 표기의 문법 설탕을 제거한다

OptionT.mk (pure (some v) >>= fun y => match y with | none => pure none | some x => f x)

m의 첫 번째 모나드 규칙을 사용한다

OptionT.mk (match some v with | none => pure none | some x => f x)

match를 줄인다

OptionT.mk (f v)

OptionT.mk의 정의

f v

두 번째 규칙은 bind w purew와 같다는 것이다. 이를 보이려면 bindpure의 정의를 전개하면 된다:

OptionT.mk do match w with | none => pure none | some v => pure (some v)

이 패턴 매칭에서 두 경우의 결과는 매칭하는 패턴을 pure로 감싼 것일 뿐이므로 패턴 자체와 같다. 다시 말해 이는 w >>= fun y => pure y와 동등하며, 이는 m의 두 번째 모나드 규칙의 한 예다.

마지막 규칙은 bind (bind v f) gbind v (fun x => bind (f x) g)와 같다는 것이다. 같은 방식으로 bindpure의 정의를 전개한 다음 기반 모나드 m에 맡겨 확인할 수 있다.

6.2.1.2. An Alternative Instance🔗

OptionT를 사용하는 편리한 방법 중 하나는 Alternative 타입 클래스를 통하는 것이다. 성공적인 반환은 이미 pure로 나타나며, AlternativefailureorElse 메서드를 사용하면 여러 하위 프로그램 중 첫 번째로 성공한 결과를 반환하는 프로그램을 작성할 수 있다:

instance [Monad m] : Alternative (OptionT m) where failure := OptionT.mk (pure none) orElse x y := OptionT.mk do match x with | some result => pure (some result) | none => y ()

6.2.1.3. Lifting🔗

m에서 OptionT m으로 동작을 끌어 올리려면 계산 결과를 some으로 감싸기만 하면 된다:

instance [Monad m] : MonadLift m (OptionT m) where monadLift action := OptionT.mk do pure (some ( action))

6.2.2. Exceptions🔗

Except의 모나드 변환기 버전은 Option의 모나드 변환기 버전과 매우 비슷하다. m α 타입의 모나드 동작에 ε 타입의 예외를 추가하려면 α에 예외를 추가해 m (Except ε α) 타입으로 만들면 된다:

def ExceptT (ε : Type u) (m : Type u Type v) (α : Type u) : Type v := m (Except ε α)

OptionTOptionT.mkOptionT.run 함수로 타입 검사기가 올바른 Monad 인스턴스를 선택하도록 이끈다. 이 기법은 ExceptT에도 유용하다:

def ExceptT.mk {ε α : Type u} (x : m (Except ε α)) : ExceptT ε m α := x def ExceptT.run {ε α : Type u} (x : ExceptT ε m α) : m (Except ε α) := x

ExceptTMonad 인스턴스도 OptionT의 인스턴스와 매우 비슷하다. 유일한 차이는 none 대신 특정 오류 값을 전달한다는 것이다:

instance {ε : Type u} {m : Type u Type v} [Monad m] : Monad (ExceptT ε m) where pure x := ExceptT.mk (pure (Except.ok x)) bind result next := ExceptT.mk do match result with | .error e => pure (.error e) | .ok x => next x

ExceptT.mkExceptT.run의 타입 서명에는 미묘한 점이 있다. αε의 우주 수준을 명시적으로 주석 처리한다. 이를 명시하지 않으면 Lean은 둘이 서로 다른 다형적 우주 변수를 갖는 더 일반적인 타입 서명을 생성한다. 하지만 ExceptT의 정의는 둘 다 m에 인수로 제공할 수 있으므로 같은 우주에 있을 것을 요구한다. 이 때문에 Monad 인스턴스에서 우주 수준 해결기가 작동하는 해를 찾지 못하는 문제가 생길 수 있다:

`ExceptT.mk`: universes `u_1`, `u_2` only occur together. This usually means there is a `max` expression in the type where none of these universes appear on their own. Note: This linter can be disabled with `set_option linter.checkUnivs false`def ExceptT.mk (x : m (Except ε α)) : ExceptT ε m α := xinstance {ε : Type u} {m : Type u Type v} [Monad m] : Monad (ExceptT ε m) stuck at solving universe constraint max ?u.26 ?u.27 =?= u while trying to unify ExceptT ε m β✝ : Type v with ExceptT.{max ?u.27 ?u.26, v} ε m β✝ : Type vstuck at solving universe constraint max ?u.17 ?u.18 =?= u while trying to unify ExceptT ε m α✝ : Type v with ExceptT.{max ?u.18 ?u.17, v} ε m α✝ : Type vwhere pure x := ExceptT.mk (pure (Except.ok x)) bind result next := ExceptT.mk do stuck at solving universe constraint max ?u.17 ?u.18 =?= u while trying to unify ExceptT ε m α✝ : Type v with ExceptT.{max ?u.18 ?u.17, v} ε m α✝ : Type vstuck at solving universe constraint max ?u.26 ?u.27 =?= u while trying to unify ExceptT ε m β✝ : Type v with ExceptT.{max ?u.27 ?u.26, v} ε m β✝ : Type vmatch ( result) with | .error e => pure (.error e) | .ok x => next x
stuck at solving universe constraint
  max ?u.26 ?u.27 =?= u
while trying to unify
  ExceptT ε m β✝ : Type v
with
  ExceptT.{max ?u.27 ?u.26, v} ε m β✝ : Type v

이런 종류의 오류 메시지는 보통 제약이 충분하지 않은 우주 변수 때문에 발생한다. 진단하기 어려울 수 있지만, 먼저 어떤 정의에서는 재사용되고 다른 정의에서는 재사용되지 않는 우주 변수를 찾아보는 것이 좋다.

Option과 달리 Except 데이터 타입은 보통 데이터 구조로 사용하지 않는다. 항상 Monad 인스턴스와 함께 제어 구조로 사용한다. 따라서 Except ε 동작을 ExceptT ε m으로, 기반 모나드 m의 동작도 함께 끌어 올리는 것이 합리적이다. Except 동작을 ExceptT 동작으로 끌어 올릴 때는 mpure로 감싼다. 예외 효과만 있는 동작은 m 모나드의 효과를 가질 수 없기 때문이다:

instance [Monad m] : MonadLift (Except ε) (ExceptT ε m) where monadLift action := ExceptT.mk (pure action)

m의 동작에는 예외가 없으므로 그 값을 Except.ok으로 감싸야 한다. FunctorMonad의 상위 클래스라는 사실을 이용하면 이를 수행할 수 있다. 즉 모든 모나드 계산의 결과에 함수를 적용할 때 Functor.map을 사용할 수 있다:

instance [Monad m] : MonadLift m (ExceptT ε m) where monadLift action := ExceptT.mk (.ok <$> action)

6.2.2.1. Type Classes for Exceptions🔗

예외 처리는 본질적으로 예외를 던지는 능력과 예외에서 복구하는 능력이라는 두 연산으로 이루어진다. 지금까지는 각각 Except의 생성자와 패턴 매칭을 사용해 이를 수행했다. 하지만 이 방식은 예외를 사용하는 프로그램을 예외 처리 효과의 특정 인코딩 하나에 묶는다. 이 연산들을 타입 클래스로 묶으면 예외를 던지고 잡는 어떤 모나드에서도 예외를 사용하는 프로그램을 실행할 수 있다.

예외를 던지는 연산은 예외를 인수로 받고 모나드 동작이 필요한 어떤 문맥에서도 허용되어야 한다. 명세의 “어떤 문맥” 부분은 m α라는 타입으로 쓸 수 있다. 임의의 타입 값을 만들어 낼 방법은 없으므로 throw 연산은 제어를 프로그램의 해당 부분에서 벗어나게 하는 일을 해야 한다. 예외를 잡는 연산은 핸들러와 함께 어떤 모나드 동작도 받아야 하며, 핸들러는 예외에서 동작의 타입으로 돌아가는 방법을 설명해야 한다:

class MonadExcept (ε : outParam (Type u)) (m : Type v Type w) where throw : ε m α tryCatch : m α (ε m α) m α

MonadExcept의 우주 수준은 ExceptT의 우주 수준과 다르다. ExceptT에서는 εα가 같은 수준이어야 하지만 MonadExcept에는 그런 제한이 없다. 이는 MonadExcept가 예외 값을 m 안에 넣지 않기 때문이다. 가장 일반적인 우주 서명은 이 정의에서 εα가 완전히 독립적이라는 사실을 반영한다. 더 일반적이므로 이 타입 클래스는 더 다양한 타입에 인스턴스화할 수 있다.

MonadExcept를 사용하는 프로그램의 예로 간단한 나눗셈 서비스를 보자. 프로그램은 두 부분으로 나뉜다. 문자열 기반 사용자 인터페이스를 제공하며 오류를 처리하는 프런트엔드와 실제로 나눗셈을 수행하는 백엔드다. 프런트엔드와 백엔드 모두 예외를 던질 수 있는데, 전자는 잘못된 형식의 입력에 대해, 후자는 0으로 나누는 오류에 대해 예외를 던진다. 예외는 귀납적 타입이다:

inductive Err where | divByZero | notANumber : String Err

백엔드는 0인지 확인하고 가능하면 나눈다:

def divBackend [Monad m] [MonadExcept Err m] (n k : Int) : m Int := if k == 0 then throw .divByZero else pure (n / k)

프런트엔드 도우미 asNumber는 전달받은 문자열이 숫자가 아니면 예외를 던진다. 프런트엔드 전체는 입력을 Int로 변환해 백엔드를 호출하고, 예외가 발생하면 이해하기 쉬운 문자열 오류를 반환해 처리한다:

def asNumber [Monad m] [MonadExcept Err m] (s : String) : m Int := match s.toInt? with | none => throw (.notANumber s) | some i => pure idef divFrontend [Monad m] [MonadExcept Err m] (n k : String) : m String := tryCatch (do pure (toString ( divBackend ( asNumber n) ( asNumber k)))) fun | .divByZero => pure "Division by zero!" | .notANumber s => pure s!"Not a number: \"{s}\""

예외를 던지고 잡는 일은 흔하므로 Lean은 MonadExcept를 사용하는 특별한 문법을 제공한다. +HAdd.hAdd의 줄임말인 것처럼 trycatchtryCatch 메서드의 줄임말로 사용할 수 있다:

def divFrontend [Monad m] [MonadExcept Err m] (n k : String) : m String := try pure (toString ( divBackend ( asNumber n) ( asNumber k))) catch | .divByZero => pure "Division by zero!" | .notANumber s => pure s!"Not a number: \"{s}\""

ExceptExceptT 외에도 처음에는 예외처럼 보이지 않을 수 있는 다른 타입에 유용한 MonadExcept 인스턴스가 있다. 예를 들어 Option으로 인한 실패는 아무 데이터도 담지 않은 예외를 던지는 것으로 볼 수 있다. 따라서 MonadExcept Unit Option 인스턴스가 있어 try ...catch ... 문법을 Option에서 사용할 수 있다.

6.2.3. State🔗

가변 상태의 시뮬레이션은 모나드 동작이 시작 상태를 인수로 받아 결과와 함께 최종 상태를 반환하게 해 모나드에 추가한다. 상태 모나드의 bind 연산자는 한 동작의 최종 상태를 다음 동작의 인수로 제공해 프로그램 전체에 상태를 전달한다. 이 패턴은 모나드 변환기로도 표현할 수 있다:

def StateT (σ : Type u) (m : Type u Type v) (α : Type u) : Type (max u v) := σ m (α × σ)

다시 말해 모나드 인스턴스는 State의 인스턴스와 매우 비슷하다. 유일한 차이는 입력 상태와 출력 상태를 순수한 코드로 다루는 대신 기반 모나드 안에서 전달하고 반환한다는 것이다:

instance [Monad m] : Monad (StateT σ m) where pure x := fun s => pure (x, s) bind result next := fun s => do let (v, s') result s next v s'

이에 대응하는 타입 클래스에는 getset 메서드가 있다. getset의 한 가지 단점은 상태를 갱신할 때 잘못된 상태를 set하기가 너무 쉽다는 것이다. 이는 상태를 가져오고 갱신한 뒤 저장하는 방식이 일부 프로그램을 작성하는 자연스러운 방법이기 때문이다. 예를 들어 다음 프로그램은 문자열에서 발음 구별 부호가 없는 영어 모음과 자음의 개수를 센다:

structure LetterCounts where vowels : Nat consonants : Nat deriving Repr inductive Err where | notALetter : Char Err deriving Repr def vowels := let lowerVowels := "aeiuoy" lowerVowels ++ lowerVowels.map (·.toUpper) def consonants := let lowerConsonants := "bcdfghjklmnpqrstvwxz" lowerConsonants ++ lowerConsonants.map (·.toUpper ) def countLetters (str : String) : StateT LetterCounts (Except Err) Unit := let rec loop (chars : List Char) := do match chars with | [] => pure () | c :: cs => let st get let st' if c.isAlpha then if vowels.contains c then pure {st with vowels := st.vowels + 1} else if consonants.contains c then pure {st with consonants := st.consonants + 1} else -- 수정되었거나 영어가 아닌 문자 pure st else throw (.notALetter c) set st' loop cs loop str.toList

set st' 대신 set st라고 쓰기 쉽다. 큰 프로그램에서는 이런 실수로 진단하기 어려운 버그가 생길 수 있다.

get 호출에 중첩 동작을 사용하면 이 문제를 해결할 수 있지만 이런 문제를 모두 해결할 수는 없다. 예를 들어 함수가 다른 두 필드의 값을 바탕으로 구조체의 한 필드를 갱신할 수 있다. 이 경우 get을 사용하는 별도의 중첩 동작 호출이 두 번 필요하다. Lean 컴파일러의 최적화 중에는 값에 대한 참조가 하나일 때만 효과적인 것이 있으므로 상태에 대한 참조를 복제하면 코드가 상당히 느려질 수 있다. 잠재적인 성능 문제와 버그는 모두 함수를 사용해 상태를 변환하는 modify를 사용해 우회할 수 있다:

def countLetters (str : String) : StateT LetterCounts (Except Err) Unit := let rec loop (chars : List Char) := do match chars with | [] => pure () | c :: cs => if c.isAlpha then if vowels.contains c then modify fun st => {st with vowels := st.vowels + 1} else if consonants.contains c then modify fun st => {st with consonants := st.consonants + 1} else -- 수정되었거나 영어가 아닌 문자 pure () else throw (.notALetter c) loop cs loop str.toList

타입 클래스에는 modify와 비슷한 modifyGet 함수가 있으며, 이를 사용하면 한 단계에서 반환값을 계산하면서 이전 상태를 변환할 수 있다. 이 함수는 첫 번째 원소가 반환값이고 두 번째 원소가 새 상태인 쌍을 반환한다. modifymodifyGet에서 사용하는 쌍에 Unit 생성자를 추가할 뿐이다:

def modify [MonadState σ m] (f : σ σ) : m Unit := modifyGet fun s => ((), f s)

MonadState의 정의는 다음과 같다:

class MonadState (σ : outParam (Type u)) (m : Type u Type v) : Type (max (u+1) v) where get : m σ set : σ m PUnit modifyGet : (σ α × σ) m α

PUnitUnit 타입의 한 버전으로, 우주 다형적이어서 Type 대신 Type u에 둘 수 있다. getset으로 modifyGet의 기본 구현을 제공할 수도 있지만, 그러면 애초에 modifyGet을 유용하게 만드는 최적화를 적용할 수 없어 메서드가 쓸모없어진다.

6.2.4. Of Classes and The Functions🔗

지금까지 MonadExcept의 예외 타입이나 MonadState의 상태 타입처럼 추가 정보를 받는 각 모나드 타입 클래스는 이런 추가 정보를 출력 매개변수로 가졌다. 간단한 프로그램에서는 이것이 대체로 편리하다. StateT, ReaderT, ExceptT를 각각 한 번씩 결합한 모나드는 상태 타입, 환경 타입, 예외 타입을 각각 하나만 가지기 때문이다. 그러나 모나드가 복잡해지면 여러 상태나 오류 타입이 포함될 수 있다. 이 경우 출력 매개변수를 사용하면 같은 do 블록에서 두 상태를 모두 대상으로 삼을 수 없다.

이런 경우를 위해 추가 정보가 출력 매개변수가 아닌 타입 클래스도 있다. 이 타입 클래스 버전들은 이름에 Of라는 단어를 사용한다. 예를 들어 MonadStateOfMonadState와 비슷하지만 outParam 수정자가 없다.

이 클래스들은 outParam 대신 각각의 상태, 환경, 예외 타입에 대해 semiOutParam을 사용한다. outParam과 마찬가지로 semiOutParam도 Lean이 인스턴스 검색을 시작하기 전에 반드시 알려져 있을 필요는 없다. 그러나 중요한 차이가 있다. outParam은 인스턴스 검색 중 무시되므로 진정한 출력이다. 검색 전에 outParam이 알려져 있다면 Lean은 검색 결과가 알려진 값과 같은지만 확인한다. 반면 검색 시작 전에 알려진 semiOutParam은 입력 매개변수처럼 후보를 좁히는 데 사용할 수 있다.

상태 모나드의 상태 타입이 outParam이면 각 모나드는 상태를 한 종류만 가질 수 있다. 이는 타입 추론을 개선해 더 많은 상황에서 상태 타입을 추론할 수 있다는 점에서 편리하다. 그러나 StateT를 여러 번 사용해 만든 모나드는 유용한 MonadState 인스턴스를 제공할 수 없다는 점에서 불편하다. 반면 MonadStateOf를 사용하면 Lean은 상태 타입을 알 수 있을 때 이를 고려해 사용할 인스턴스를 선택하므로, 하나의 모나드가 여러 상태 타입을 제공할 수 있다. 단점은 상태 타입이 충분히 명시되지 않았을 때 결과 인스턴스가 의도한 인스턴스가 아닐 수 있어 혼란스러운 오류 메시지가 발생한다는 것이다.

마찬가지로 추가 정보의 타입을 암시적 인자가 아니라 명시적(explicit) 인자로 받는 타입 클래스 메서드 버전도 있다. MonadStateOf에는 다음 타입의 getThe가 있다.

(σ : Type u) {m : Type u Type v} [MonadStateOf σ m] m σ

그리고 다음 타입의 modifyThe도 있다.

(σ : Type u) {m : Type u Type v} [MonadStateOf σ m] (σ σ) m PUnit

새 상태의 타입만으로 어떤 주변 상태 모나드 변환기를 사용할지 결정할 수 있으므로 setThe는 없다.

Lean 표준 라이브러리에는 Of가 없는 클래스 버전의 인스턴스가 Of가 있는 버전의 인스턴스로 정의되어 있다. 즉 Of 버전을 구현하면 두 버전의 구현을 모두 얻는다. 일반적으로 Of 버전을 구현한 다음 클래스의 Of가 없는 버전으로 프로그램을 작성하고, 출력 매개변수가 불편해지면 Of 버전으로 전환하는 것이 좋다.

6.2.5. Transformers and Id🔗

항등 모나드 Id는 어떤 이유로 모나드를 요구하지만 실제로는 모나드가 필요하지 않은 문맥에서 사용할 수 있는, 아무 효과도 없는 모나드다. Id의 또 다른 용도는 모나드 변환기 스택의 바닥 역할을 하는 것이다. 예를 들어 StateT σ IdState σ와 똑같이 작동한다.

6.2.6. Exercises🔗

6.2.6.1. Monad Contract🔗

종이와 연필을 사용하여 이 절의 각 모나드 변환기가 모나드 변환기 계약의 규칙을 만족하는지 확인하라.

6.2.6.2. Logging Transformer🔗

WithLog의 모나드 변환기 버전을 정의하라. 대응하는 MonadWithLog 타입 클래스도 정의하고, 로깅과 예외를 결합하는 프로그램을 작성하라.

6.2.6.3. Counting Files🔗

doug의 모나드를 StateT로 수정하여 방문한 디렉터리와 파일의 수를 세도록 하라. 실행이 끝나면 다음과 같은 보고서를 표시하게 하라.

  Viewed 38 files in 5 directories.