UnliftIO

Posted on November 13, 2022

모나드 스택에서 IO 작업들을 불러다 쓸 때, MonadTrans의 liftIO를 이용합니다. 그런데, IO가 contravariant (negative) 위치에 있을 때는 liftIO로 해결이 안됩니다. 이럴 때 리프팅 작업을 도와주는 클래스입니다.

unliftio: The MonadUnliftIO typeclass for unlifting monads to IO (batteries included)

생각 스트레칭

(고)고차 함수 읽기

C언어에서 포인터의 포인터를 업자들은 포포인터라 부르기도 하는데요. 고차 함수의 고차 함수로 차수가 올라가며 쓰이는 패턴들을 보니 포인터만큼 난해함을 주는 느낌이 들어 그와 비슷하게 불러 봤습니다. 다른데서는 이렇게 informal하게 쓰는 곳은 보진 못했습니다. ;-)

고차 함수 형태를 보고 알 수 있는 것들이 있습니다. 아래 글을 참고해 주세요.

Covariant, Contravariant, Positive, Negative

반복 idiom

처음 withRunInIO 코드를 볼 때, 잘 읽히지 않았습니다. 고차 함수 트레이닝을 위해, 핵심 동작을 하는 부분만 따로 떼어내어 봤습니다.

loop :: ((Int -> Int) -> Int) -> Int
loop f = if (f (+1) < 100) then loop $ \next -> f (next . (+1))
                           else f id

ghci> loop (\func -> func 1)
99

loop안에서 loop를 부를 때 합성하는 부분을 잘 봐 두세요. 재귀 반복을 하는데, 인자로 받은 함수를 계속 합성하고 있습니다. 재귀를 볼 때, 저는 아래처럼 보는 편입니다.

f (next . (+1))

UnliftIO에서는, 모나드 스택을 하나씩 내려가는 동작을 반복시키고, 내려갈 때 필요한 함수(runXXXT계열 - 위 소스에서 (+1)에 해당)를 클래스 인스턴스를 통해 고르도록 해서, IO에 도달하면 끝마치는 동작을 합니다.

추상적으로 얘기하면, 컴포지션할 함수들을 클래스 인스턴스를 통해 고르게 해서 Compose하는 관용구입니다.

UnliftIO의 단순화 코드

아래 withRunInIO소스와 같은 패턴으로, 단순화 시킨 코드입니다. withRunInIO 코드가 눈에 잘 안들어 오면 읽어보세요.

{-# LANGUAGE DeriveFunctor #-}

data Layer l a = Layer {unLayer :: l a} 
data Base a = Base a deriving (Functor, Show)

class Peel f where
  peel :: ((f a -> Base a) -> Base b) -> f b

instance Peel l => Peel (Layer l) where
  peel job = Layer $ peel $ \next -> job (next . unLayer)

instance Peel Base where
  peel job = job id

MonadUnliftIO 클래스

MonadUnliftIO Class source

withRunInIO :: ((forall a. m a -> IO a) -> IO b) -> m b

instance MonadUnliftIO m => MonadUnliftIO (ReaderT r m) where
  {-# INLINE withRunInIO #-}
  withRunInIO inner =
    ReaderT $ \r ->
    withRunInIO $ \run ->
    inner (run . flip runReaderT r)

forall의 의미는, 모든 a에 대해서, 즉 어떠한 타입을 골라도 m a -> IO a를 만족한다는 뜻이고, 이 말은 결과적으로 a에 의존하지 않는다는 말과 같습니다. “구조”만 변형하는 함수라 읽기도 합니다. 혹 이해가 어려우면, 여기서는 잠시 접어두고 넘어가도 됩니다.

MonadIO의 liftIO

참고로, liftIO가 찾아 가는 방식은 IO에서, 모나드 스택 m에 도착할 때까지 계속 lift합니다. lift . lift . lift . id 같은 모양이 됩니다.

class (Monad m) => MonadIO m where
    liftIO :: IO a -> m a
    
instance MonadIO IO where
    liftIO = id

-- 트랜스포머의 인스턴스 예
instance (MonadIO m) => MonadIO (StateT s m) where
    liftIO = lift . liftIO

-- MonadTrans의 lift
class MonadTrans t where
    lift :: (Monad m) => m a -> t m a

왜 Contravariant 위치에 IO가 있을 땐 liftIO로 해결이 안될까?

@TODO

liftIO도 UnliftIO도 없이 해결

withBinaryFile(handle -> ReaderT Env IO a)IO로 Unlift하는 모양을 생각하는 방법입니다. handle을 하나 받는 함수로 job이라 두겠습니다.

withBinaryFile fp mode job

jobh가 있어야 실행이 되는 것만 생각합니다.

withBinaryFile fp mode (job h) 

job의 실행 결과는 ReaderT타입이니 runReaderTenv을 주면 IO컨텍스트에 도달합니다.

withBinaryFile fp mode (ReaderT (job h) env)

h가 들어올 길을 만들기 위해 람다 헤드와 바인딩합니다. withBinaryFile이 받는 타입은 handler -> IO a입니다.

withBinaryFile fp mode (\h -> ReaderT (job h) env)

env가 들어올 길을 만들기 위해 람다 헤드와 바인딩합니다. 결과는 ReaderT 타입이니 runReaderT를 먹입니다.

runRederT (\env -> withBinaryFile fp mode (\h -> ReaderT (job h) env)) 

이제 withBinaryFileReaderT 컨텍스트에서 쓸 수 있게 되었습니다. negative 위치에 있는 IO를 위해 ReaderT를 써서 Unlift 했습니다. 매 번 mIO로 Unlift하려면 이렇게 쫓아가며 하면 됩니다.

withRunInIO

withRunInIO :: ((forall a. m a -> IO a) -> IO b) -> m b
  1. 안쪽만 먼저 보면
    g :: ( f :: forall a. m a -> IO a) -> IO b f모양의 함수를 받으면 IO b를 돌려주고 있습니다. g안에는 a에서 b를 만들어 내고, m을 떼어내는 작업이 있어야 합니다. 그래야 IO b가 나오는 게 말이 됩니다. 그런데, 이 함수는 f모양의 함수를 미리 가지고 있지 않습니다. 나중에 누군가한테 받아야 합니다.

  2. withRunInIO는 이런 g모양의 함수를 받으면 m b를 돌려 줍니다.
    withRunInIO는 안에 g에 넣어 줄 f모양의 함수를 미리 가지고 있다는 얘기입니다.

아래는 위에 생각 스트레칭에서 충분히 보셨다면 건너뛰셔도 됩니다.

다시 한번, 가장 단순 버전부터 차근 차근 쫓아가 보겠습니다.
(사실은 a타입의 값, b타입의 값이라고 해야 하는데, 복잡함을 줄이기 위해 그냥 a, b로 표현하겠습니다.)

  1. f :: a -> b라는 함수는 a를 주면 b를 돌려 줍니다.

  2. g :: (f :: a -> b) -> bf모양의 함수를 받으면 b를 돌려줍니다. g는 내부에 a를 가지고 있고, f a같은 작업이 있을 겁니다.

  3. h :: (g :: (f :: a -> b) -> b) -> bg모양의 함수를 받으면 b를 돌려 줍니다. 이 번엔 h 내부에 a가 아닌 f모양의 함수를 가지고 있을 겁니다. 물론 g에는 a가 있어야 합니다. 정리하면, a를 이미 가지고 있고, f를 받을 자리만 있는 g를 넘기면 b를 돌려줍니다. 코드 모양은 이렇게 나올 겁니다.

h = \g -> g alreadyHasF

g = \f -> f alreadyHasA

h 안에는 f모양의 함수alreadyHasF를 이미 가지고 있어, 이를 gf자리에 넣어 줄 겁니다.

위에 고차함수 읽는 훈련을 한 대로 읽어 보겠습니다.

withRunInIO :: ((forall a. m a -> IO a) -> IO b) -> m b

withRunInIO는 내부에 (forall a. m a -> IO a) -> IO b를 받았을 때 넣어 줄 alreadyHasF가 있습니다.

withRunInIO inner = inner alreadyHasF

inner는 아래 모양의 함수가 들어올 겁니다.

inner func = func alreadyHasA 

※ 재귀 읽기

위에 코드가 다 이해갔다면, 굳이 아래를 볼 필요는 없습니다. 위가 잘 안읽히는 분들은 재귀 반복을 아래처럼 따라가 보는 것도 한 방법입니다.

재귀를 돌 때마다 다른 함수로 바라봅니다. 재귀가 잘 보이도록 일부 코드를 지웠습니다. - 참고 재귀 생각법

withRunInIO1 inner1 =
  withRunInIO2 $ \run1 -> inner1 (run1 . unlifter1)
                 ------------ inner2 -------------

withRunInIO2 inner2 =
--withRunInIO3 $ \run2 -> inner2 (run2 . unlifter)
  withRunInIO2 $ \run2 -> (\run1 -> inner1 (run1 . unlifter1))  (run2 . unlifter2)
  withRunInIO2 $ \run2 -> inner1 (run2 . unlifter2 . unlifter1)
                 ------------------ inner3 -------------------

withRunInIO3 inner3 =
--withRunInIO3 $ \run3 -> inner3 (run3 . unlifter)
  withRunInIO3 $ \run3 ->  \run2 -> inner1 (run2 . unlifter2 . unlifter1)  (run3 . unlifter3)
  withRunInIO3 $ \run3 ->  inner1 (run3 . unlifter3 . unlifter2 . unlifter1)

GHC 컴파일러는 withRunInIO를 어떤 인스턴스의 것으로 고를 것이냐를 인자를 보고 결정합니다.
inner1이 받는 함수는 forall a. m a -> IO a이어야 합니다.

inner1 :: forall a. m a -> IO a = \run1 -> inner1 (run1 . unlifter1)
inner2 :: forall a. m a -> IO a = \run2 -> inner1 (run2 . unlifter2 . unlifter1)
inner3 :: forall a. m a -> IO a = \run3 -> inner1 (run3 . unlifter3 . unlifter2 . unlifter1)

아래 타입을 설명할 때, 잠시 a를 생략하겠습니다.

영리한 GHC가 타입을 잘 추론해 줄 수 있는 단서들이 보입니다.

비슷한 목적의 솔루션

@TODO 다른 솔루션을 찾는대로 적어 놓도록 하겠습니다.
Monad Control

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