타입 추론할 때, 살짝 숨어 있는 제약constraint사항들 주의하기

Posted on April 23, 2021

타입 결정을 거의 GHC에 떠맡긴 코드입니다.

...
class (MonadIO m) => HasConsole m where
    getConsoleCenterChan :: m MessageChan
    
    sendToAllConsoles :: Message -> m ()
    sendToAllConsoles msg = do -- 여기서 IO 작업을 하니, 나중에 m으로 들어올 타입은
                               -- MonadIO 인스턴스여야 합니다.
        ch <- getConsoleCenterChan
        liftIO $ atomically $ writeTChan ch msg
...

콘솔에 메시지를 보내는 쓰레드들은 HasConsole 클래스의 인스턴로 만들도록 했습니다.
아래 TrackerMHasConsole의 인스턴스로 만들었습니다.

...
newtype TrackerM m a = TrackerM { runTrackerM :: ReaderT TrackerConfig (LoggingT m) a }
    deriving ( Functor, Applicative, Monad, MonadIO , MonadReader TrackerConfig ) ----------------(A)

runTracker :: (MonadIO m) => TrackerConfig -> TrackerM m a -> m a
runTracker config tm = runStdoutLoggingT $ runReaderT (runTrackerM tm) config
-- TrackerM을 벗기고, ReaderT를 벗기고, LoggingT를 벗기면 m만 남습니다. 
-- m이 뭔지는 정해져 있지 않고, MonadIO인스턴스면 OK입니다.

instance (MonadIO m) => HasConsole (TrackerM m) where ----------------------(B)
  getConsoleCenterChan = asks tcConsoleCenterCha 
  -- TrackerM을 HasConsole의 인스턴스로 만들었기 때문에
  -- TrackerM 컨텍스트에서 sendToAllConsole을 실행할 수 있습니다.

instance (MonadIO m, MonadLogger m) => MonadLogger (TrackerM m) where ------------------(C)
  monadLoggerLog a b c d = TrackerM $ Trans.lift $ do ...


startTracker :: (MonadIO m, MonadLogger m, MonadReader TrackerConfig m, HasConsole m) => m ()
startTracker = do
  raw  <- liftIO $ BS.readFile "example.data"
  logInfoN $ convertString $ show $ ...
  ...
  sendToAllConsoles ...
  ...

main :: IO ()
main = do
    ...
    withAsync (runTracker trackerCfg startTracker)
--                                   ^^^^^^^^^^^^
--                                    컴파일 에러

컴파일 하면 아래 에러가 납니다. MonadLogger 클래스는 IO 인스턴스를 가지고 있지 않다는 에러입니다.
MonadLogger IO를 찾게 됐을까요?

No instance for (Control.Monad.Logger.MonadLogger IO)
        arising from a use of ‘startTracker’
In the second argument of ‘runTracker’, namely ‘startTracker’
      In the first argument of ‘withAsync’, namely
        ‘(runTracker trackerCfg startTracker)’
  1. startTrackerrunTracker의 인자로 들어가니, TrackerM m 타입으로 추론,
  2. runTracker로 벗겨지면 만날 컨텍스트가 main :: IO ()이니 TrackerM IO로 추론,
  3. 최종 startTrackerTrackerM IO () 타입으로 추론됩니다.

startTracker를 선언할 때, 나중에 들어오는 타입이 따라야만 하는 Constraint를 적어두었습니다. 그럼, 추론된 타입이 startTracker의 제약사항을 만족하는지 봅니다.

MonadIO (TrackerM IO), -- (A) deriving MonadIO이 있으니 통과
MonadLogger (Tracker IO), -- (C) instance MonadLogger (Tracker M)이 있으니 통과
MonadReader TrackerConfig (TrackerM IO), -- (A) deriving에 MonadReader TrackConfig 있으니 통과
HasConsole (TrackerM IO) -- (B) instance HasConsole (Tracker M) 있으니 통과

위 제약 사항들을 만족하는지 GHC가 확인하게 됩니다. 다 문제 없이 통과했는데, 느닷없이 왜 MonadLogger (Tracker IO)로 추론해서 통과했는데 MonadLogger IO를 찾을까요?

원인은 instance MonadLogger 부분의 제약사항에 있습니다.

instance (MonadIO m, MonadLogger m) => MonadLogger (TrackerM m) where

TrackerM에 쌓여 있는 mMonadLogger 인스턴스여야 한다는 굳이 필요 없는 제약 조건이 들어가 있었습니다.
startTracker를 사용use하면서 TrackerM IO로 추론한 후, MonadLogger (Tracker m) 인스턴스를 살펴보니 TrackerM에 쌓여 있는 m, 즉 여기선 IO(MonadLogger m)=> 때문에 MonadLooger의 인스턴스여야 한다는 결론에 도달합니다.

GHC는 타입이 정해지지 않은 소문자가 보이면

  1. 함수에 인자로 넘어 가면, 함수의 인자 타입으로 추론합니다.
  2. 또는 어떤 컨텍스트에서 불리고 있다면, 해당 컨텍스트로 추론합니다.
  3. 또는 어떤 클래스에 있는 메소드를 쓰면, 클래스 제약을 추론합니다.
  4. 그런 다음, 추론한 타입이 a의 제약사항에 있는 조건들을 만족하는 타입인지 확인합니다.
Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com