모나드와 Free 모나드를 알고 있는 분을 위한, Free 모나드 DSL(Domain Specific Language) 예시입니다. Free 모나드 글에서 이어지는 글입니다.
먼저 DSL로 쓸 펑터를 정의합니다. 읽기와 쓰기 명령어만 가진 간단한 언어입니다.
{-# LANGUAGE DeriveFunctor #-}
import Control.Monad.Free
data Command a -- Command next
= Read (String -> a) -- = Read (String -> next)
| Write String a -- | Write String next
deriving Functor
-- 위와 같이 deriving Functor 해주거나 아래와 같이 직접 정의
instance Functor Command where
fmap f (Read g) = Read (\s -> f (g s))
fmap f (Write s a) = Write s (f a)
Command
가 Cmd1 a
, Cmd2 a
같은 모양이면 그러려니 하는데, Read
가 함수 (String -> a)
를 가지고 있습니다. 나중에 체이닝을 하다 보면 a
에는 Free
가 들어가기도1 할텐데, 복잡해집니다.
Free (Read ? (Pure r))
지금부터 나중에 넣어 주는 펑터를 외부 펑터라 부르겠습니다. Free
모나드의 정의를 보면, Free타입
값은 Free (f (Pure r))
같이 Pure r
로 끝나는 값입니다. 그럼 외부 펑터 f
가 Free
를 받을 수 있어야 합니다. 그런데 Read
는 String -> a
타입의 함수를 받아야만 합니다. 이러면 Free타입
을 못 받는 것처럼 보일 수 있습니다. 값 생성자와 타입 생성자를 혼동하지 말아야 합니다. Command a
타입에 Free타입
을 넣어 주면 Read (String -> Free타입)
값이거나 Write String Free타입
값이 됩니다.
Command a
에서 a
의 역할이 뭘까요? Free
에 넣어서 Read
와 Write
를 이어 붙인 모양을 보겠습니다. Free (Read (String -> Free타입))
과 Free (Write String (Pure a))
을 붙이면 아래 모양이 나옵니다.
Free (Read (String -> Free (Write String (Pure a)))) -- (가)
a
자리엔 다음 Free
값이 들어 갑니다. 그래서 a
대신 next
로 표현하기도 합니다. 위 한 줄은 Read
작업 후 Write
를 하는 하나의 작은 프로그램입니다. 이를 해석하는 인터프리터를 보겠습니다. 3가지 패턴 매칭을 처리합니다.
interpret :: Free Command a -> IO a
Pure a) = return a interpret (
Free (Read f)) = do input <- getLine
interpret (-- f는 String -> Free (...) 타입입니다. interpret (f input)
Free (Write s next)) = do putStrLn s
interpret ( interpret next
작은 (가)
프로그램을 interpret
에 넣으면, 첫 번째 Free (Read f)
패턴에 걸리고, f
는 String -> Free타입
함수입니다. 이 함수에 input
을 인자로 주면 또 다시 Free (Write ...)
를 interpret
에 넣습니다. Write
생성자의 인자로 들어온 input
을 출력하고, Pure r
을 다음 Free
값으로 넘기면 return a
로 끝납니다. 입력 받은 값을 출력하고 나면, 별다른 반환값이 없으므로 마지막 a
로 ()
가 들어가도록 하면 됩니다.
Free타입
은 항상 Pure r
로 끝나니,
오직 Read
한 번만 있는 프로그램은 Free (Read (String -> (Pure r)))
오직 Write
한 번만 있는 프로그램은 Write
는 Free (Write String (Pure r))
그렇다면, Read
후에 Write
하는 프로그램은? do
를 써서 아래같이 예쁘게 쓸 수 있습니다.
exampleProgram :: Free Command ()
= do name <- Free (Read ?) -- Free Command String
exampleProgram -- Free Command () write name
?
에 뭘 써주면 될까요? 일단 최종 String
타입을 돌려줘야 하는데, 일단 가장 간단한 \s -> Pure s
를 넣어서 보겠습니다. 이쁘긴 한데, 왜 이렇게 할 수 있는 잘 안보이니, 둘이 어떻게 연결되는지 뜯어 보겠습니다. @todo Read
가 함수를가지고 있는 이유 추가
do name <- Free (Read (\s -> Pure s))
write name
do
를 바인드가 보이게 디슈거 해보면,
Free (Read (\s -> Pure s)) >>= \name -> Write name (Pure ())
Free
의 바인드(Free x) >>= g = Free (fmap (>>= g) x)
를 넣어 풀어 보면,
-- Command의 fmap
-- |
Free (fmap (>>= (\name -> Free (Write name (Pure ())))) (Read (\s -> Pure s)))
Free (Read (\s -> Pure s >>= (\name -> Free (Write name (Pure ()))) ))
Pure s >>= (\name -> Free (Write name (Pure ())))
는 바인드의 (Pure r) >>= g = g r
패턴 매칭에 의해 Free (Write s (Pure ()))
이 됩니다.
Free (Read \s -> Free (Write s (Pure ())))
Read
한 후 Write
하는 프로그램이 위와 같이 표현되었습니다.
> interpret (Free (Read \s -> Free (Write s (Pure ()))))
lionhairdino lionhairdino
텍스트를 입력하면 그대로 출력하는 간단한 프로그램이 완성되었습니다. 여기까지 오는 길이 쉽지 않은데, 여기 도착했다고 끝이 아닙니다. 이제 MonadFree를 봐야 합니다… 하스켈, 어렵습니다. 분명 이런 방식으로 접근하는 것보다 더 나은 방법이 있을 거라 믿고 있는데, 현재까지 제가 이해하는 방식은 위와 같습니다. 쓸 때마다 머릿속에 이런 내용들을 다 넣고 쓰는 게 아니라, 원리를 알고 나면, 세부 구현은 잠시 미루고 쓸 줄도 알아야 하는데, 모르는 구석이 조금이라도 남아 있으면 계속 뜯어 보게 됩니다. 권장할 만한 학습 방법은 아닌 것 같습니다.
{-# LANGUAGE DeriveFunctor #-}
import Control.Monad.Free
data Command a
= Read (String -> a)
| Write String a
deriving Functor
type CommandM = Free Command -- 공짜? 최소한의 모나드
-- 조금 더 긴! 프로그램을 만들어 봤습니다.
-- (liftF로 보기 좋게 바꿀 수도 있습니다.)
exampleProgram :: CommandM String
= do
exampleProgram Free (Write "이름 입력:" (Pure ()))
<- Free (Read (\s -> Pure s))
name Free (Write (name ++ "님, 안녕하세요!") (Pure ()))
return name
interpret :: CommandM a -> IO a
Pure a) = return a
interpret (Free (Read f)) = do
interpret (<- getLine
input
interpret (f input)Free (Write s next)) = do
interpret (putStrLn s
interpret next
main :: IO ()
= do
main <- interpret exampleProgram
result putStrLn $ "final result: " ++ result
명령어를 모아서 가지고만 있던 Free 모나드 체인을 인터프리터에 넘겨 IO
컨텍스트에서 해석하고 있습니다. 모나드 do
로 체이닝을 해놨으니, 먼저 실행한 명령어들의 결과를 다음 명령어가 받을 수 있고, 덤으로 코드 모양도 이뻐집니다.
항상 들어가는 게 아니라, “들어 가기도”라고 표현했습니다. 컨텍스트를 유지한다는 말이 혼동될 때가 있습니다. 예를 들어 Maybe
바인드가 Maybe Int
타입에 적용하는 함수는 반드시 Int -> Maybe Int
가 아니라 Int -> Maybe String
처럼, 다음 이어지는 함수가 받는 타입과 같은 것을 가진 Int -> Maybe (어떤 타입)
이든 될 수 있습니다. Free Command
는 Free Command Int
이거나, Free Command (Free 타입)
이거나, Free Command String
등이 될 수 있다는 말입니다.↩︎