Has type class 패턴, mtl 스타일

Posted on July 21, 2020

이 글을 읽으시려면 Monad Transformer를 먼저 알고 있어야 합니다.
참고 - 모나드 트랜스포머 포스트

클래스 + 인스턴스 + constraint, Has type class 패턴

클래스와 인스턴스를 사용하면 선언과 구현을 분리 할 수 있습니다. 먼저 선언해서 사용하고, 실제 구현체는 나중에 정해 줄 수 있습니다.

newtype Player = Player { name :: String }    
newtype Villain = Villain { nickname :: String }    
    
showNameDirectParam :: Player -> IO ()    
showNameDirectParam p = putStrLn $ "Player : " ++ (name p)    

함수에서 Player 타입을 다이렉트로 받으면, 이 함수가 Player 타입에 종속됐다고 말합니다.
이렇게 직접적으로 타입을 넘기지 않고, 클래스와 constraint를 이용하면

class HasName a where    
  getName :: a -> String    
    
showName :: HasName a => a -> IO ()    
showName x = putStrLn $ getName x    

일단 showName 내에서는 getName으로 name값을 가져올거라 전제하고, 코드에서 마음대로 활용하고, 실제 구현체는 나중에 인스턴스를 통해 만들어 주면 됩니다.

instance HasName Player where    
  getName x = "Player : " ++ name x    
    
instance HasName Villain where    
  getName x = "Villain : " ++ nickname x    

이렇게 클래스를 활용하는 걸 Has type class 패턴이라 부릅니다.

정리하면, Player -> IO ()Player 타입만 받아 들이지만,
HasName a => a 는 a가 뭐든간에 HasName 인스턴스이기만 하면 받아 들일 수 있습니다.

여기서 쓰인 아이디어를 모나드 트랜스포머와 붙여서 사용할 수 있습니다.

mtl 라이브러리 Monad Transformer Library

newtype AppM a = ReaderT Env (LoggingT IO) a

이렇게 몇 겹으로 쌓은 모나드 트랜스포머를 쓸 때, 특정 층에 있는 모나드의 메소드를 쓴다고 생각해 봅시다. ReaderT가 어느 층에 있는지, LoggingT가 어느층에 있는지 알아야 합니다. lift를 몇 번을 써서 해당 층에 도달할지 다 기억한다는 건 매우 비효율적입니다. 이럴 때를 위해 mtl이 필요합니다.

모나드 트랜스포머를 여러겹 쌓은 걸 모나드 트랜스포머 스택이라고 합니다. 소스상 모양은 품고 품은 모양인데, 이 바닥 사람들은 수직으로 쌓아 올린 메타포를 즐겨 사용하나 봅니다. AppM 의 경우 가장 아래 IO, 그 위에 LoggingT, 그 위에 ReaderT를 쌓아 올린 스택으로 바라봅니다. 그래서 IO모나드에 있는 액션을 AppM층에서 사용하려면, 끌어 올린다lift는 표현을 씁니다.

-- m a 액션을 t m a 액션으로 끌어 올려 줍니다.
class MonadTrans t where
  lift :: Monad m => m a -> t m a

많이 쓰이는 대부분의 트랜스포머(ListT, MaybeT, ExceptT, ReaderT …)들은 모두 MonadTrans의 인스턴스로 라이브러리에 미리 정의되어 있습니다.
Control.Monad.Trans.Class

트랜스포머 안에 들어있는 모나드의 액션을 실행하려면 lift를 써서 트랜스포머층으로 끌어 올립니다.

mtl에는, Monad Transformer만 있는게 아니라, Monad Transformer와 같이 쓸 라이브러리들(MonadReader, MonadState, MonadIO, …)도 같이 있습니다.

그 중 MonadReader를 살펴 보겠습니다.

class Monad m => MonadReader r m | m -> r where
    ask   :: m r
    ask = reader id

| m -> r functional dependency 문법은 각주 참고 1

Has클래스에서 봤던 아이디어처럼, 한 가지 타입만 ask를 쓸 수 있는게 아니라, MonadReader r m => ... constraint를 만족하는 여러 타입들도 ask를 쓸 수 있게 됩니다.

혹시 MonadReader의 ask 정의가 궁금한 적 없나요?

Control.Monad.Trans.Reader #ask
여기 ask는 클래스의 메소드로 정의되어 있는 게 아닙니다.
Control.Monad.Trans.Reader 모듈의 일반 함수입니다.

ask :: (Monad m) => ReaderT r m r ----- (1)
ask = ReaderT return

ReaderT 액션이므로 runReaderTreturn을 벗기고 r을 넣어주면
return r 이되고
보통 env <- ask 를 쓰니 <-return으로 씌운 생성자가 벗겨지면 renv로 들어갑니다.
하지만 이 ask는 ReaderT 컨텍스트에서만 사용할 수 있습니다.

MonadReader 클래스는 ask 메소드를 가지고 있습니다.

class Monad m => MonadReader r m | m -> r where
    ask   :: m r ------- (2)
    ask = reader id

그리고, 많이 쓰이는 트랜스포머들의 인스턴스를 미리 정의해 두었습니다.

ReaderT와 어떻게 어울려지나 볼까요?

import qualified Control.Monad.Trans.Reader as ReaderT (ask, local, reader)
...
instance Monad m => MonadReader r (ReaderT r m) where
    ask = ReaderT.ask

오른 쪽의 ask는 위의 (1)번 ReaderT의 ask입니다.

다른 인스턴스를 보면

instance MonadReader r m => MonadReader r (MaybeT m) where
    ask   = lift ask

여기서 오른쪽 ask는 어떤 ask일까요? mask입니다.

m이 구체적으로 뭔지 몰라도 MonadReader r m 인스턴스가 있다는 것만 압니다.

함수를 ReaderT 타입을 직접적으로 받게 하지 않고, 다음처럼 constraint를 활용하면

-- m이 모나드인지는 어떻게 알까요? MonadReader 클래스 서명을 보면 Monad m => 이 있습니다.
someFunc :: MonadReader r m => Int -> m
someFunc n = do
                ask
                n ...

이 함수는 딱 ReaderT만 받는게 아니라, 어떤 모나드든 MonadReader2 인스턴스이기만 하면 다 받을 수 있습니다. 그런데, 실용 프로그램에서 자주 만나게 되는 모나드 트랜스포머용 인스턴스를 미리 선언해 뒀기 때문에, 대부분의 경우 모나드 트랜스포머 스택 어느 층에든 ReaderT만 있으면 받을 수 있는 함수가 됩니다. 이 부분은 모나드 트랜스포머 포스트에 자세히 적어 두었습니다.


  1. functional dependencies
    짧게 동작만 설명하면, 매개 변수가 m, r 두 개지만, m이 정해지면 그에 따라 r도 정해진다는 문법입니다. 타입 추론을 거들기 위한 문법입니다.
    필요한 이유 - Why is FunctionalDependency needed for defining MonadReader? - stackoverflow↩︎

  2. Control.Monad.Reader.Class #MonadReader↩︎

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