모나드 트랜스포머 - Monad Transformer 그리고 mtl

Posted on July 16, 2020

이 글을 읽으시려면 IO 모나드와 Reader 모나드의 동작을 알고 있어야 합니다.

목차

  1. IO
  2. IO 위에 Reader
  3. mtl
  4. Return Type Polymorphic
  5. mtl 핵심 아이디어
  6. 자주 만나는 에러 No instance
  7. 에러 수정

라이브러리를 제외하고, IO가 없는 프로그램은 없습니다. 데이터를 받고, 처리를 하고, 출력을 해주는게 프로그램입니다. 예를 들어, 소수를 구하는 프로그램은 입력Input도 없고, 출력Ouput도 없는 것 아니냐라고 생각할 수도 있습니다. 입력은 없지만, 구한 소수를 눈에 보이지 않게 하면 의미가 없으니 결국 출력이 필요합니다. 소수를 구하는 함수 자체에는 출력이 없지만, 나중에 GHCi 등에서 함수를 부르면 결과값을 출력해주는 작업을 GHCi가 대신하게 됩니다. 어찌 됐든 결론은, 언젠가 IO는 반드시 필요하다는 얘기입니다.

IO

IO 모나드를 반드시 써야 하고, 모든 IO 액션들이 설정값을 필요로 하는 경우를 생각해 봅시다. 이미 IO로 체이닝한 상태인데 Reader 모나드를 쓰고 싶을 때는 어떻게 할까요? 이미 IO 액션이 (..(..(..()))) 이렇게 엮여 있는데, 액션 하나가 실행 할 때마다 Reader 속성이 발현되도록 다시 엮어야 합니다.

act1 :: IO ()
act1 = putStrLn " 나는 OO과 영화를 봤다. "

act2 :: IO ()
act2 = putStrLn " 나는 OO과 밥을 먹었다. "

act3 :: IO ()
act3 = putStrLn " 나는 OO과 술을 먹었다. "

act = do
  act1
  act2
  act3

OO에 입력 받은 이름을 넣고 싶으면

act1 :: String -> IO ()
act1 who = putStrLn $ "나는 " ++ who ++ "와 영화를 봤다. "

act2 :: String -> IO ()
act2 who = putStrLn $ "나는 " ++ who ++ "와 밥을 먹었다. "

act3 :: String -> IO ()
act3 who = putStrLn $ "나는 " ++ who ++ "와 술을 먹었다. "

act who = do
  act1 who
  act2 who
  act3 who

이렇게 모든 함수들에 필요한 매개 변수를 만들면 되지만, 액션 종류가 많아지고 필요한 정보가 많아지면 꽤 성가진 모양이 될 겁니다.

IO 위에 Reader

이럴 때, 모나드 트랜스포머를 이용해서 다음처럼 구현할 수 있습니다. 아래는 좀 억지스러운 예시라 오히려 소스가 늘어났지만, 실용 프로그램에서는 대부분 줄어드는 효과도 있고, 무엇보다 유연하게 쓸 수 있다는 장점이 있습니다.

act1 :: ReaderT String IO ()
act1 = do
  who <- ask
  liftIO $ putStrLn $ "나는 " ++ who ++ "와 영화를 봤다. "

act2 :: ReaderT String IO ()
act2 = do
  who <- ask
  liftIO $ putStrLn $ "나는 " ++ who ++ "와 밥을 먹었다. "

act3 :: ReaderT String IO ()
act3 = do
  who <- ask
  liftIO $ putStrLn $ "나는 " ++ who ++ "와 술을 먹었다. "

act who = runReaderT ( do
    act1
    act2
    act3
  ) who  

actIO 모나드와 Reader 모나드의 특징을 모두 가진 모나드 액션이 됐습니다. 이렇게 두 개 이상의 모나드를 섞어서 써야 할 때 모나드 트랜스포머를 씁니다.

모나드의 특징은 바인드로 발현됩니다. IO 모나드와 Reader 모나드 특징 모두가 발현 되려면 두 모나드의 바인드가 모두 실행되어야 합니다.

newtype ReaderT r innerm a = ReaderT { runReaderT :: r -> innerm a }
instance (Monad m) => Monad (ReaderT r m) where
  return = lift . return
  rtm >>= k = ReaderT $ \r -> do --(1)
                       a <- runReaderT rtm r
                       runReaderT (k a) r

바인드만 살펴 보도록 하겠습니다.
바인드 정의를 보면 인스턴스 헤드에 있는 m이 보이지 않습니다. m은 (1) do구문에 가려져 있는 바인드의 인스턴스를 선택할 때 단서로 쓰입니다. 여기서 dom모나드의 do입니다.
실제 act1act2 를 바인딩 했을 때 무슨 일이 일어나는지 보겠습니다.

do를 걷어내서 바인드가 보이게 하면

act1 >>= \_ -> act2 =

위 바인드 정의에 그대로 넣으면

  ReaderT $ \r -> do
            a <- runReaderT act1 r ---(1)
            runReaderT ((\_ -> act2) a) r ---(2)

act1act2의 바인딩 결과는 r을 받는 함수를 ReaderT로 감싼 상태입니다.
여기에 act 함수에서처럼 runReaderTr을 넣어주면 그 때 실행됩니다.
(1)번과 (2)번 사이에는 mbind가 숨어 있습니다.

여기서는 r값으로 “친구” 넣어 보겠습니다.

runRederT ( ReaderT $ \r -> do
            a <- runReaderT act1 r
            runReaderT ((\_ -> act2) a) r
) "친구"

runReaderTReaderT 안에 있는 함수를 꺼내는 작업을 합니다.

\r -> do
    a <- runReaderT act1 r
    runReaderT ((\_ -> act2) a) r

이 함수에 “친구”를 넣으면,

a <- runReaderT act1 "친구" --- (1)
runReaderT ((\_ -> act2) a) "친구" ---(2)
  1. act1IO 작업 putStrLn " 나는 친구와 영화를 봤다. "준비하고, IO ()를 리턴하면,
    ※ 주의: 여기서 바로 실행되는게 아닙니다! 그래서 준비라고 표현했습니다.
    <-IO를 벗기면 a()가 됩니다.

  2. 안 쪽의 람다 함수(\_ -> act2)()를 넣어주면 act2 만 남고

runReaderT act2 "친구"

act2IO작업 putStrLn " 나는 친구와 밥을 먹었다. "준비하고, IO ()를 리턴합니다.

결론은 act1"친구"를 넣어 IO 액션을 만들고, 그 다음 act2"친구"를 넣어 IO 액션을 만들어서, 바인드의 결과는 IO 액션을 엮어 놓은 상태가 됐습니다.
IO 액션 엮음chaining은 GHCi에서 이 엮음에 realworld 환경값을 넣어주면서 실행하게 됩니다. (IO 모나드의 실행)
(이 품고 품게 엮은 모양을 뭐라 번역하면 좋을까요?)

ReaderT의 바인드를 보면 두 개의 바인드가 실행되는 게 보이나요?

아직은 그다지 좋은 설명이 아닌 것 같긴한데, 일단 포스팅해 놨습니다.
여기서 강조하고 싶은 건 모나드 트랜스포머의 바인드는 당연히 합쳐진 모든 모나드의 바인드를 실행한다 입니다.

mtl

Return Type Polymorphic

act1의 리턴 타입을 하드 코딩하지 않고, GHC에게 떠넘기는 방법이 있습니다. Reader와 IO가 섞여 있는 걸 ReaderT String IO () 같이 고정된 타입으로 만들지 않고, GHC가 알아서 타입을 결정하게 만들면 좀 더 유연하게 쓸 수 있습니다. 꼭 ReaderT String IO () 컨텍스트에서만 동작하는게 아니라, MonadReader 인스턴스이고, MonadIO 인스턴스이기만 하면, 어떤 컨텍스트에서건 불러다 쓸 수 있게 됩니다. 어떻게 이렇게 동작할 수 있는지 클래스 제약 포스트를 보면 도움이 됩니다.

{-# Language FlexibleContexts #-}

import Control.Monad.IO.Class ( MonadIO(..) )
import Control.Monad.Reader ( MonadReader, ReaderT, runReaderT, ask )

act1 :: (MonadReader String m, MonadIO m) => m ()
act1 = do
  who <- ask
  liftIO $ putStrLn $ "나는 " ++ who ++ "와 영화를 봤다. "

--act2 :: (MonadReader String m, MonadIO m) => m ()
act2 :: ReaderT String IO ()
act2 = do
  who <- ask
  liftIO $ putStrLn $ "나는 " ++ who ++ "와 밥을 먹었다. "

act3 :: (MonadReader String m, MonadIO m) => m ()
act3 = do
  who <- ask
  liftIO $ putStrLn $ "나는 " ++ who ++ "와 술을 먹었다. "

act :: String -> IO ()
act who = runReaderT ( do
    act1
    act2
    act3
  ) who 

main :: IO ()
main = do act "친구

act1, act2, act3 함수들을 모두 ReaderT 컨텍스트 안에서 사용 가능합니다. act1 ,act3act2는 결과 타입이 달라 보이지만, GHC가 나중에 추론해서 act1act3ReaderT String IO ()로 맞춰지게 됩니다. parameteric 다형성, ad-hoc 다형성처럼 결과 타입에 정해지지 않은 타입 m을 써주는 것에 대한 용어가 있을 것 같은데, 따로 이름은 찾지 못했고, 그냥 Return type polymorphic 이라 부르는 것 같습니다.

{-# Language FlexibleContexts #-}

import Control.Monad.IO.Class ( MonadIO(..) )
import Control.Monad.Reader ( MonadReader, ReaderT, runReaderT, ask )

-- act1 :: IO () 이렇게 타입을 고정하면 IO 컨텍스트에서만 act1을 부를 수 있습니다.
-- MonadIO m => 으로 지정하면 IO 컨텍스트에서 불러 쓸 수 있고, 
-- ReaderT Int IO 컨텍스트에서도 그대로 불러 쓸 수 있습니다.
act1 :: (MonadIO m) => m ()
act1 = do
  liftIO $ putStrLn $ "어떤 컨텍스트 안에서든 쓸 수 있어요." 

act2 :: (MonadReader Int m, MonadIO m) => m ()
act2 = do
    num <- ask
    liftIO $ print num

act3 :: (MonadReader Int m) => m ()
act3 = do
    num <- ask
    return ()

acts :: ReaderT Int IO ()
acts = do
    act1
    act2
    act3

main :: IO ()
main = do liftIO $ runReaderT acts 1

act1, act2, act3 모두 ReaderT로 추론되고,
act1의 조립 조건 (MonadIO m) => 을 만족하는 instance MonadIO ReaderT...가 있으니 OK
act2의 조립 조건 (MonadReader Int m, MonadIO m) => 을 만족하는 instance MonadReader ReaderT..., instance MonadIO ReaderT...가 있으니 OK
act3의 조립 조건 (MonadReader Int m) => 을 만족하는 instance MonadReader ReaderT...가 있으니 OK

미리 준비되어 있는 MonadReader의 인스턴스는 MaybeT, ListT, WriterT, StateT, IdentityT, ExceptT, ErrorT, ContT, ReaderT, RWST 용 인스턴스가 있습니다. acts의 리턴 타입(또는 컨텍스트라 말하기도 합니다.)이 ReaderT 타입이니, act2의 타입도 ReaderT 타입으로 추론하고, ReaderTask를 가져옵니다.
https://hackage.haskell.org/package/mtl-2.2.2/docs/Control-Monad-Reader.html#g:1

미리 준비되어 있는 MonadIO의 인스턴스는 MonadIO쪽에서 선언되어 있지 않고, ReaderTWriterT... 각각에서 볼 수 있습니다. https://hackage.haskell.org/package/mtl-2.2.2/docs/Control-Monad-Reader.html#g:3

main 을 다음과 같이 쓰면 ReaderT 타입은 보이지 않아 처음에는 다른 컨텍스트로 오해할 수도 있지만, runReaderT를 보고 알 수 있습니다.

main :: IO ()
main = liftIO $ runReaderT ( do
           act1
           act2
           act3 
       ) 

mtl 핵심 아이디어

모나드 트랜스포머를 3개 이상 겹치기 시작하면 상황이 복잡해지기 시작합니다. 예를 들어 ListT, ReaderT,LoggerT, IO 의 성격이 모두 들어 있는 컨텍스트 안에서 ask를 실행하려면 ReaderT가 몇 번째로 쌓여 있는지 알아야 실행할 수 있습니다. 모나드 트랜스포머는 모두 MonadTrans의 인스턴스여서 lift 메소드를 가지고 있습니다. lift 함수를 이용하면 모나드 스택에서 실행할 수 있습니다. 그런데 쌓아 올린 모양에 따라 lift . ask 로 실행해야 하는지, lift . lift . ask 로 실행해야 하는지 알아야 합니다.

※ 이렇게 몇 개의 모나드 트랜스포머로 쌓여 있는 걸 모나드 스택이라 부릅니다.

몇 층에 있든 알아서 해
class Monad m => MonadReader r m | m -> r where
    ask :: m r
    ask = reader id
    ...
    reader :: (r -> a) -> m a
    reader f = do
       r <- ask ------------ (1)
       return (f r)

(1)번 ask는 어떤 ask가 실행될까요? reader 메소드는 m a 컨텍스트에 있습니다. 모나드 m에 있는 ask를 실행할거라 예상할 수 있습니다. 만일 mListT이면

instance MonadReader r m => MonadReader r (ListT m) where
    ask--(1)   
       = lift ask ---------- (2)
    local = mapListT . local
    reader = lift . reader

MonadReader r (ListT m) 인스턴스에 있는 ask를 실행합니다. 그럼 (2)번은 어디에 있는 ask일까요? ask 선언에 따라 lift ask(ListT m) 타입이고, 그럼 ask는 리프팅 되어 (ListT m) 타입이니 m타입입니다. m 타입이 ReaderT 타입이라면

instance Monad m => MonadReader r (ReaderT r m) where
    ask--(2) 
       = ReaderT.ask --------- (3)
    local = ReaderT.local
    reader = ReaderT.reader

(3)번 ask를 실행하게 됩니다. 조금 복잡하지만 결론은 GHC가 알아서 ask구현을 찾아 갔습니다. 모나드 스택의 몇 번째 층에 ReaderT가 있든, GHC가 알아서 ask구현을 찾아 갈 수 있습니다. 이게 mtl의 핵심 아이디어입니다.

참고 - Has Type class 패턴 포스트

자주 만나는 에러 No instance

somefunc :: (MonadIO m, MonadReader c m) => ... -> m ()
...

main :: IO ()
main = somefunc ...

아래 에러가 납니다.

No instance for (MonadReader IO)
        arising from a use of ‘somefunc

somefunc에선 Return Type Polymorphic과 constraint를 이용해 타입 추론을 뒤로 미뤘습니다. 나중에 somefunc가 불릴 컨텍스트가 MonadIO, MonadReader 인스턴스인지 체크해야 합니다. 지금 코드는 IO 컨텍스트 안에서 불렀습니다. 그럼 instance MonadIO IO , instance MonadReader c IO 가 있어야 하는데, instance MonadReader IO 는 정의되어 있지 않다는 에러입니다. MonadReader 쪽을 보면 실제로 IO 인스턴스는 정의되어 있지 않습니다.

※ 그럼 MonadReader는 다른 트랜스포머용 인스턴스는 모두 정의해 놨는데, 왜 IO는 정의해놓지 않았을까요? MonadReaderask메소드를 계층에 상관없이 편리하게 쓰기 위해 정의한 클래스입니다. 어떤 트랜스포머로 쌓여 있든, 결국 ReaderT모나드의 ask등의 메소드를 찾아 갈 수 있게 만들어 놓은 겁니다. IO는 모나드 스택에서 항상 바닥에 있는 모나드이므로 IO까지 도달하면 더 진행할 수 가 없습니다. 만일 IOT같은 IO 트랜스포머가 있다면 얘기는 달라질 겁니다.

※ 그럼 MonadIO IO 인스턴스가 없다는 에러는 왜 안 났을까요?

instance MonadIO IO where
    liftIO = id

Monad IO 인스턴스는 정의 되어있습니다.

IO의 트랜스포머 IOT는 왜 없을까요?
http://h2.jaguarpaw.co.uk/posts/io-transformer/

에러 수정

다른 부분 수정없이 runReaderT로 환경값을 먹이면 컴파일 됩니다. 왜 그럴까요?

main = runReaderT somefunc ... 환경값 ...

runReaderTReaderT를 받으니, GHC는 somefuncReaderT타입일거라 추론합니다. GHC가 추적에 이용한 단서는 MonadReader 인스턴스이면 OK라 했습니다. ReaderTMonadReader의 인스턴스이니 문제가 없습니다. runReaderTReaderT를 벗겨내면 만나는 컨텍스트는 main :: IO () 이니, ReaderT 안에 들어 있는 모나드는 IO입니다. somefunc의 리턴 타입 m 추론이 끝났습니다. 타입이 모호하지 않게 타입 지정을 해 준게 아니라, 타입 추론이 막히지 않도록 단서를 추가해 준 거라 봐도 됩니다.

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com