6.3. 모나드 트랜스포머의 순서 정하기
모나드 트랜스포머를 쌓아 모나드를 구성할 때는 모나드 트랜스포머를 쌓는 순서가 중요하다는 점을 알아 두어야 합니다. 같은 트랜스포머들이라도 쌓는 순서가 다르면 서로 다른 모나드가 됩니다.
이 버전의 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 -- modified or non-English letter
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. 교환 가능한 모나드
함수형 프로그래밍 용어로는, 두 모나드 트랜스포머의 순서를 바꿔도 프로그램의 의미가 달라지지 않으면 두 모나드 트랜스포머가 교환 가능하다(commute)고 합니다. StateT와 ExceptT의 순서를 바꾸면 프로그램의 결과가 달라질 수 있다는 사실은 상태와 예외가 교환 가능하지 않다는 것을 의미합니다. 일반적으로 모나드 트랜스포머가 교환 가능하리라고 기대해서는 안 됩니다.
모든 모나드 트랜스포머가 교환 가능한 것은 아니지만, 일부는 교환 가능합니다. 예를 들어, StateT를 두 번 사용하는 경우에는 그 순서를 바꿀 수 있습니다. StateT σ (StateT σ' Id) α의 정의를 펼치면 σ → σ' → ((α × σ) × σ') 타입이 되고, StateT σ' (StateT σ Id) α를 펼치면 σ' → σ → ((α × σ') × σ) 타입이 됩니다. 다시 말해, 둘의 차이는 반환 타입에서 σ 타입과 σ' 타입이 중첩되는 위치가 서로 다르다는 점과 인자를 받는 순서가 다르다는 점입니다. 클라이언트 코드는 어느 쪽이든 여전히 동일한 입력을 제공해야 하며, 여전히 동일한 출력을 받게 됩니다.
가변 상태와 예외를 모두 갖춘 대부분의 프로그래밍 언어는 M2처럼 동작합니다. 그러한 언어에서는 예외가 발생했을 때 되돌려야 하는 상태를 표현하기 어려우며, 보통 M1에서 명시적인 상태 값을 전달하는 방식과 매우 비슷한 형태로 이를 흉내 내야 합니다. 모나드 트랜스포머는 당면한 문제에 맞는 효과 순서 해석을 자유롭게 선택할 수 있게 해 주며, 어느 쪽을 선택하든 똑같이 쉽게 프로그램을 작성할 수 있습니다. 그러나 모나드 트랜스포머를 사용할 때는 트랜스포머의 순서를 신중하게 선택해야 합니다. 강력한 표현력에는 표현된 내용이 의도한 바와 일치하는지 확인할 책임이 따르며, countWithFallback의 타입 시그니처는 아마도 필요 이상으로 다형적일 것입니다.
6.3.2. 연습 문제
-
ReaderT와StateT의 정의를 펼치고 그 결과로 나오는 타입을 따져 보면서 두 모나드 트랜스포머의 순서를 서로 바꾸어도 되는지 확인하십시오. -
ReaderT와ExceptT는 교환 가능합니까? 각각의 정의를 전개하고 그 결과로 나오는 타입에 대해 추론하여 답을 확인하십시오. -
Many의 정의를 바탕으로, 적절한Alternative인스턴스를 갖춘 모나드 트랜스포머ManyT를 만드십시오. 이것이Monad계약을 만족하는지 확인하십시오. -
ManyT와StateT는 교환 가능합니까? 그렇다면 정의를 전개하고 그 결과로 나오는 타입을 따져 보아 답을 확인하십시오. 그렇지 않다면ManyT (StateT σ Id)로 작성한 프로그램과StateT σ (ManyT Id)로 작성한 프로그램을 각각 하나씩 작성하십시오. 각 프로그램은 주어진 모나드 트랜스포머 순서에서 더 자연스러운 프로그램이어야 합니다.