모나드
모나드
모나드는 하스켈을 이해하는 데 꽤 유명한 관문이에요. 부수 효과를 순수 함수형 세계 안으로 데려오고, 시퀀싱과 합성을 표현하는 강력한 추상화예요. 이 페이지에서는 >>=와 return이라는 두 기본 연산자, 그걸 편하게 쓰게 해 주는 do 문법, 그리고 모나드가 지켜야 할 법칙까지 정리해 볼게요.
본문
모나드란?
모나드는 IO 같은 다형성 타입 위에 세워져요. 모나드 자체는 그 타입을 Functor, Monad, MonadPlus 같은 모나딕 클래스의 instance로 선언하면서 정의돼요. 모나딕 클래스 중 어느 것도 파생(derive)될 수 없어요. IO 외에도 표준 라이브러리에는 리스트([])와 Maybe가 모나딕 클래스의 멤버예요.
수학적으로 모나드는 모나딕 연산들이 지켜야 하는 **법칙(laws)**의 집합으로 다뤄져요. 이런 법칙이 모나드에만 있는 건 아니에요. 예를 들어 x /= y와 not (x == y)는 어떤 타입에서든 같아야 하는데, ==와 /=는 Eq 클래스의 별개 메서드라 그걸 보장할 방법은 없어요. 마찬가지로 모나딕 법칙도 하스켈이 강제하지 않지만, 모나딕 클래스의 instance는 지켜야 하는 규칙이에요.
Functor와 fmap
Functor 클래스는 단일 연산 fmap을 정의해요. map이 컨테이너(다형성 타입은 다른 타입의 값을 담는 컨테이너로 생각할 수 있어요) 안의 객체에 연산을 적용하고, 같은 모양의 컨테이너를 돌려주는 것처럼요. fmap에는 다음 법칙이 적용돼요.
| 법칙 | |
|---|---|
fmap id |
= id |
fmap (f . g) |
= fmap f . fmap g |
이 법칙들은 fmap이 컨테이너의 모양을 바꾸지 않고, 내용물을 재배치하지 않는다는 걸 보장해 줘요.
Monad 클래스와 두 기본 연산자
Monad 클래스는 두 기본 연산자 >>=(bind)와 return을 정의해요.
infixl 1 >>, >>=
class Monad m where
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
return :: a -> m a
fail :: String -> m a
m >> k = m >>= \_ -> k
bind 연산 >>와 >>=는 모나딕 값 두 개를 결합하고, return은 값을 모나드(컨테이너) 안으로 주입해요. >>=의 시그니처가 이 연산을 이해하게 해 줘요. ma >>= \v -> mb는 타입 a의 값을 담은 모나딕 값 ma와, 타입 a의 값 v를 받아 모나딕 값 mb를 돌려주는 함수를 결합해요. 결과는 b를 담은 모나딕 값으로 합쳐져요. >>는 함수가 첫 모나딕 연산자가 만든 값을 필요로 하지 않을 때 쓰여요.
bind의 정확한 의미는 모나드에 따라 달라져요. IO 모나드에서 x >>= y는 두 동작을 순차적으로 수행하면서 첫 번째 결과를 두 번째로 넘겨요. 리스트와 Maybe 같은 다른 내장 모나드에서는 한 계산에서 다음 계산으로 0개 이상의 값을 넘기는 것으로 이해할 수 있어요.
do 문법
do 문법은 모나딕 연산의 연결을 위한 간편 표기예요. 핵심 번역은 두 규칙으로 요약돼요.
do e1 ; e2 = e1 >> e2
do p <- e1; e2 = e1 >>= \p -> e2
두 번째 형태에서 패턴이 반증 가능(refutable)하면 패턴 매치 실패가 fail을 호출해요. 이는 오류를 일으키거나(IO 모나드) "zero"를 돌려줄 수도 있어요(리스트 모나드). 그래서 더 복잡한 번역은 이렇게 돼요.
do p <- e1; e2 = e1 >>= (\v -> case v of p -> e2; _ -> fail "s")
여기서 s는 오류 메시지에 쓸 do 문의 위치를 알려주는 문자열이에요. 예컨대 IO 모나드에서 'a' <- getChar 같은 동작은 입력한 문자가 'a'가 아니면 fail을 호출하고, IO 모나드에서 fail이 error를 부르므로 프로그램이 종료돼요.
모나드 법칙
>>=와 return을 지배하는 법칙은 다음과 같아요.
| 법칙 | |
|---|---|
return a >>= k |
= k a |
m >>= return |
= m |
xs >>= return . f |
= fmap f xs |
m >>= (\x -> k x >>= h) |
= (m >>= k) >>= h |
mplus 연산자의 법칙은 이렇게 돼요.
| 법칙 | |
|---|---|
m \mplus` mzero` |
= m |
mzero \mplus` m` |
= m |
리스트 모나드에서 mplus는 보통의 리스트 이어붙이기예요.
리스트 모나드
리스트 모나드에서 bind는 리스트의 각 값에 대해 계산 집합을 이어붙이는 일이에요. 리스트와 쓰일 때 >>=의 시그니처는 이렇게 돼요.
(>>=) :: [a] -> (a -> [b]) -> [b]
즉 a의 리스트와 a를 b의 리스트로 보내는 함수가 주어졌을 때, bind는 이 함수를 입력의 각 a에 적용하고 만들어진 모든 b를 한 리스트로 이어붙여 돌려줘요. return은 원소 하나짜리 리스트를 만들어요. 이 연산들은 이미 익숙한데, 리스트 컴프리헨션은 리스트에 정의된 모나딕 연산으로 쉽게 표현할 수 있거든요. 다음 세 표현은 전부 같은 것을 가리켜요.
[(x,y) | x <- [1,2,3] , y <- [1,2,3], x /= y]
do x <- [1,2,3]
y <- [1,2,3]
True <- return (x /= y)
return (x,y)
[1,2,3] >>= (\ x -> [1,2,3] >>= (\y -> return (x/=y) >>=
(\r -> case r of True -> return (x,y)
_ -> fail "")))
이 정의는 이 모나드에서 fail이 빈 리스트라는 사실에 의존해요. 본질적으로 각 <-는 모나딕 계산의 나머지로 넘겨질 값 집합을 만들어요. 그래서 x <- [1,2,3]은 리스트의 각 원소마다 모나딕 계산의 나머지를 세 번 호출해요. 이런 의미에서 리스트 모나드는 다중 값 인자의 함수로 생각할 수 있어요. 예를 들어 이 함수는
mvLift2 :: (a -> b -> c) -> [a] -> [b] -> [c]
mvLift2 f x y = do x' <- x
y' <- y
return (f x' y')
두 인자의 보통 함수 f를 다중 값(리스트 인자) 위의 함수로 바꿔 줘요. 두 입력 인자의 가능한 모든 조합에 대해 값을 하나씩 돌려주죠. 이건 모나드 라이브러리의 LiftM2 함수의 특수 버전이에요.
Maybe에 정의된 모나드는 리스트 모나드와 비슷해요. Nothing이 [], Just x가 [x] 역할을 해요.
상태 모나드 예시
상태 타입 S를 중심으로 만든 상태 모나드는 이렇게 생겼어요.
data SM a = SM (S -> (a,S)) -- The monadic type
instance Monad SM where
-- defines state propagation
SM c1 >>= fc2 = SM (\s0 -> let (r,s1) = c1 s0
SM c2 = fc2 r in
c2 s1)
return k = SM (\s -> (k,s))
-- extracts the state from the monad
readSM :: SM S
readSM = SM (\s -> (s,s))
-- updates the state of the monad
updateSM :: (S -> S) -> SM ()
updateSM f = SM (\s -> ((), f s))
-- run a computation in the SM monad
runSM :: S -> SM a -> (a,S)
runSM s0 (SM c) = c s0
이 예시는 타입 SM을, 타입 S의 상태를 암묵적으로 끌고 다니는 계산으로 정의해요. 타입 SM t의 계산은 타입 t의 값을 정의하면서 동시에 상태 S와 상호작용(읽고 쓰기)해요. SM은 상태를 받아 두 결과 — 돌려줄 값(아무 타입이나)과 갱신된 상태 — 를 만드는 함수로 구성돼요. instance 선언에 쓸 수 있는 SM 같은 타입 이름이 필요하다 보니 여기선 타입 동의어를 쓸 수 없고, newtype 선언을 자주 써요.
이 instance 선언은 모나드의 "배관"을 정의해요. 시퀀싱(>>=)은 초기 상태 s0를 c1에 넘기고, 그 계산에서 나온 값 r을 두 번째 계산 fc2를 돌려주는 함수에 넘기며, c1에서 나온 상태를 c2에 넘겨 전체 결과는 c2의 결과가 되는 계산을 정의해요. return은 상태를 전혀 바꾸지 않고 값만 모나드로 데려와요.
>>=와 return이 기본 시퀀싱 연산이라면, 모나딕 프리미티브라는 것도 필요해요. 이는 모나드 추상화의 안쪽을 이용해 모나드가 작동하게 하는 실제 바퀴와 톱니에 접근하는 연산이에요. IO 모나드에서 putChar 같은 연산이 원시적이에요. 상태 모나드에서도 readSM과 updateSM 두 프리미티브를 써요. 이들은 모나드의 내부 구조에 의존해서, SM 타입 정의가 바뀌면 함께 바뀌어야 해요.
마지막으로 모나드에서 계산을 실행하는 함수 runSM이 필요해요. 초기 상태와 계산을 받아 계산의 반환 값과 최종 상태를 모두 돌려줘요.
임베디드 언어와 DSL
모나드는 특정 도메인에 맞춘 연산과 타입 라이브러리를 쌓아 작은 임베디드 언어를 만드는 데 특히 강력해요. 하스켈은 임베디드 언어 정의에 매우 능하고, 그 능력의 핵심이 모나드예요. 대표적인 예로 반응형 애니메이션 언어 Fran, 컴퓨터 음악 언어 Haskore가 있어요. 자원 계산을 다루는 모나드 R을 정의하는 예시를 통해, 모나드가 시스템의 기본 의미론을 깔끔하고 모듈화된 방식으로 캡슐화하는 데 얼마나 유용한지 볼 수 있어요.
data R a = R (Resource -> (Resource, Either a (R a)))
각 계산은 남은 자원을 입력받아, 결과 a 또는 자원이 소진된 지점까지의 작업을 담은 중단된 계산 R a를 반환하는 함수예요. 이런 모나드를 통해 자원이 있을 때만 진행하고, 없으면 계산이 중단되는 계산을 표현할 수 있어요. 더 나아가 로직이 거의 하스켈처럼 읽히는 프로그램을 모나드로 감쌀 수 있게 돼요.
더 알아보기
- 입출력 — 모나드가 실제 부수 효과를 다루는 IO를 만나는 곳
- 값, 타입, 그리고 리스트 —
Maybe와 리스트 모나드의 바탕이 되는 타입 - 함수 — 지연 평가, 부분 적용