After publishing my early GHC plugin, I saw a
lot of response from people saying “Why not ExceptT
?”,
despite having outlined it in the README of the project. After reading
some sincere responses failing to see why I bothered with this at all,
it seems I haven’t explained myself well.
I’m happy for people to just disagree because they have different values, but I want to make sure we’re talking about the same trade-offs.
I think people need to see real code to see why I think this is a substantial improvement. You’ll have to use your imagination generously to raise the contrived to the “real world”.
(I’m calling this plugin EarlyDo
so I can talk
specifically about the syntax extension.)
Here is a module of three functions (but use your imagination to make
a couple dozen), where the IO actions return
Either failure a
. The proposal I have been repeatedly given
is to use ExceptT
. I’m typically using RIO, so I define a
fake one, and then use a real one later.
To do concurrency or exception handling or any with-like thing, you
need the equivalent of MonadUnliftIO
to capture the current
context, and then re-run the monad further in the new thread. Here we do
it manually to demonstrate what’s happening.
import Control.Concurrent.Async
import Control.Monad.Trans.Except
import Control.Monad.Trans.Reader
import Control.Monad.Trans
type RIO r a = ReaderT r IO a
foo :: Int -> RIO () (Either () ())
= runExceptT $ do
foo i <- ExceptT $ bar (i + 1)
_ ExceptT $ zot (i + 2)
bar :: Int -> RIO () (Either () ((), ()))
=
bar i $ do
runExceptT ExceptT $ zot 0
<- ExceptT $ fmap Right ask
r <-
(x, y) ExceptT $ lift $
fmap
-> (,) <$> x <*> y)
(\(x, y)
(concurrentlyflip runReaderT r $ foo (i + 1))
(flip runReaderT r $ zot (i + 2)))
(pure (x, y)
zot :: Int -> RIO () (Either () ())
= runExceptT $ do
zot i <- ExceptT $ bar (i + 1)
_ ExceptT $ zot (i + 2)
(Why am I not using ExceptT () (RIO ()) ..
in my type
signatures? Continue reading to the end.)
Here’s a version using MonadUnliftIO
. Not that the work
of unlifting has been done for me.
import Control.Monad.Trans.Except
import RIO (RIO)
import UnliftIO
foo :: Int -> RIO () (Either () ())
= runExceptT $ do
foo i <- ExceptT $ bar (i + 1)
_ ExceptT $ zot (i + 2)
bar :: Int -> RIO () (Either () ((), ()))
=
bar i $ do
runExceptT ExceptT $ zot 0
<-
(x, y) ExceptT $
fmap
-> (,) <$> x <*> y)
(\(x, y)
(concurrently+ 1))
(foo (i + 2)))
(zot (i pure (x, y)
zot :: Int -> RIO () (Either () ())
= runExceptT $ do
zot i <- ExceptT $ bar (i + 1)
_ ExceptT $ zot (i + 2)
What remains is the runExceptT
and ExceptT
calls which cannot be eliminated. Here we are unsatisfied, because all
the functions in our module are using this “pattern”. A pattern is what
we call repeating yourself because your language can’t abstract it for
you. We pay an O(1)
cost per definition, and an
O(n)
cost for calls per definition.
Additionally, any traverse
call that is just
MonadIO m => ..
in the middle of the function, or
logging function, will also run in ExceptT
, causing a
performance penalty that we didn’t want. If we want to avoid that
penalty, we must use lift
. But I haven’t benchmarked this.
You are welcome to do so.
You pay to short-circuit and you pay to do normal actions in the base monad, and if you’re not careful you may accidentally pay extra.
Given that pretty much all of my apps have monads that look something like this, this seemed worth improving.1
With EarlyDo
, we have:
{-# OPTIONS -F -pgmF=early #-}
import Control.Early
import RIO (RIO)
import UnliftIO
foo :: Int -> RIO () (Either () ())
= do
foo i <- bar (i + 1)?
_ + 2)
zot (i
bar :: Int -> RIO () (Either () ((), ()))
= do
bar i 0?
zot <-
(x, y) fmap (\(x, y) -> (,) <$> x <*> y) (concurrently (foo (i + 1)) (zot (i + 2)))?
pure (Right (x, y))
zot :: Int -> RIO () (Either () ())
= do
zot i <- bar (i + 1)?
_ + 2) zot (i
A single character that appears in a not-normally-valid syntax
position signals short-circuiting. Any base monad action like
mapRIO
(runs in RIO r
) can be used without
lifting or unlifting. I have to explicitly produce Right
in
bar
, which is a cost, but not in
zot
/foo
.
Finally, we can use our own wrapper monad:
{-# OPTIONS -F -pgmF=early #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Early
import RIO (RIO)
import UnliftIO
data AppEnv
newtype App a = App { runApp :: RIO AppEnv a} deriving (Functor, Applicative, Monad, MonadIO)
instance MonadUnliftIO App where withRunInIO = wrappedWithRunInIO App runApp
foo :: Int -> App (Either () ())
= do
foo i <- bar (i + 1)?
_ + 2)
zot (i
bar :: Int -> App (Either () ((), ()))
= do
bar i 0?
zot <-
(x, y) fmap (\(x, y) -> (,) <$> x <*> y) (concurrently (foo (i + 1)) (zot (i + 2)))?
pure (Right (x, y))
zot :: Int -> App (Either () ())
= do
zot i <- bar (i + 1)?
_ + 2) zot (i
The App
monad has the full power of all the previously
mentioned things.
But not with ExceptT
. I cannot put
ExceptT
in this monad stack and retain the
MonadUnliftIO
instance. It has no valid
instance!
Could the mechanics of this syntax use ExceptT
underneath? Sure, but then the whole do block would be under
ExceptT
, and I’d have to lift the rest of the do notation
too (even if it’s not necessary, incurring extra cost for no reason).
Even if I could tell which statements should be ExceptT
’d
or lift
’d, it would be overkill.
At this level, it doesn’t matter much. All you need the syntax to
produce is a case expression. The use of an Early
class is
unneccessary, it just makes it easy to use either Maybe
or
Either
with the same syntax.
If your answer is “just define really short names for
runExceptT
and ExceptT
and lift
”,
perhaps you could go back to using >>=
and
>>
instead of do
, and ApplicativeDo
,
and RecursiveDo
,
and Arrows
,
and report your experience.
I’ve never once used Arrow
and I’ve used
RecursiveDo
as a novelty, but mfix
was
sufficient in the end. ApplicativeDo
is a brittle
extension, but that doesn’t stop me from using it.↩︎