모나드를 ‘프로그램’으로 보고, 모나드 법칙을 명령형 프로그램 관점에서 해설한다. 또한 일급성, 모듈성, 다양한 모나드와 결합 방법, 언어의 범용성과 생산성의 트레이드오프, 그리고 Haskell에서의 추상화 수준을 개괄한다.
이것은 모나드 튜토리얼은 아닐 것이다.
이 글을 쓰는 동기는, 모나드를 모르는 사람이 모나드라는 미지의 개념에 기대하는 것이 근본적으로 빗나가 있을지 모른다는 우려 때문이다.
명령형 언어로 프로그래밍을 배운 사람이 모나드, 혹은 Haskell이나 함수형 언어(라고 불리는 언어)를 학습할 때 흔히 받는 조언은 이렇다:
「명령형 언어에서 지금까지 배운 것을 전부 잊고 임하면 좋아」
이 조언은 크게 틀린 말은 아닐지 모르지만, 거칠다.
언젠가 내 친구가 이렇게 말했다:
「프로그래머가 팀으로 일할 때 필요한 건 서로를 배려하는 마음이잖아」
이 말도 아마 크게 빗나가지는 않았겠지만, 배려로 모든 것을 해결하려 들면 모든 비용이 매우 커진다. 우리는 개별 문제에 눈을 돌려 각 문제의 해법을 알아둠으로써 비용을 낮출 수 있다는 것을 알고 있다. 뭐 친구는 그런 것보다 남을 배려하지 않는 특정 멤버를 염두에 두고 있었겠지만.
모나드란 대략적으로 말해 “프로그램”이다.
좀 더 정확히 말하자면, Haskell에서 모나드란 튜링 완전한 정적 타입 명령형 언어처럼 다룰 수 있는 것이다.
프로그래머(관계자 전반을 가리킴)에게 이것은 자명하며, 프로그래머에게 프로그램은 중요하다.
다른 언어와의 차이를 고려해 좀 더 설명하자면, “모나드를 통해 우리는 프로그램을 일급 값으로 다룰 수 있기” 때문이고, “그 프로그램에 적절하게 타입을 붙일 수 있기” 때문이다.
더 말하자면 그것은 “모듈성”을 위해서이며, 왜 함수형プログラミングは重要か에서 John Hughes가 지적한 것처럼 “문제를 부분으로 분해하는 능력은 해를 이어 붙이는 능력에 직접적으로 의존한다”. 프로그램을 적절하게 분해하려면 그것을 적절히 조합하는 능력이 필요하다. 이를 위한 것이 모나드 법칙이며, 그를 위한 것이 모나드다.
일급(first-class)이라는 것은 대략 다음을 의미한다
(계산機プログラムの構造と解釈 - 1.3.4 値として返される手続き). 여기서 절차는 함수로 치환해 읽으면 된다.
Haskell에서는 함수가 일급의 지위를 갖고 있으며, 예를 들어 2인수 함수의 경우 다음과 같은 타입이 붙는다(A, B, C는 기지라고 하자)
function :: A -> B -> C
이에 비해 모나드를 사용한 2인수 “프로그램”은 예를 들어 다음과 같은 타입을 갖는다(A, B, C, M은 기지이고, M은 모나드라고 하자)
program :: A -> B -> M C
Haskell에서 “프로그램”은 “모나드를 사용한 특수한 함수”라는 것이다.
즉, “함수가 일급인 Haskell에서는, 프로그램을 일급 값으로 다룰 수 있다”.
주의할 점은, 프로그램(모나드)이 언어의 built-in-feature가 아니라는 것이다.
모나드도 수많은 타입 클래스 중 하나에 불과하다. 모나드를 구성하는 것은, term 레벨(값 레벨)에서는 람다 계산의 범위로 가능하다.
모나드 법칙(Monad Laws)이란 모나드가 만족해야 할 규칙이며, Haskell에서는 다음의 3가지를 가리킨다:
return x >>= f == f x
m >>= return == m
(m >>= f) >>= g == m >>= (\x -> f x >>= g)
모나드가 프로그램이라는 관점에서 모나드 법칙을 보자.
여기서 등호 (==)는 Haskell의 연산자를 뜻하지 않는다. 결과가 같아지는 정도로 이해하면 된다.
모나드 법칙 첫 번째는 이해를 돕기 위해, 좌변을 장황하게 써 보자.
return x >>= f == f x
return x >>= (\y -> f y) == f x
값 x를 변수에 할당하고(y라 하자) 그 값을 f에 넘기는 것(law1_lhs)과, f에 x를 곧장 넘기는 것(law1_rhs)이 같아야 한다고 말한다.
이것들을 각각 do 표기법으로 표현해 보자. 그러면 모나드는 명령형 프로그램처럼 보인다. (<-)를 값의 변수로의 할당, 각 행을 문(statements; Haskell에서는 actions라고 부른다)처럼 보면 된다. do 표기법은 모나드를 명령형 언어처럼 “보이게” 하기 위한 특별한 모나드 전용 설탕 문법(syntax sugar)이며, 그리고 모나드는 실제로 명령형 프로그램이다.
law1_lhs = do
y <- return x
f y
law1_rhs = do
f x
프로그래밍을 해 본 사람이라면, 그것들이 같다는 것을 경험적으로 얼추 알 것이다. 당연한 소리를 한다고 느낄지도 모른다. 이 규칙은 그 당연한 일을 요구한다. Haskell에서는, 모나드(Monad 타입 클래스의 인스턴스)가 반드시 모나드 법칙을 만족하는 것은 아니다. 그것은 모나드가 Haskell의 built-in-feature가 아니기 때문이거나, 혹은 의존 타입을 갖고 있지 않기 때문이다. Haskell에는 이 모나드 같은 대수적 성질을 요구하는 수학적 객체가 흔히 존재한다.
나는 대수적 성질이 프로그래밍에 직접적으로 도움이 된다고 생각한다. 그것이 설계에서는 코드의 가시성을 높이고, 구현에서는 코드의 복잡도를 줄이며, 테스트에서는 그 서술을 용이하게 하기 때문이다. 다만 여기서는 탈선하므로 생략한다.
모나드 법칙 두 번째도 장황하게 써 보자.
m >>= return == m
m >>= (\z -> return z) == m
m을 실행하고 결과를 변수에 할당해(z라 하자), 그 z를 return에 넘긴 결과(law2_lhs)와, m 자체가 같아야 한다는 요구다.
law2_lhs = do
z <- m
return z
law2_rhs = do
m
모나드를 프로그램으로 간주하는 관점에서 말하면, 모나드 법칙 1과 2를 합쳐 “return은 값을 돌려주는 것 말고는 아무 일도 하지 않는 문(statement)이어야 한다”는 요구를 의미한다.
모나드 법칙 세 번째는 bind(>>=)에 관한 요구다.
이것도 η-확장을 해서 변수를 보완하면 대칭성이 보기 쉬워진다. 우선순위를 나타내는 괄호도 넉넉히 보완해 두자.
(m >>= f) >>= g == m >>= (\x -> f x >>= g)
(m >>= \x -> f x) >>= \y -> g y == m >>= (\x -> f x >>= \y -> g y)
(m >>= (\x -> f x)) >>= (\y -> g y) == m >>= (\x -> (f x >>= (\y -> g y)))
이는 “프로그램(또는 statements)이 셋 있을 때, 그 결합 순서가 같다면 결합 방법에 상관없이 결과가 같아야 한다”를 요구한다.
예를 들어, 기존 프로그램이 셋 있을 때(m :: Monad n => n a; f , g :: Monad n => a -> n b), 프로그램은 일급이므로 앞의 둘(m, f)을 결합해 두고 재사용을 위해 이름을 붙여 두었다가(prog1), 나중에 세 번째 g를 이어 붙이고 싶을 수 있다(law3_lhs):
prog1 = do
x <- m
f x
law3_lhs = do
y <- prog1
g y
혹은, f, g를 먼저 결합해 두고(prog2), 나중에 m을 결합하고 싶을 수 있다(law3_rhs):
prog2 x = do
y <- f x
g y
law3_rhs = do
x <- m
prog2 x
이 law3_lhs와 law3_rhs의 결과가 같아야 한다고 말한다.
이러한 요구는 실제 프로그래밍에서 자연스럽게 발생한다. 프로그램의 결합 순서가 같다면 결과가 같았으면 하고 자연스레 생각할 것이고, 이 결과가 어긋나면 우리는 프로그램의 합성에 쓸데없는 신경을 써야 한다.
모나드 법칙 세 번째는 그러한 프로그램의 결합에 관한 요구를 나타낸다.
모나드 법칙을 만족했다면, 우리는 모나드를 마음 놓고 명령형 언어로서 다룰 수 있다. 모나드 법칙은 그를 위한 최소한의 허들이다.
여기서 모나드의 정의로 돌아가자.
class Monad m where
-- | Sequentially compose two actions, passing any value produced
-- by the first as an argument to the second.
(>>=) :: forall a b. m a -> (a -> m b) -> m b
-- | Inject a value into the monadic type.
return :: a -> m a
Monad의 Minimal complete definition은 bind(>>=)와 return이다. 이것들은 각각 모나드 법칙을 고려하면 다음과 같은 의미를 갖는다는 것을 알 수 있다.
return은 아무것도 하지 않고, 단지 모나딕한 값을 돌려줄 뿐인, 가장 작은 문(statement)이다>>=)는 두 개의 문(statements)을 직렬로 결합한다
“모나드는 프로그램”이라고 설명했지만, 이 문구는 캐치함을 중시했고, 자연어의 모호성 때문에 의도대로 읽히지 않을 수도 있다. 그래서 좀 더 정확히 용어를 설명하자.
Haskell에 정의된 Monad 타입 클래스는, “절차”라는 개념 그 자체다.
그리고 “명령형 프로그래밍 언어의 추상”이기도 하다.
명령형 프로그래밍 언어가 무엇이냐고 묻는다면, 앞서 말했듯 문(statements)과 그 결합(bind)으로 정의되는 언어다.
예를 들어 IO 모나드나 Maybe, List 모나드처럼, Monad 타입 클래스의 인스턴스가 된 개념을 가리킨다.
이는 개별 “명령형 프로그래밍 언어”에 해당한다.
세상에는 다양한 명령형 언어(C, Java, Python, …)가 존재하지만, 그 언어들에 대응하는 Monad 인스턴스를 만드는 것도 가능할 것이다.
IO 모나드의 함수나, Maybe 모나드의 구체적 함수에 해당한다.
이는 개별적으로 구체화된 “프로그램”을 의미한다.
program :: A -> M B
이상에서, “모나드는 프로그램”이라는 문구는 꽤 거칠며, “모나드는 절차”라거나 “모나드는 프로그래밍 언어의 추상”이라고 하는 편이 더 정확하다. 다만 그렇게 말한다 해도 내 경험상 반응은 애매하다. 또한 각 단어가 갖는 자연어의 모호성, 즉 개념 그 자체를 가리키는 경우와 개념의 구체예를 가리키는 경우가 있거나, 설명의 생략도 빈번하므로, 무엇을 의미하는지는 각 문맥에서 판단해야 한다. 이러한 자연어의 모호성은 모나드 자체의 추상도의 높음과 맞물려 모나드의 이해를 방해하는 듯하다. 더구나 각 개인의 모나드에 대한 불완전한 이해에서 비롯된 언급이 여기에 박차를 가하는 것 같기도 하다.
구체적인 모나드를 살펴보자.
이 프로그래밍 언어는, 문 중 하나라도 실패하면 전체 프로그램이 실패한다는 특징을 가진다.
문이 복수의 가능성을 반환할 수 있는 프로그래밍 언어다. 흔히 비결정적 계산이라 불린다.
임의의 IO를 실행할 수 있는 명령형 언어다. 익숙한 사람도 많을 것이다.
문 뒤편에 상태를 하나 가질 수 있는 프로그래밍 언어다. 그 상태에는 API(get/put)를 사용하여 접근한다.
문에서 값이 반환되는 대신 예외가 던져질 가능성이 있는 프로그래밍 언어다.
bind(>>=)를 문의 직렬적 결합이라고 설명했다. “문제를 부분으로 분해하는 능력은 해를 이어 붙이는 능력에 직접적으로 의존한다.” 다른 결합 방법은 없을까? 물론 있다.
bind(>>=)에 의한 결합
고차 함수적 결합
모나드의 기능 결합
고차 함수적 결합에 대해 보충하자.
모나드는 프로그램이다. 그리고 프로그램이 프로그램을 받거나, 프로그램이 프로그램을 반환한다는 것은 고차 프로그램이라고 할 수 있을 것이다. Haskell에서 프로그램은 모나드를 쓴 단순한 함수이며, Haskell에는 고차 함수가 존재하므로, 고차 프로그램도 동일하게 존재하고 기술할 수 있다. 그것은 예를 들어 다음과 같은 타입을 가질 것이다(A, B, C, M은 기지이고, M은 모나드라고 하자):
hoProgram_arg :: M A -> M B -> M C
hoProgram_ret :: A -> B -> M (M C)
인수로서 모나딕한 값을 받고 있거나, 프로그램 M 위에서(M C)의 값을 반환하고 있다는 것을 읽을 수 있을 것이다.
모나드로 무엇이 바뀌는가 하면 설계다. 우리는 모나드를 사용함으로써 다양한 “폭”의 언어를 Haskell 안에 만들어낼 수 있다. 언어의 폭이란 여기서는 그 언어가 풀 수 있는 문제의 범위를 의미한다.
언어의 폭은 이른바 범용성이다. 그리고 범용성과 생산성에는 트레이드오프가 존재한다.
언어가 넓으면 여러 문제를 해결할 수 있지만, 개별 문제를 푸는 데 있어서는 생산성이 낮다.
언어를 좁혀 특정 문제에 특화하면 범용성은 낮아지지만, 그 영역에서는 생산성을 높일 수 있다.
그렇게 Haskell로 문제를 풀려고 할 때 당혹스러울 수 있다. 표준 라이브러리에 준비된 각종 모나드는 범용성이 너무 높기 때문이다. 물론 그것들을 써서 곧바로 문제를 밀어붙여 풀어도 상관없지만, 문제를 적절히 풀 수 있는 주변 언어를 구축하는 방법을 하나의 접근법으로 알아두면 좋겠다. DSL을 구축해 어느 정도 범위를 좁혀 두면, 그 후의 시행착오가 쉬워질 것이다.
모나드를 이용한 테크닉에 관해서는 예전에 만든 슬라이드가 있으니 참고하기 바란다.
Haskell 학습자가 Haskell 내의 기능 중 하나인 모나드에 기대하는 것은 어디까지나 “언어 내의 기능”인 경우가 많은 듯하다. 예를 들어 이터레이터나 제너레이터, 코루틴, 비동기 기능, Optional, 예외 기능 등. 그러나 여기까지 기술한 대로, 모나드는 그것들보다 훨씬 추상도가 높은 개념이다. 이 예상 밖의 높은 추상도는 자주 겪지 않기 때문에 당혹스러울 수 있다. 게다가 어렵다 어렵다 소문난 데 비해 모나드의 정의는 매우 단순하다. 추상도가 높다면 정의가 간소해지는 것은 곰곰이 생각해 보면 지극히 당연하지만, 역시 그러한 경험은 다른 언어에서는 별로 체험하기 어렵다.
명령형 언어로 여러 프로그래밍을 익혀 온 사람에게 미지의 영역으로의 탐색은 아주 오래전 일일지도 모른다. 그런 사람에게 Haskell 학습은 그것을 요구하는 경우가 많고, 그래서 “지금까지 배운 것은 잊고 임할” 필요가 있을지도 모른다. 그럴 경우 마음의 준비를 해 두면 좋겠다. Haskell에서는 추상적(모호하다는 뜻이 아님)인 것을 추상적인 채로 다루는 일이 자주 있기 때문이다.
다만, 모나드를 이해한 뒤에는 그 기존 지식이 필요해진다는 것은 기억해 두면 좋겠다. 모나드는 명령형 프로그래밍 언어 그 자체이기 때문이다.
모나드 덕분에 프로그램이 모듈성을 동반한 채 우리 앞에 나타난다.
모나드가 무엇이며 무엇에 쓰이는가를 오랫동안 생각해 온 사람에게도, 모나드를 처음부터 홀로 이해하는 일은 역시 어렵다고 느낀다. 그래서 다른 사람이 매끄럽게 모나드를 이해할 수 있는 바탕을 만들 수 있으면 한다.
4년 정도 전의 나의 몸부림이 남아 있으니 붙여 둔다.
Haskell은 아직도 발전 도중이라고 생각하고, 아직 쓰임새를 찾지 못한 수학적 객체가 수두룩한 Haskell을 나는 정말로 쿨하다고 생각한다. 대수적 성질이 먼저 굴러다니고 있으니, 기쁠 수밖에. 그래서 그런 객체들의 쓰임새를 다 같이 생각해 보자—라는 일을 해 나갈 수 있으면 한다.