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)로 작성한 프로그램을 작성하십시오. 각 프로그램은 주어진 모나드 트랜스포머 순서에 더 부합하는 것이어야 합니다.