모나드 스택에서 IO 작업들을 불러다 쓸 때, MonadTrans의 liftIO를 이용합니다. 그런데, IO가 contravariant (negative) 위치에 있을 때는 liftIO로 해결이 안됩니다. 이럴 때 리프팅 작업을 도와주는 클래스입니다.
unliftio: The MonadUnliftIO typeclass for unlifting monads to IO (batteries included)
C언어에서 포인터의 포인터를 업자들은 포포인터라 부르기도 하는데요. 고차 함수의 고차 함수로 차수가 올라가며 쓰이는 패턴들을 보니 포인터만큼 난해함을 주는 느낌이 들어 그와 비슷하게 불러 봤습니다. 다른데서는 이렇게 informal하게 쓰는 곳은 보진 못했습니다. ;-)
고차 함수 형태를 보고 알 수 있는 것들이 있습니다. 아래 글을 참고해 주세요.
Covariant, Contravariant, Positive, Negative
처음 withRunInIO
코드를 볼 때, 잘 읽히지 않았습니다. 고차 함수 트레이닝을 위해, 핵심 동작을 하는 부분만 따로 떼어내어 봤습니다.
loop :: ((Int -> Int) -> Int) -> Int
= if (f (+1) < 100) then loop $ \next -> f (next . (+1))
loop f else f id
> loop (\func -> func 1)
ghci99
loop
안에서 loop
를 부를 때 합성하는 부분을 잘 봐 두세요. 재귀 반복을 하는데, 인자로 받은 함수를 계속 합성하고 있습니다. 재귀를 볼 때, 저는 아래처럼 보는 편입니다.
. (+1)) f (next
next
바인딩을 빼고, 그저 언젠가는 바인딩 되겠지 하고 봅니다.f
에는 (\func -> func 1)
이 들어갈테니 reduce하면 next . (+1) $ 1
이 됩니다.next
를 람다 헤드와 걸어 함수로 만듭니다. \next -> next . (+1) $ 1
f
에 넣어주면 (\next -> next . (+1) $ 1) (next2 . (+1))
이 되고,(next2 . (+1)) . (+1) $ 1
이 됩니다.\next -> next . (+1) . (+1) . (+1) ... $ 1
모양이 됩니다.f (+1) < 100
조건을 넣어, 이 조건에 만족하지 않는 순간 더 이상 loop
를 부르지 않고, id
를 인자로 넣어 id . (+1) . (+1) . (+1) ...$ 1
로 마무리 됩니다.UnliftIO
에서는, 모나드 스택을 하나씩 내려가는 동작을 반복시키고, 내려갈 때 필요한 함수(runXXXT
계열 - 위 소스에서 (+1)
에 해당)를 클래스 인스턴스를 통해 고르도록 해서, IO
에 도달하면 끝마치는 동작을 합니다.
추상적으로 얘기하면, 컴포지션할 함수들을 클래스 인스턴스를 통해 고르게 해서 Compose하는 관용구입니다.
아래 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
= Layer $ peel $ \next -> job (next . unLayer)
peel job
instance Peel Base where
= job id peel job
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 ->
$ \run ->
withRunInIO . flip runReaderT r) inner (run
inner
는 runXXXT :: m a -> IO a
계열, 즉 unlifter를 받아 IO
를 돌려주는 함수입니다.runXXXT
함수가 들어 올까요? 클래스의 인스턴스에서 미리 정해 가지고 있습니다. withRunInIO
의 ReaderT
인스턴스는 runReader
를 미리 가지고 있고, IO
의 인스턴스는 id
를 미리 가지고 있습니다.\run
에는 뭐가 들어 올까요? 다음 withRunInIO
가 알아서 runXXXT
계열을 넣어 줄 겁니다.IO
까지 도달합니다.※ forall
의 의미는, 모든 a
에 대해서, 즉 어떠한 타입을 골라도 m a -> IO a
를 만족한다는 뜻이고, 이 말은 결과적으로 a
에 의존하지 않는다는 말과 같습니다. “구조”만 변형하는 함수라 읽기도 합니다. 혹 이해가 어려우면, 여기서는 잠시 접어두고 넘어가도 됩니다.
참고로, liftIO
가 찾아 가는 방식은 IO
에서, 모나드 스택 m
에 도착할 때까지 계속 lift
합니다.
lift . lift . lift . id
같은 모양이 됩니다.
class (Monad m) => MonadIO m where
liftIO :: IO a -> m a
instance MonadIO IO where
= id
liftIO
-- 트랜스포머의 인스턴스 예
instance (MonadIO m) => MonadIO (StateT s m) where
= lift . liftIO
liftIO
-- MonadTrans의 lift
class MonadTrans t where
lift :: (Monad m) => m a -> t m a
@TODO
withBinaryFile
의 (handle -> ReaderT Env IO a)
를 IO
로 Unlift하는 모양을 생각하는 방법입니다.
handle
을 하나 받는 함수로 job
이라 두겠습니다.
withBinaryFile fp mode job
job
은 h
가 있어야 실행이 되는 것만 생각합니다.
withBinaryFile fp mode (job h)
job
의 실행 결과는 ReaderT
타입이니 runReaderT
로 env
을 주면 IO
컨텍스트에 도달합니다.
ReaderT (job h) env) withBinaryFile fp mode (
h
가 들어올 길을 만들기 위해 람다 헤드와 바인딩합니다. withBinaryFile
이 받는 타입은 handler -> IO a
입니다.
-> ReaderT (job h) env) withBinaryFile fp mode (\h
env
가 들어올 길을 만들기 위해 람다 헤드와 바인딩합니다. 결과는 ReaderT
타입이니 runReaderT
를 먹입니다.
-> withBinaryFile fp mode (\h -> ReaderT (job h) env)) runRederT (\env
이제 withBinaryFile
을 ReaderT
컨텍스트에서 쓸 수 있게 되었습니다. negative 위치에 있는 IO
를 위해 ReaderT
를 써서 Unlift 했습니다. 매 번 m
을 IO
로 Unlift하려면 이렇게 쫓아가며 하면 됩니다.
withRunInIO :: ((forall a. m a -> IO a) -> IO b) -> m b
안쪽만 먼저 보면
g :: ( f :: forall a. m a -> IO a) -> IO b
f
모양의 함수를 받으면 IO b
를 돌려주고 있습니다. g
안에는 a
에서 b
를 만들어 내고, m
을 떼어내는 작업이 있어야 합니다. 그래야 IO b
가 나오는 게 말이 됩니다. 그런데, 이 함수는 f
모양의 함수를 미리 가지고 있지 않습니다. 나중에 누군가한테 받아야 합니다.
withRunInIO
는 이런 g
모양의 함수를 받으면 m b
를 돌려 줍니다.
withRunInIO
는 안에 g
에 넣어 줄 f
모양의 함수를 미리 가지고 있다는 얘기입니다.
아래는 위에 생각 스트레칭에서 충분히 보셨다면 건너뛰셔도 됩니다.
다시 한번, 가장 단순 버전부터 차근 차근 쫓아가 보겠습니다.
(사실은 a
타입의 값, b
타입의 값이라고 해야 하는데, 복잡함을 줄이기 위해 그냥 a
, b
로 표현하겠습니다.)
f :: a -> b
라는 함수는 a
를 주면 b
를 돌려 줍니다.
g :: (f :: a -> b) -> b
는 f
모양의 함수를 받으면 b
를 돌려줍니다.
g
는 내부에 a
를 가지고 있고, f a
같은 작업이 있을 겁니다.
h :: (g :: (f :: a -> b) -> b) -> b
는 g
모양의 함수를 받으면 b
를 돌려 줍니다.
이 번엔 h
내부에 a
가 아닌 f
모양의 함수를 가지고 있을 겁니다. 물론 g
에는 a
가 있어야 합니다.
정리하면, a
를 이미 가지고 있고, f
를 받을 자리만 있는 g
를 넘기면 b
를 돌려줍니다.
코드 모양은 이렇게 나올 겁니다.
= \g -> g alreadyHasF
h
= \f -> f alreadyHasA g
h
안에는 f
모양의 함수alreadyHasF를 이미 가지고 있어, 이를 g
의 f
자리에 넣어 줄 겁니다.
위에 고차함수 읽는 훈련을 한 대로 읽어 보겠습니다.
withRunInIO :: ((forall a. m a -> IO a) -> IO b) -> m b
withRunInIO
는 내부에 (forall a. m a -> IO a) -> IO b
를 받았을 때 넣어 줄 alreadyHasF
가 있습니다.
= inner alreadyHasF withRunInIO inner
inner
는 아래 모양의 함수가 들어올 겁니다.
= func alreadyHasA inner func
※ 재귀 읽기
위에 코드가 다 이해갔다면, 굳이 아래를 볼 필요는 없습니다. 위가 잘 안읽히는 분들은 재귀 반복을 아래처럼 따라가 보는 것도 한 방법입니다.
재귀를 돌 때마다 다른 함수로 바라봅니다. 재귀가 잘 보이도록 일부 코드를 지웠습니다. - 참고 재귀 생각법
=
withRunInIO1 inner1 $ \run1 -> inner1 (run1 . unlifter1)
withRunInIO2 ------------ inner2 -------------
=
withRunInIO2 inner2 --withRunInIO3 $ \run2 -> inner2 (run2 . unlifter)
$ \run2 -> (\run1 -> inner1 (run1 . unlifter1)) (run2 . unlifter2)
withRunInIO2 $ \run2 -> inner1 (run2 . unlifter2 . unlifter1)
withRunInIO2 ------------------ inner3 -------------------
=
withRunInIO3 inner3 --withRunInIO3 $ \run3 -> inner3 (run3 . unlifter)
$ \run3 -> \run2 -> inner1 (run2 . unlifter2 . unlifter1) (run3 . unlifter3)
withRunInIO3 $ \run3 -> inner1 (run3 . unlifter3 . unlifter2 . unlifter1) withRunInIO3
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
를 생략하겠습니다.
unlifter1 :: ... -> ReaderT
면, run1
은 ReaderT-> IO
여야 하고
그럼, 다음 번 withRunInIO
는 ReaderT
인스턴스에서 가져오고, 그 인스턴스는 runReaderT
를 unlifter
로 넣어줄 것이고
unlifter1 :: ... -> LoggingT
면, run1
은 LoggingT -> IO
여야 하고
그럼, 다음 번 withRunInIO
는 LoggingT
인스턴스에서 가져오고, 그 인스턴스는 runLoggingT
를 unlifter
로 넣어줄 것이고
unlifter1 :: ... -> IO
면, run1
은 IO -> IO
여야 하고
그럼, 다음 번 withRunInIO
는 IO
인스턴스에서 가져오고, 그 인스턴스는, 더 이상 추가 unlifter
위한 자리 없이 id
만 넣어줘서 반복이 끝납니다.
영리한 GHC가 타입을 잘 추론해 줄 수 있는 단서들이 보입니다.
@TODO 다른 솔루션을 찾는대로 적어 놓도록 하겠습니다.
Monad Control