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

6.2. 모나드 조립 키트🔗

유용한 모나드 트랜스포머가 ReaderT만 있는 것은 결코 아닙니다. 이 절에서는 그 밖의 여러 트랜스포머를 설명합니다. 각 모나드 트랜스포머는 다음 요소로 구성됩니다.

  1. 모나드를 인자로 받는 정의 또는 데이터 타입 T입니다. 이는 (Type u → Type v) → Type u → Type v와 같은 타입을 가져야 합니다. 다만 모나드보다 앞에 추가 인자를 받을 수도 있습니다.

  2. Monad m 인스턴스에 의존하는, T m에 대한 Monad 인스턴스입니다. 이 인스턴스가 있으면 변환된 모나드를 모나드로 사용할 수 있습니다.

  3. 임의의 모나드 m에 대해 m α 타입의 액션을 T m α 타입의 액션으로 변환하는 MonadLift 인스턴스입니다. 이 인스턴스 덕분에 기반 모나드의 액션을 변환된 모나드에서 사용할 수 있습니다.

또한 트랜스포머의 Monad 인스턴스는 적어도 기반 Monad 인스턴스가 Monad의 계약을 따른다면 그 계약을 따라야 합니다. 아울러 monadLift (pure x : m α)는 변환된 모나드에서 pure x와 동등해야 하며, monadLift는 bind에 대해 분배되어야 합니다. 즉, monadLift (x >>= f : m α)는 (monadLift x : m α) >>= fun y => monadLift (f y)와 같아야 합니다.

많은 모나드 트랜스포머는 추가로 MonadReader와 같은 방식의 타입 클래스를 정의하며, 이 타입 클래스는 해당 모나드에서 실제로 사용할 수 있는 효과를 기술합니다. 이는 더 큰 유연성을 제공할 수 있습니다. 즉, 인터페이스에만 의존하는 프로그램을 작성할 수 있게 해 주며, 기반 모나드가 특정 트랜스포머로 구현되도록 제약하지 않습니다. 타입 클래스는 프로그램이 자신의 요구 사항을 표현하는 수단이며, 모나드 트랜스포머는 이러한 요구 사항을 충족하는 편리한 수단입니다.

6.2.1. 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. 모나드 인스턴스🔗

모나드 인스턴스를 작성해 보면 한 가지 어려움이 드러납니다. 타입으로 보면 pure는 기반 모나드 m의 pure를 some과 함께 사용해야 합니다. 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) f가 f v와 같음을 보이는 것입니다. 그 단계는 다음과 같습니다:

bind (pure v) f

bind와 pure의 정의 펼치기

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 pure가 w와 같다는 것입니다. 이를 보이기 위해 bind와 pure의 정의를 펼치면 다음을 얻습니다:

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) g가 bind v (fun x => bind (f x) g)와 같다는 것입니다. 이 규칙도 같은 방식으로 확인할 수 있습니다. bind와 pure의 정의를 전개한 다음 기반 모나드 m에 위임하면 됩니다.

6.2.1.2. Alternative 인스턴스🔗

OptionT를 편리하게 사용하는 한 가지 방법은 Alternative 타입 클래스를 이용하는 것입니다. 성공적인 반환은 이미 pure로 나타내고 있으며, Alternative의 failure 메서드와 orElse 메서드는 여러 하위 프로그램 중 처음으로 성공한 결과를 반환하는 프로그램을 작성하는 방법을 제공합니다:

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. 리프팅🔗

m에서 OptionT m으로 액션을 리프팅하려면 계산 결과를 some으로 감싸기만 하면 됩니다:

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

6.2.2. 예외🔗

Except의 모나드 트랜스포머 버전은 Option의 모나드 트랜스포머 버전과 매우 비슷합니다. m α 타입의 모나드 액션에 ε 타입의 예외를 추가하려면 α에 예외를 추가하면 되며, 그 결과 m (Except ε α) 타입이 됩니다:

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

OptionT는 타입 검사기가 올바른 Monad 인스턴스를 찾도록 이끄는 OptionT.mk 함수와 OptionT.run 함수를 제공합니다. 이 기법은 ExceptT에도 유용합니다:

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

ExceptT의 Monad 인스턴스도 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.mk와 ExceptT.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 인스턴스와 함께 제어 구조로 사용됩니다. 따라서 기반 모나드 m의 액션뿐만 아니라 Except ε 액션도 ExceptT ε m으로 리프트하는 것이 합리적입니다. Except 액션을 ExceptT 액션으로 리프트하는 것은 해당 액션을 m의 pure로 감싸서 이루어집니다. 예외 효과만 가진 액션은 모나드 m의 효과를 전혀 가질 수 없기 때문입니다:

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

m의 액션에는 예외가 전혀 들어 있지 않으므로, 그 값은 Except.ok로 감싸야 합니다. 이는 Functor가 Monad의 상위 클래스라는 사실을 이용하여 해결할 수 있습니다. 즉, 어떤 모나드 계산이든 그 결과에 함수를 적용하는 일은 Functor.map으로 할 수 있습니다:

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

6.2.2.1. 예외를 위한 타입 클래스🔗

예외 처리는 근본적으로 두 가지 연산으로 이루어집니다. 하나는 예외를 던지는 능력이고, 다른 하나는 예외로부터 복구하는 능력입니다. 지금까지는 이를 각각 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의 축약형인 것처럼, try와 catch는 tryCatch 메서드의 축약형으로 사용할 수 있습니다:

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}\""

Except와 ExceptT 외에도, 언뜻 보기에는 예외처럼 보이지 않을 수도 있는 다른 타입들에 대해서도 유용한 MonadExcept 인스턴스가 있습니다. 예를 들어, Option으로 인한 실패는 아무런 데이터도 담고 있지 않은 예외를 던지는 것으로 볼 수 있으므로, Option에서 try ...catch ... 문법을 사용할 수 있게 해 주는 MonadExcept Unit Option 인스턴스가 있습니다.

6.2.3. 상태🔗

모나드 액션이 시작 상태를 인자로 받고 결과와 함께 최종 상태를 반환하도록 하면 모나드에 가변 상태의 시뮬레이션을 추가할 수 있습니다. 상태 모나드의 바인드 연산자는 한 액션의 최종 상태를 다음 액션의 인자로 넘겨, 상태를 프로그램 전체에 걸쳐 이어 나갑니다. 이 패턴은 모나드 트랜스포머로도 표현할 수 있습니다:

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'

이에 대응하는 타입 클래스에는 get 메서드와 set 메서드가 있습니다. get과 set의 한 가지 단점은 상태를 갱신할 때 잘못된 상태를 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 -- modified or non-English letter 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 -- modified or non-English letter pure () else throw (.notALetter c) loop cs loop str.toList

이 타입 클래스에는 modify와 비슷한 modifyGet이라는 함수가 있습니다. 이 함수를 사용하면 반환값을 계산하는 일과 이전 상태를 변환하는 일을 한 단계에서 함께 처리할 수 있습니다. 이 함수는 첫 번째 요소가 반환값이고 두 번째 요소가 새 상태인 쌍을 반환합니다. modify는 modifyGet에서 사용하는 쌍에 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 α

PUnit은 Unit 타입의 유니버스 다형적 버전으로, Type 대신 Type u에 속할 수 있습니다. get과 set을 이용해 modifyGet의 기본 구현을 제공할 수는 있지만, 그렇게 하면 애초에 modifyGet을 유용하게 만드는 최적화를 적용할 수 없으므로 이 메서드는 쓸모가 없어집니다.

6.2.4. Of 클래스와 The 함수🔗

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

이러한 경우를 위해, 추가 정보가 출력 매개변수가 아닌 타입 클래스들이 별도로 존재합니다. 이러한 버전의 타입 클래스는 이름에 Of라는 단어를 사용합니다. 예를 들어, MonadStateOf는 MonadState와 비슷하지만 outParam 수식어가 없습니다.

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

상태 모나드의 상태 타입이 outParam이면 각 모나드는 최대 한 가지 타입의 상태만 가질 수 있습니다. 이는 타입 추론을 개선하므로 편리합니다. 더 많은 상황에서 상태 타입을 추론할 수 있기 때문입니다. 하지만 이는 불편하기도 합니다. StateT를 여러 번 사용하여 만든 모나드는 유용한 MonadState 인스턴스를 제공할 수 없기 때문입니다. 반면 MonadStateOf를 사용하면 Lean은 어떤 인스턴스를 사용할지 선택할 때 상태 타입을 알 수 있으면 그 타입을 고려합니다. 따라서 하나의 모나드가 여러 타입의 상태를 제공할 수 있습니다. 이 방식의 단점은 상태 타입이 충분히 명시적으로 지정되지 않으면 선택된 인스턴스가 의도한 것과 다를 수 있다는 점이며, 이로 인해 이해하기 어려운 오류 메시지가 발생할 수 있습니다.

마찬가지로, 추가 정보의 타입을 암시적 인자가 아닌 명시적 인자로 받는 타입 클래스 메서드 버전도 있습니다. 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. 트랜스포머와 Id🔗

항등 모나드 Id는 효과가 전혀 없는 모나드로, 어떤 이유로 모나드를 요구하지만 실제로는 모나드가 필요하지 않은 맥락에서 사용됩니다. Id의 또 다른 용도는 모나드 트랜스포머 스택의 맨 아래 역할을 하는 것입니다. 예를 들어, StateT σ Id는 State σ와 똑같이 동작합니다.

6.2.6. 연습 문제🔗

6.2.6.1. 모나드 계약🔗

연필과 종이를 사용하여 이 절의 각 모나드 트랜스포머가 모나드 트랜스포머 계약의 규칙을 만족하는지 확인하십시오.

6.2.6.2. 로깅 트랜스포머🔗

WithLog의 모나드 트랜스포머 버전을 정의하십시오. 이에 대응하는 타입 클래스 MonadWithLog도 정의하고, 로깅과 예외를 결합하는 프로그램을 작성하십시오.

6.2.6.3. 파일 개수 세기🔗

StateT를 사용하여 doug의 모나드를 수정해, 지금까지 살펴본 디렉터리와 파일의 수를 세도록 하십시오. 실행이 끝나면 다음과 같은 보고서를 출력해야 합니다.

  Viewed 38 files in 5 directories.