6.3. Ordering Monad Transformers
모나드 변환기를 쌓아 모나드를 구성할 때는 모나드 변환기를 쌓는 순서가 중요하다는 점을 알아야 한다. 같은 변환기 집합도 순서를 다르게 쌓으면 서로 다른 모나드가 된다.
이 버전의 countLetters는 구체적인 모나드를 제공하는 대신 사용 가능한 효과의 집합을 타입 클래스로 표현한다는 점만 제외하면 앞의 버전과 같다:
def countLetters [Monad m] [MonadState LetterCounts m] [MonadExcept Err m]
(str : String) : m 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상태 모나드 변환기와 예외 모나드 변환기는 두 가지 순서로 결합할 수 있으며, 어느 순서든 두 타입 클래스의 인스턴스를 가진 모나드가 된다:
abbrev M1 := StateT LetterCounts (ExceptT Err Id)
abbrev M2 := ExceptT Err (StateT LetterCounts Id)프로그램이 예외를 던지지 않는 입력에 대해 실행하면 두 모나드 모두 비슷한 결과를 낸다:
#eval countLetters (m := M1) "hello" ⟨0, 0⟩#eval countLetters (m := M2) "hello" ⟨0, 0⟩
하지만 이 반환값들 사이에는 미묘한 차이가 있다.
M1의 경우 가장 바깥 생성자는 Except.ok이며, 그 안에 단위 생성자와 최종 상태의 쌍이 들어 있다.
M2의 경우 가장 바깥 생성자는 쌍이며, 그 안에는 단위 생성자에만 적용된 Except.ok가 들어 있다.
최종 상태는 Except.ok 바깥에 있다.
두 경우 모두 프로그램은 모음과 자음의 개수를 반환한다.
반면 문자열 때문에 예외가 발생하면 모음과 자음의 개수를 보존해 반환하는 모나드는 하나뿐이다.
M1을 사용하면 예외 값만 반환된다:
#eval countLetters (m := M1) "hello!" ⟨0, 0⟩
M2를 사용하면 예외 값이 예외가 발생한 시점의 상태와 쌍을 이룬다:
#eval countLetters (m := M2) "hello!" ⟨0, 0⟩
디버깅할 때 유용할 수 있는 정보를 더 많이 제공한다는 이유로 M2가 M1보다 우월하다고 생각하기 쉽다.
같은 프로그램이 M1에서 계산하는 답과 M2에서 계산하는 답은 다를 수 있으며, 어느 답이 반드시 더 낫다고 말할 원칙적인 이유는 없다.
예외를 처리하는 단계를 프로그램에 추가하면 이를 볼 수 있다:
def countWithFallback
[Monad m] [MonadState LetterCounts m] [MonadExcept Err m]
(str : String) : m Unit :=
try
countLetters str
catch _ =>
countLetters "Fallback"
이 프로그램은 항상 성공하지만, 서로 다른 결과로 성공할 수 있다.
예외가 발생하지 않으면 결과는 countLetters와 같다:
#eval countWithFallback (m := M1) "hello" ⟨0, 0⟩#eval countWithFallback (m := M2) "hello" ⟨0, 0⟩
하지만 예외가 발생해 잡히면 최종 상태는 크게 달라진다.
M1에서는 최종 상태에 "Fallback"의 글자 개수만 들어 있다:
#eval countWithFallback (m := M1) "hello!" ⟨0, 0⟩
M2에서는 명령형 언어에서 예상할 수 있듯이 최종 상태에 "hello!"와 "Fallback"의 글자 개수가 모두 들어 있다:
#eval countWithFallback (m := M2) "hello!" ⟨0, 0⟩
M1에서는 예외를 던지면 상태가 예외를 잡은 지점으로 “되돌아간다”.
M2에서는 예외를 던지고 잡는 동안에도 상태 변경이 유지된다.
이 차이는 M1과 M2의 정의를 전개하면 볼 수 있다.
M1 α을 전개하면 LetterCounts → Except Err (α × LetterCounts)가 되고, M2 α를 전개하면 LetterCounts → Except Err α × LetterCounts가 된다.
다시 말해 M1 α은 초기 글자 개수를 받아 오류 또는 갱신된 개수와 쌍을 이룬 α를 반환하는 함수를 나타낸다.
M1에서 예외가 발생하면 최종 상태가 없다.
M2 α는 초기 글자 개수를 받아 새로운 글자 개수와 오류 또는 α 중 하나의 쌍을 반환하는 함수를 나타낸다.
M2에서 예외가 발생하면 상태가 함께 제공된다.
6.3.1. Commuting Monads
함수형 프로그래밍 용어로는 모나드 변환기 두 개의 순서를 바꾸어도 프로그램의 의미가 바뀌지 않으면 두 변환기는 교환한다고 한다.
StateT와 ExceptT의 순서를 바꾸었을 때 프로그램의 결과가 달라질 수 있다는 사실은 상태와 예외가 교환하지 않는다는 뜻이다.
일반적으로 모나드 변환기가 교환한다고 기대해서는 안 된다.
모든 모나드 변환기가 교환하지는 않지만 일부는 교환한다.
예를 들어 StateT를 두 번 사용하는 경우 순서를 바꿀 수 있다.
StateT σ (StateT σ' Id) α의 정의를 전개하면 σ → σ' → ((α × σ) × σ') 타입이 되고, StateT σ' (StateT σ Id) α의 정의를 전개하면 σ' → σ → ((α × σ') × σ)가 된다.
다시 말해 두 타입은 반환 타입에서 σ와 σ'를 서로 다른 위치에 중첩하고 인수를 서로 다른 순서로 받는다는 점에서 다르다.
어떤 클라이언트 코드든 같은 입력을 제공해야 하고 같은 출력을 받는다.
가변 상태와 예외를 모두 가진 대부분의 프로그래밍 언어는 M2처럼 동작한다.
이런 언어에서는 예외가 발생했을 때 되돌려야 하는 상태를 표현하기 어렵고, 보통 M1에서 명시적인 상태 값을 전달하는 것과 매우 비슷한 방식으로 시뮬레이션해야 한다.
모나드 변환기는 주어진 문제에 맞는 효과 순서의 해석을 선택할 자유를 주며, 두 선택 모두 똑같이 쉽게 프로그래밍할 수 있다.
하지만 변환기의 순서를 선택할 때 주의를 기울여야 한다.
표현력이 커질수록 표현한 것이 의도한 것인지 확인할 책임도 커지며, countWithFallback의 타입 서명은 아마도 필요한 것보다 더 다형적이다.
6.3.2. Exercises
-
모나드 변환기
ManyT를Many의 정의를 바탕으로 적절한Alternative인스턴스와 함께 구성하라.Monad계약을 만족하는지 확인하라. -
ManyT는StateT와 교환하는가? 그렇다면 정의를 전개하고 그 결과 타입을 추론해 답을 확인하라. 그렇지 않다면ManyT (StateT σ Id)에서 프로그램 하나와StateT σ (ManyT Id)에서 프로그램 하나를 작성하라. 각 프로그램은 해당 모나드 변환기 순서에서 더 의미 있는 것이어야 한다.