모나드 스택에서 안 쪽에 있는 모나드에 제약 사항이 또 필요한 이유

Posted on August 21, 2021

모나드, 모나드 트랜스포머, 클래스 동작을 알고 있는 분들을 위한 글입니다.
여기서는 코드를 설명할 때, ReaderReaderT를 딱히 구분하지 않고 같은 것으로 봅니다. 둘의 관계는 모나드 트랜스포머 관련 글을 보시면 됩니다.

{-# 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 ()
func = do
    env1 <- asks env1
    print env1

언젠가는 IO컨텍스트에서 불릴거라 생각하고, 이렇게 쓰고 컴파일하면 IO를 찾는 에러가 납니다.

monadStack.hs:13:5: error:
Couldn't match typeIO’ with ‘SomeM Config m’
      Expected type: SomeM Config m ()
        Actual type: IO ()
        ...
13 |     print env1

printIO타입이지, SomeM 타입이 아닙니다. 그래서 SomeMMonadIO deriving을 해줬는데, 왜 IO를 못 찾는는다는 에러가 날까요?
MonadIO 클래스의 인스턴스가 IO 타입과 같은 게 아닙니다. MonadIO 클래스는 liftIO 메소드를 가지고 있다는 표시일 뿐입니다.

그럼, 언젠가 안 쪽에 IO층이 있을테니 liftIO로 끌어 올려다 쓰기 위해 liftIO를 추가합니다.

func = do
    env1 <- asks env1
    liftIO $ print env1
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 인스턴스여야 하는데 mMonad란 제약이 없다고 합니다. SomeM이 이미 deriving Monad 해 놨는데, 왜 안에 들어있는 mMonad 인스턴스여야 한다고 할까요? 두 번째 에러도 비슷합니다. liftIO를 쓰는 걸 봐서 MonadIO 인스턴스여야 하는데 mMonadIO란 제약이 없다고 합니다. SomeM을 이미 deriving MonadIO 해 놨는데 말입니다.

먼저, 모나드 스택을 이루는 트랜스포머들이 가진 bind의 동작을 살펴 보겠습니다.

func안에 보이지 않는 bind의 동작은 deriving Monad로 정의되어 있는데, Somebind, 안에 있는 ReaderTbind, 또 그 안에 있는 mbind가 모두 동작하게 되어 있는 bind입니다.

Q. SomeM 타입 정의에는 제일 안에 들어 있는 mMonad란 말은 없는데요?
A. ReaderT 정의에 m이 모나드여야 한다는 제약이 있습니다.

SomeMMonadReader에서 deriving 되어 있으니, asks가 있긴 있는데, SomeMasks가 하는 일은 다음 층의 asks를 부르는 동작만 합니다. 다음 층에서 ReaderTasks를 만나니 문제 없이 예상했던 asks의 동작을 합니다. 위에서도 보면 MonadReader 관련 에러는 안보입니다.

이 번엔 liftIO를 보겠습니다.
SomeMMonadIO에서 deriving 되어 있으니, liftIO가 있긴 있는데, SomeMliftIO가 하는 일은 다음 층의 liftIO를 부르는 동작만 합니다. 다음 층이 ReaderT인데, 하스켈이 ReaderTMonadIO 인스턴스1를 미리 준비 해놨습니다.

Haskell Package - Control.Monad.Trans.Reader

instance (MonadIO m) => MonadIO (ReaderT r m) where
    liftIO = lift . liftIO -- 별 다른 동작이 아니라, 다음 층으로 떠넘기기만 합니다.
    --              ^ 이 건 m을 받는 liftIO입니다.

인스턴스가 구현되어 있으니, SomeMReaderT에게 떠넘기고, ReaderTm에게 떠넘기게 됩니다. 바로 이 부분이 이 글의 핵심입니다. m이 뭔지 알 수 없습니다. 다른 말로 하면, m은 아무 제약이 없기 때문에 모든 타입입니다. 모든 타입이란 말은, 어떤 타입이 들어오든 문제없이 동작할 거란 뜻입니다. 하지만 코드에서는 m 타입을 인자로 받는 liftIO를 찾고 있습니다. 선언과 몸체가 서로 다른 말을 하고 있는 상황입니다. 선언은 모든 타입 vs 몸체는 liftIO가 있는 타입만 받는다는 상황입니다.
그래서 이 걸 해결하는 방법은, 나중에 liftIO를 가진 m이 들어올테니, 일단 넘어가라고 알려주기 위해 MonadIO m => 제약을 추가합니다. 가장 바깥 쪽 모나드가 MonadIO 인스턴스라 해도, 안 쪽에 있는 mMonadIO 인스턴스여야 한다는 제약이 필요한 이유입니다.

func :: (MonadIO m) => SomeM Config m ()
func = do
    env1 <- asks env1
    liftIO $ print env1

정리하면, asks를 쓰는 모나드 스택은 어느 층엔가는 반드시 Reader가 있어야 하고, Reader보다 상위층(또는 바깥층)에 있는 모든 모나드MonadReader의 인스턴스여야 합니다.(모두 asks 메소드를 가지고 있어야 합니다.) Reader모나드를 제외한 모든 모나드는 메소드 asks를 받았을 때, 자신이 직접 처리하는게 아니라 다음 층의 asks에게 떠넘기는 코드(MonadReader의 인스턴스)를 가지고 있어야 합니다.

Q. MonadReader가 ReaderT인가요?
A. 둘은 다른 겁니다. ReaderT는 타입이고, MonadReader는 클래스입니다. ReaderTasks를 가지고2 있고, MonadReaderasks를 가지고 있어야 한다는 제약을 나타낼 때 쓰입니다. 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 라이브러리입니다.


  1. 다른 트랜스포머들은 liftIO를 어떻게 정의해 놨는지 볼까요?

    instance (MonadIO m) => MonadIO (MaybeT m) where
        liftIO = lift . liftIO
    
    instance (MonadIO m) => MonadIO (StateT s m) where
        liftIO = lift . liftIO
    
    instance (MonadIO m) => MonadIO (WriterT w m) where
        liftIO = lift . liftIO
    ...

    예상대로 별 다른 모양은 없습니다. 그저 아래층으로 떠넘기기만 하면 되니 인스턴스들이 다를 게 없습니다.↩︎

  2. Reader 타입이 asks를 “가지고 있다”라 표현하는게 적합하지 않을 수 있습니다. OOP에서 오브젝트가 메소드를 가지고 있는 것과는 달리 asks는 그냥 보통의 함수입니다. 인자로 받는 타입이 Reader일 뿐입니다. OOP처럼 함수들을 강제로 묶어서 하나의 개체로 처리하는 방법은 없습니다. 레코드 문법Record Syntax이 마치 함수를 묶어 하나로 처리하는 것처럼 보이나, 레코드 문법에서도 각 필드에 접근하기 위한 접근자들은 그냥 함수일 뿐입니다. 그러고 보면, 함수형에서 함수는 다른 패러다임의 함수보다는 stand alone 성격이 강합니다.↩︎

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