이 글을 읽으시려면 IO
모나드와 Reader
모나드의 동작을 알고 있어야 합니다.
라이브러리를 제외하고, IO
가 없는 프로그램은 없습니다. 데이터를 받고, 처리를 하고, 출력을 해주는게 프로그램입니다. 예를 들어, 소수를 구하는 프로그램은 입력Input도 없고, 출력Ouput도 없는 것 아니냐라고 생각할 수도 있습니다. 입력은 없지만, 구한 소수를 눈에 보이지 않게 하면 의미가 없으니 결국 출력이 필요합니다. 소수를 구하는 함수 자체에는 출력이 없지만, 나중에 GHCi 등에서 함수를 부르면 결과값을 출력해주는 작업을 GHCi가 대신하게 됩니다. 어찌 됐든 결론은, 언젠가 IO
는 반드시 필요하다는 얘기입니다.
IO
모나드를 반드시 써야 하고, 모든 IO
액션들이 설정값을 필요로 하는 경우를 생각해 봅시다. 이미 IO
로 체이닝한 상태인데 Reader
모나드를 쓰고 싶을 때는 어떻게 할까요? 이미 IO
액션이 (..(..(..())))
이렇게 엮여 있는데, 액션 하나가 실행 할 때마다 Reader
속성이 발현되도록 다시 엮어야 합니다.
act1 :: IO ()
= putStrLn " 나는 OO과 영화를 봤다. "
act1
act2 :: IO ()
= putStrLn " 나는 OO과 밥을 먹었다. "
act2
act3 :: IO ()
= putStrLn " 나는 OO과 술을 먹었다. "
act3
= do
act
act1
act2 act3
OO에 입력 받은 이름을 넣고 싶으면
act1 :: String -> IO ()
= putStrLn $ "나는 " ++ who ++ "와 영화를 봤다. "
act1 who
act2 :: String -> IO ()
= putStrLn $ "나는 " ++ who ++ "와 밥을 먹었다. "
act2 who
act3 :: String -> IO ()
= putStrLn $ "나는 " ++ who ++ "와 술을 먹었다. "
act3 who
= do
act who
act1 who
act2 who act3 who
이렇게 모든 함수들에 필요한 매개 변수를 만들면 되지만, 액션 종류가 많아지고 필요한 정보가 많아지면 꽤 성가진 모양이 될 겁니다.
이럴 때, 모나드 트랜스포머를 이용해서 다음처럼 구현할 수 있습니다. 아래는 좀 억지스러운 예시라 오히려 소스가 늘어났지만, 실용 프로그램에서는 대부분 줄어드는 효과도 있고, 무엇보다 유연하게 쓸 수 있다는 장점이 있습니다.
act1 :: ReaderT String IO ()
= do
act1 <- ask
who $ putStrLn $ "나는 " ++ who ++ "와 영화를 봤다. "
liftIO
act2 :: ReaderT String IO ()
= do
act2 <- ask
who $ putStrLn $ "나는 " ++ who ++ "와 밥을 먹었다. "
liftIO
act3 :: ReaderT String IO ()
= do
act3 <- ask
who $ putStrLn $ "나는 " ++ who ++ "와 술을 먹었다. "
liftIO
= runReaderT ( do
act who
act1
act2
act3 ) who
act
는 IO
모나드와 Reader
모나드의 특징을 모두 가진 모나드 액션이 됐습니다. 이렇게 두 개 이상의 모나드를 섞어서 써야 할 때 모나드 트랜스포머를 씁니다.
모나드의 특징은 바인드로 발현됩니다. IO
모나드와 Reader
모나드 특징 모두가 발현 되려면 두 모나드의 바인드가 모두 실행되어야 합니다.
newtype ReaderT r innerm a = ReaderT { runReaderT :: r -> innerm a }
instance (Monad m) => Monad (ReaderT r m) where
return = lift . return
>>= k = ReaderT $ \r -> do --(1)
rtm <- runReaderT rtm r
a runReaderT (k a) r
바인드만 살펴 보도록 하겠습니다.
바인드 정의를 보면 인스턴스 헤드에 있는 m
이 보이지 않습니다. m
은 (1) do
구문에 가려져 있는 바인드의 인스턴스를 선택할 때 단서로 쓰입니다. 여기서 do
는 m
모나드의 do
입니다.
실제 act1
과 act2
를 바인딩 했을 때 무슨 일이 일어나는지 보겠습니다.
do
를 걷어내서 바인드가 보이게 하면
>>= \_ -> act2 = act1
위 바인드 정의에 그대로 넣으면
ReaderT $ \r -> do
<- runReaderT act1 r ---(1)
a -> act2) a) r ---(2) runReaderT ((\_
act1
과 act2
의 바인딩 결과는 r
을 받는 함수를 ReaderT
로 감싼 상태입니다.
여기에 act
함수에서처럼 runReaderT
로 r
을 넣어주면 그 때 실행됩니다.
(1)번과 (2)번 사이에는 m
의 bind
가 숨어 있습니다.
여기서는 r
값으로 “친구” 넣어 보겠습니다.
ReaderT $ \r -> do
runRederT ( <- runReaderT act1 r
a -> act2) a) r
runReaderT ((\_ "친구" )
runReaderT
는 ReaderT
안에 있는 함수를 꺼내는 작업을 합니다.
-> do
\r <- runReaderT act1 r
a -> act2) a) r runReaderT ((\_
이 함수에 “친구”를 넣으면,
<- runReaderT act1 "친구" --- (1)
a -> act2) a) "친구" ---(2) runReaderT ((\_
act1
은 IO
작업 putStrLn " 나는 친구와 영화를 봤다. "
를 준비하고, IO ()
를 리턴하면,
※ 주의: 여기서 바로 실행되는게 아닙니다! 그래서 준비라고 표현했습니다.
<-
로 IO
를 벗기면 a
는 ()
가 됩니다.
안 쪽의 람다 함수(\_ -> act2)
에 ()
를 넣어주면 act2
만 남고
"친구" runReaderT act2
act2
는 IO
작업 putStrLn " 나는 친구와 밥을 먹었다. "
를 준비하고, IO ()
를 리턴합니다.
결론은 act1
에 "친구"
를 넣어 IO
액션을 만들고,
그 다음 act2
에 "친구"
를 넣어 IO
액션을 만들어서,
바인드의 결과는 IO
액션을 엮어 놓은 상태가 됐습니다.
IO
액션 엮음chaining은 GHCi에서 이 엮음에 realworld 환경값을 넣어주면서 실행하게 됩니다. (IO
모나드의 실행)
(이 품고 품게 엮은 모양을 뭐라 번역하면 좋을까요?)
ReaderT
의 바인드를 보면 두 개의 바인드가 실행되는 게 보이나요?
아직은 그다지 좋은 설명이 아닌 것 같긴한데, 일단 포스팅해 놨습니다.
여기서 강조하고 싶은 건 모나드 트랜스포머의 바인드는 당연히 합쳐진 모든 모나드의 바인드를 실행한다 입니다.
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 ()
= do
act1 <- ask
who $ putStrLn $ "나는 " ++ who ++ "와 영화를 봤다. "
liftIO
--act2 :: (MonadReader String m, MonadIO m) => m ()
act2 :: ReaderT String IO ()
= do
act2 <- ask
who $ putStrLn $ "나는 " ++ who ++ "와 밥을 먹었다. "
liftIO
act3 :: (MonadReader String m, MonadIO m) => m ()
= do
act3 <- ask
who $ putStrLn $ "나는 " ++ who ++ "와 술을 먹었다. "
liftIO
act :: String -> IO ()
= runReaderT ( do
act who
act1
act2
act3
) who
main :: IO ()
= do act "친구 main
act1
, act2
, act3
함수들을 모두 ReaderT
컨텍스트 안에서 사용 가능합니다. act1
,act3
과 act2
는 결과 타입이 달라 보이지만, GHC가 나중에 추론해서 act1
도 act3
도 ReaderT 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 ()
= do
act1 $ putStrLn $ "어떤 컨텍스트 안에서든 쓸 수 있어요."
liftIO
act2 :: (MonadReader Int m, MonadIO m) => m ()
= do
act2 <- ask
num $ print num
liftIO
act3 :: (MonadReader Int m) => m ()
= do
act3 <- ask
num return ()
acts :: ReaderT Int IO ()
= do
acts
act1
act2
act3
main :: IO ()
= do liftIO $ runReaderT acts 1 main
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
타입으로 추론하고, ReaderT
용 ask
를 가져옵니다.
https://hackage.haskell.org/package/mtl-2.2.2/docs/Control-Monad-Reader.html#g:1
미리 준비되어 있는 MonadIO
의 인스턴스는 MonadIO
쪽에서 선언되어 있지 않고, ReaderT
나 WriterT...
각각에서 볼 수 있습니다.
https://hackage.haskell.org/package/mtl-2.2.2/docs/Control-Monad-Reader.html#g:3
main
을 다음과 같이 쓰면 ReaderT
타입은 보이지 않아 처음에는 다른 컨텍스트로 오해할 수도 있지만, runReaderT
를 보고 알 수 있습니다.
main :: IO ()
= liftIO $ runReaderT ( do
main
act1
act2
act3 )
모나드 트랜스포머를 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
= reader id
ask ...
reader :: (r -> a) -> m a
= do
reader f <- ask ------------ (1)
r return (f r)
(1)번 ask
는 어떤 ask
가 실행될까요? reader
메소드는 m a
컨텍스트에 있습니다. 모나드 m
에 있는 ask
를 실행할거라 예상할 수 있습니다. 만일 m
이 ListT
이면
instance MonadReader r m => MonadReader r (ListT m) where
--(1)
ask= lift ask ---------- (2)
= mapListT . local
local = lift . reader 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
--(2)
ask= ReaderT.ask --------- (3)
= ReaderT.local
local = ReaderT.reader reader
(3)번 ask
를 실행하게 됩니다. 조금 복잡하지만 결론은 GHC가 알아서 ask구현을 찾아 갔습니다. 모나드 스택의 몇 번째 층에 ReaderT
가 있든, GHC가 알아서 ask
구현을 찾아 갈 수 있습니다. 이게 mtl의 핵심 아이디어입니다.
somefunc :: (MonadIO m, MonadReader c m) => ... -> m ()
...
main :: IO ()
= somefunc ... main
아래 에러가 납니다.
No instance for (MonadReader IO)
• of ‘somefunc arising from a use
somefunc
에선 Return Type Polymorphic과 constraint를 이용해 타입 추론을 뒤로 미뤘습니다. 나중에 somefunc
가 불릴 컨텍스트가 MonadIO
, MonadReader
인스턴스인지 체크해야 합니다. 지금 코드는 IO
컨텍스트 안에서 불렀습니다. 그럼 instance MonadIO IO
, instance MonadReader c IO
가 있어야 하는데, instance MonadReader IO
는 정의되어 있지 않다는 에러입니다. MonadReader
쪽을 보면 실제로 IO
인스턴스는 정의되어 있지 않습니다.
※ 그럼 MonadReader
는 다른 트랜스포머용 인스턴스는 모두 정의해 놨는데, 왜 IO
는 정의해놓지 않았을까요? MonadReader
는 ask
메소드를 계층에 상관없이 편리하게 쓰기 위해 정의한 클래스입니다. 어떤 트랜스포머로 쌓여 있든, 결국 ReaderT모나드의 ask등의 메소드를 찾아 갈 수 있게 만들어 놓은 겁니다. IO
는 모나드 스택에서 항상 바닥에 있는 모나드이므로 IO
까지 도달하면 더 진행할 수 가 없습니다. 만일 IOT
같은 IO
트랜스포머가 있다면 얘기는 달라질 겁니다.
※ 그럼 MonadIO IO
인스턴스가 없다는 에러는 왜 안 났을까요?
instance MonadIO IO where
= id liftIO
Monad IO
인스턴스는 정의 되어있습니다.
※ IO
의 트랜스포머 IOT
는 왜 없을까요?
http://h2.jaguarpaw.co.uk/posts/io-transformer/
다른 부분 수정없이 runReaderT
로 환경값을 먹이면 컴파일 됩니다. 왜 그럴까요?
= runReaderT somefunc ... 환경값 ... main
runReaderT
는 ReaderT
를 받으니, GHC는 somefunc
가 ReaderT
타입일거라 추론합니다. GHC가 추적에 이용한 단서는 MonadReader
인스턴스이면 OK라 했습니다. ReaderT
는 MonadReader
의 인스턴스이니 문제가 없습니다. runReaderT
로 ReaderT
를 벗겨내면 만나는 컨텍스트는 main :: IO ()
이니, ReaderT
안에 들어 있는 모나드는 IO
입니다. somefunc
의 리턴 타입 m
추론이 끝났습니다. 타입이 모호하지 않게 타입 지정을 해 준게 아니라, 타입 추론이 막히지 않도록 단서를 추가해 준 거라 봐도 됩니다.