모나드, 모나드 트랜스포머, 클래스 동작을 알고 있는 분들을 위한 글입니다.
여기서는 코드를 설명할 때, Reader
와 ReaderT
를 딱히 구분하지 않고 같은 것으로 봅니다. 둘의 관계는 모나드 트랜스포머 관련 글을 보시면 됩니다.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad.Reader
data Config = Config { env1 :: Int }
newtype SomeM conf m a = SomeM { runSomeM :: ReaderT conf m a }
deriving (Functor, Applicative, Monad, MonadIO, MonadReader conf)
func :: SomeM Config m ()
= do
func <- asks env1
env1 print env1
언젠가는 IO
컨텍스트에서 불릴거라 생각하고, 이렇게 쓰고 컴파일하면 IO
를 찾는 에러가 납니다.
.hs:13:5: error:
monadStackCouldn't match type ‘IO’ with ‘SomeM Config m’
• Expected type: SomeM Config m ()
Actual type: IO ()
...
13 | print env1
print
는 IO
타입이지, SomeM
타입이 아닙니다. 그래서 SomeM
을 MonadIO deriving
을 해줬는데, 왜 IO
를 못 찾는는다는 에러가 날까요?
MonadIO
클래스의 인스턴스가 IO
타입과 같은 게 아닙니다. MonadIO
클래스는 liftIO
메소드를 가지고 있다는 표시일 뿐입니다.
그럼, 언젠가 안 쪽에 IO
층이 있을테니 liftIO
로 끌어 올려다 쓰기 위해 liftIO
를 추가합니다.
= do
func <- asks env1
env1 $ print env1 liftIO
No instance for (Monad m) arising from a do statement
• Possible fix: add (Monad m) to the context of ...
12 | env1 <- asks env1
No instance for (MonadIO m) arising from a use of ‘liftIO’
• Possible fix: add (MonadIO m) to the context of ...
13 | liftIO $ print env1
일단 IO 타입이 맞지 않는 건 해결되어 넘어갔는데, 이 번엔 do
를 쓰는 걸 보니(do
에는 >>=
가 숨어 있습니다.) Monad
인스턴스여야 하는데 m
이 Monad
란 제약이 없다고 합니다. SomeM
이 이미 deriving Monad
해 놨는데, 왜 안에 들어있는 m
이 Monad
인스턴스여야 한다고 할까요? 두 번째 에러도 비슷합니다. liftIO
를 쓰는 걸 봐서 MonadIO
인스턴스여야 하는데 m
이 MonadIO
란 제약이 없다고 합니다. SomeM
을 이미 deriving MonadIO
해 놨는데 말입니다.
먼저, 모나드 스택을 이루는 트랜스포머들이 가진 bind의 동작을 살펴 보겠습니다.
func
안에 보이지 않는 bind
의 동작은 deriving Monad
로 정의되어 있는데, Some
의 bind
, 안에 있는 ReaderT
의 bind
, 또 그 안에 있는 m
의 bind
가 모두 동작하게 되어 있는 bind
입니다.
Q.
SomeM
타입 정의에는 제일 안에 들어 있는m
이Monad
란 말은 없는데요?
A.ReaderT
정의에m
이 모나드여야 한다는 제약이 있습니다.
SomeM
이 MonadReader
에서 deriving
되어 있으니, asks
가 있긴 있는데, SomeM
의 asks
가 하는 일은 다음 층의 asks
를 부르는 동작만 합니다. 다음 층에서 ReaderT
의 asks
를 만나니 문제 없이 예상했던 asks
의 동작을 합니다. 위에서도 보면 MonadReader 관련 에러는 안보입니다.
이 번엔 liftIO
를 보겠습니다.
SomeM
이 MonadIO
에서 deriving
되어 있으니, liftIO
가 있긴 있는데, SomeM
의 liftIO
가 하는 일은 다음 층의 liftIO
를 부르는 동작만 합니다. 다음 층이 ReaderT
인데, 하스켈이 ReaderT
의 MonadIO
인스턴스1를 미리 준비 해놨습니다.
Haskell Package - Control.Monad.Trans.Reader
instance (MonadIO m) => MonadIO (ReaderT r m) where
= lift . liftIO -- 별 다른 동작이 아니라, 다음 층으로 떠넘기기만 합니다.
liftIO -- ^ 이 건 m을 받는 liftIO입니다.
인스턴스가 구현되어 있으니, SomeM
이 ReaderT
에게 떠넘기고, ReaderT
가 m
에게 떠넘기게 됩니다. 바로 이 부분이 이 글의 핵심입니다. m
이 뭔지 알 수 없습니다. 다른 말로 하면, m
은 아무 제약이 없기 때문에 모든 타입입니다. 모든 타입이란 말은, 어떤 타입이 들어오든 문제없이 동작할 거란 뜻입니다. 하지만 코드에서는 m
타입을 인자로 받는 liftIO
를 찾고 있습니다. 선언과 몸체가 서로 다른 말을 하고 있는 상황입니다. 선언은 모든 타입 vs 몸체는 liftIO가 있는 타입만 받는다는 상황입니다.
그래서 이 걸 해결하는 방법은, 나중에 liftIO를 가진 m이 들어올테니, 일단 넘어가라고 알려주기 위해 MonadIO m =>
제약을 추가합니다. 가장 바깥 쪽 모나드가 MonadIO
인스턴스라 해도, 안 쪽에 있는 m
도 MonadIO
인스턴스여야 한다는 제약이 필요한 이유입니다.
func :: (MonadIO m) => SomeM Config m ()
= do
func <- asks env1
env1 $ print env1 liftIO
정리하면, asks
를 쓰는 모나드 스택은 어느 층엔가는 반드시 Reader
가 있어야 하고, Reader
보다 상위층(또는 바깥층)에 있는 모든 모나드는 MonadReader
의 인스턴스여야 합니다.(모두 asks
메소드를 가지고 있어야 합니다.) Reader
모나드를 제외한 모든 모나드는 메소드 asks
를 받았을 때, 자신이 직접 처리하는게 아니라 다음 층의 asks
에게 떠넘기는 코드(MonadReader
의 인스턴스)를 가지고 있어야 합니다.
Q. MonadReader가 ReaderT인가요?
A. 둘은 다른 겁니다.ReaderT
는 타입이고,MonadReader
는 클래스입니다.ReaderT
는asks
를 가지고2 있고,MonadReader
는asks
를 가지고 있어야 한다는 제약을 나타낼 때 쓰입니다.MonadReader
는 없어도,ReaderT
만으로 모나드 스택은 만들 수 있습니다. 이 경우 각 층에 참여한 모나드들이 모두asks
를 알고 있지 않기 때문에asks
를 가진Reader
모나드 층에 도달할 수 있게끔 명시적으로lift
를 써주면 됩니다.lift . lift . lift . ask
이런 식으로 몇 번째 층에서asks
를 처리할 수 있는지 정확한 횟수의lift
를 써서 알려주면 됩니다. 하지만 모든 층이MonadReader
의 인스턴스여서asks
메소드가 모두 구현되어 있다면 어떻게 될까요? 모든 층에 있는asks
가 모두asks
자체 동작을 하는 게 아니라, “나는 모르지만, 다음 층으로 떠 넘겨 줄게(mtl라이브러리 핵심 아이디어)” 라는 동작을 하는asks
가 있다면,lift
를 굳이 써주지 않고 그냥asks
만 써도, 알아서ReaderT
층까지 찾아가는 동작을 하게 됩니다.MonadReader
(mtl은)는ReaderT
(모나드 트랜스포머 스택)를 편하게 쓰기 위한 보조helper 라이브러리입니다.
다른 트랜스포머들은 liftIO를 어떻게 정의해 놨는지 볼까요?
instance (MonadIO m) => MonadIO (MaybeT m) where
= lift . liftIO
liftIO
instance (MonadIO m) => MonadIO (StateT s m) where
= lift . liftIO
liftIO
instance (MonadIO m) => MonadIO (WriterT w m) where
= lift . liftIO
liftIO ...
예상대로 별 다른 모양은 없습니다. 그저 아래층으로 떠넘기기만 하면 되니 인스턴스들이 다를 게 없습니다.↩︎
Reader
타입이 asks
를 “가지고 있다”라 표현하는게 적합하지 않을 수 있습니다. OOP에서 오브젝트가 메소드를 가지고 있는 것과는 달리 asks
는 그냥 보통의 함수입니다. 인자로 받는 타입이 Reader
일 뿐입니다. OOP처럼 함수들을 강제로 묶어서 하나의 개체로 처리하는 방법은 없습니다. 레코드 문법Record Syntax이 마치 함수를 묶어 하나로 처리하는 것처럼 보이나, 레코드 문법에서도 각 필드에 접근하기 위한 접근자들은 그냥 함수일 뿐입니다. 그러고 보면, 함수형에서 함수는 다른 패러다임의 함수보다는 stand alone 성격이 강합니다.↩︎