카테고리 이론에서 모나드가 어떤 의미를 갖는가는 저도 잘 모릅니다.
여기에서는 지금까지 제가 모나드에 대해 이해하고 있던 것과 바로 전에
제가 올린 글에 나온 모나드 강좌를 보고 새롭게 알게 된 것을 매우 간략히
정리하고자 합니다.
내가 이해하는 모나드
모나드란 단순화해서 말하자면 암시적인 함수 인자를 다루는 방법,
혹은 주로 드러내 보이고 싶은 것만 명시적으로 보고 나머지는 뒤에
숨겨놓는 방법입니다.
예를 들어 에러가 날 수 있는 연산을 한다고 합시다.
data Exp = Int Int | Add Exp Exp | Sub Exp Exp | Mul Exp Exp | Div Exp Exp
data Val = INT Int | ERROR String
calc (Int n) = INT n
calc (Add e1 e2) =
case (calc e1) of
ERROR s -> ERROR s
INT n -> case (calc e2) of
ERROR s -> ERROR s
INT m -> INT(n+m)
{- Sub, Mul 도 마찬가지 ... -}
calc (Div e1 e2)
case (calc e1) of
ERROR s -> ERROR s
INT n -> case (calc e2) of
ERROR s -> ERROR s
INT 0 -> ERROR "Div by 0"
INT m -> INT(n`div`m)
뭔가 상당히 복잡해져 버립니다.
우리가 간단히 떠올리는 아래와 같은 코드로부터 멀어지게 됩니다.
calc (Int n) = INT n
calc (Add e1 e2) = INT (calc e1 + calc e2)
{- 나머지 생략 -}
잠깐 곁가지로 빠지자면, 프로그래머는 게을러야 한다는 격언이 있습니다.
너무 열심히 타이핑하는 것을 좋아하는 프로그래머는 동료에게 고통을 줍니다.
위의 코드들을 보고 "에러처리 하려면 당연하지"라고 생각하신다면 더 많이
게을러질 필요가 있습니다. 함수를 제대로 처리하지 못하는, 이를테면 C와 같은
언어로 프로그램을 짤 때는 함수 호출을 할때마다 그 결과값에 대해 할때마다
if ( /*result is errorcode*/ ) 를 하는 것을 너무 당연한 일로 여기게 됩니다.
표현력이 많이 모자라는 언어라 사실 그것 말고는 별다른 방법이 없기 때문일지도
모르겠습니다. 함수형 언어는 대세가 될 수밖에 없습니다. 프로그래밍 언어 관련
연구자나 현장에 있는 사람들이 그 시기를 얼마나 앞당기느냐가 관건이 될
뿐입니다.
다시 줄기로 돌아옵시다. monad 를 쓰면 에러를 커튼 뒤로 숨기고 군더더기가
거의 없는 원래 생각했던 간단한 코드와 거의 비슷하게 프로그래밍할 수 있습니다.
모나드란 다음과 같은 꼴을 가지는 두 연산이 정의되는 대상을 말합니다.
(>>=) :: m a -> (a -> m b) -> m b
return :: a -> m a
다시 줄기로 돌아옵시다. monad 를 쓰면 에러를 커튼 뒤로 숨기고 군더더기가
거의 없는 원래 생각했던 간단한 코드와 거의 비슷하게 프로그래밍할 수 있습니다.
그리고 덤으로, 꼭 필요한 건 아니지만 이항연산자 >>= 의 첫번째 항의 결과를
두번째 항으로 넘길 필요가 없는 경우가 종종 있기 때문에 쓰는 >> 연산자를
편의상 정의합니다. 꼴은 m a -> m b -> m b 이고 그 정의는 다음과 같습니다.
여기서 a -> b 이면 a 타입의 인자를 받아 b 를 내놓는 함수 꼴이라는 것은
알겠는데 도대체 m a 가 무엇인가 하는 분들이 있을 것입니다.
이는 parametrized type 입니다. 예를 들어 List Int, List String 과 같이
어떤 정수 리스트도 있을 수 있고 문자열 리스트도 있을 수 있고 할때 이런
꼴들을 아울러서 일반적으로 꼴 변수 a 에 대해 List a 라고 쓸 수 있고 앞의
List 마저 일반화 시켜 꼴 변수를 m 을 그 자리에 대신 쓴다면 m a 라고 표시할
수 있습니다.
m a 라는 꼴이 모나드라는 뜻은, 우리가 드러내 보이고자 하는 꼴은 a 인데
우리가 보고 싶어하는 것은 물론 드러나지 않은 것까지 다 포함하여 갖고
있는 꼴이 바로 m a 라는 것입니다.
parametrize type 이 있는 타입시스템을 한번도 사용해보거나 본적이 없는
분들은 무슨 이야기를 하는지 감이 안잡힐 수도 있는데, 단순화해서 그냥
(>>=) :: m -> (a -> m) -> m
return :: a -> m
(>>) :: m -> m -> m
이렇게 일단 생각하셔도 되겠습니다.
처음에 제기했던 문제와 연결되는 예를 하나 들면 위의 내용이 무슨 말인지
와닿을 것입니다. 모나드 연산을 다음과 같이 정의합시다.
INT n >>= f = f n
ERROR s >>= f = ERROR s
return = INT
그러면 calc 를 다음과 같이 간단하면서도 처음에 복잡하게 썼던 프로그램과
같이 에러를 처리할 수 있는 프로그램을 쓸 수 있습니다.
calc (Int n) = return n
calc (Add e1 e2) = calc e1 >>= \n -> calc e2 >>= \m -> return (m+n)
{- Sub, Mul 마찬가지 -}
calc (Div e1 e2) = calc e1 >>= \n -> calc e2 >>= \m ->
if m=0 then ERROR "Div by 0" else return (m`div`n)
분명히 글자 개수는 줄어든 것 같은데, 읽기가 상당히 불편하다고 아직 실망하기엔
이릅니다. 다음과 같이 일반 이항연산자를 모나드에 대한 이항연산자로 들어올리는
함수를 정의합시다.
liftM2 op m1 m2 = m1 >>= \v1 -> m2 >>= \v2 -> return (v1`op`v2)
그리고 새로이 모나드에 대한 사칙연산을 정의합니다.
에러를 실제로 발생시키는 나누기 연산을 제외하고는 모두 위 함수를 사용해
다음과 같이 정의할 수 있습니다.
aDD = liftM2 (+)
sUB = liftM2 (-)
mUL = liftM2 (*)
dIV m1 m2 = m1 >>= \v1 -> m2 >>= \v2 ->
if v2=0 then ERROR "Div by 0" else return (v1`div`v2)
그러면 다음과 같이 에러처리에 대한 부분은 모나드 뒤에 숨어 있고
에러처리를 하지 않는 간단한 코드에서처럼 핵심적인 내용만이 드러나는
프로그램을 쓸 수 있습니다.
calc (Int n) = return n
calc (Add e1 e2) = calc e1 `aDD` calc e2
{- Sub, Mul, Div 마찬가지 -}
뒤에 숨기고자 하는 대상은 에러 처리 뿐만 아니고 어떤 것이든지 될 수 있습니다.
이것이 모나드를 사람들이 이해하기 어렵다고 느끼는 이유입니다. 정의는
허무하게도 연산 두 개인데, 대체 어떻게 돌아가는지 잘 보이지 않고 그렇다고
딱히 무엇에 써먹는지도 확실하지 않기 때문입니다. 아니 사실 원래 이것이 생긴
이유가 어떻게 돌아가는지 잘 보이지 않게 하려고 한 것일지도 모르겠습니다.
그러다 보니 처음 보면 뭐하자는 것인지 잘 이해가 안갑니다. 여담이지만
수학의 정의들이 대체로 복잡하기보다는 너무 간단하고 일반적이어서 감을
못잡는 것 같습니다. 카테고리 이론은 특히 더 그런 것 같습니니다.