(or, The phantom/redundant constraint pattern)
All effect systems introduce an unavoidable heavy performance penalty, even so with mtl
if you aren’t careful enough about specialization. But here, hot take: you probably don’t need an effect system at all. We have a way to avoid all these - maybe?
# Why?
This section briefly recaps what I’ve learnt from Alexis King’s very nice talk on ZuriHac 2020. Thank you Alexis for such a good talk.
Well, why all existing solutions suck at performance? It is perhaps because they all abstract too much. For the two common approaches, a typical effectful function will look like this:
simple :: (Reader String :> m, Error () :> m) => Int -> m () -- (or Eff m ())
The first kind of approach is the typeclass approach, taken by fused-effects
, capability
and of course, mtl
. In this approach, effectful functions are completely polymorphic in their monads. What the monad can be is limited by the constraints, i.e. typeclasses containing the actual implementation of how to interpret a certain effect. Now we know that ghc uses dictionary passing for constraints, so we can transform this signature a bit:
simple :: Reader String :> m -> Error () :> m -> Int -> m ()
Let’s see what we get here. ghc cannot optimize the effect by any inlining because we even don’t know the exact implementation of the effects; they are arguments. It cannot optimize any bind operations either because we’re polymorphic in the monad type. Oops.
Another approach is to use the famous free monad, taken by freer-simple
and polysemy
. A free monad itself is basically a datatype of program trees! ghc wouldn’t know that of course, and so it won’t know how to optimize your Eff
type, which is just a giant stack of datatype constructors in its eyes.
# A little story
Say you have an application like this using effect systems.
myApp :: (Members '[Reader A, State B, Error C] m) => Eff m ()
It worked well until you benchmarked it, where you were shocked by the numbers. You decided to go back to the primitive way, using the ReaderT
pattern.
data Global = Global
{ myA :: A
, myB :: IORef B
}
myApp :: ReaderT Global IO ()
Now you get good performance. But you could do all kinds of IO actions, unlimited, which is what you want to avoid by using an effect system. You decide to restrict yourself a bit:
newtype App a = UnsafeApp { runApp :: ReaderT Global IO a } -- UnsafeApp not exported
askA :: App A
getB :: App B
putB :: B -> App B
throwC :: C -> App a
catchC :: App a -> (C -> App a) -> App a
And you go back happily to change myApp
to use App
. But this restriction is still not fine-grained enough; some of your functions only read A
, which can be expressed in an effect system but not with your App
. You’re mad now. What can you do?
# You know, I’m something of an effect myself
Out of anger, you quickly wrote:
class Reader r where
class State s where
class Error e where
and decided not to define any fields nor instances for them. They are completely useless - or are they?
Of course not. You can now change your functions to irritate the compiler to complain about redundant constraints:
askA :: Reader A => App A
getB :: State B => App B
putB :: State B => B -> App ()
throwC :: Error C => C -> App a
catchC :: Error C => App a -> (C -> App a) -> App a
Magic happens!
f :: App A
f = askA
-- ^^^^
-- • No instance for (Reader A) arising from a use of ‘askA’
g :: Reader A => App A
g = getA
-- compiles!
Seems that you’ve created an effect system with 3 empty typeclasses. Phenomenal effort! Your myApp
now looks like this:
myApp :: (Reader A, State B, Error C) => App ()
“Hey,” you say, “but how do I get rid of these constraints when I run my application inmain
? I can’t define any instances because that would make these constraints make no sense!”
In short: you could. Just separate the main
far away from your effectful functions, in another file:
instance Reader a
instance State a
instance Error a
main :: IO ()
main = runReaderT (runApp myApp) initialGlobalEnv
Be careful don’t let other modules import this one. Otherwise good luck debugging.
# Takeaway
This small thing (I call it the “redundant constraint pattern”) shows that for many needs of effect management, you may actually not need to use a full-blown effect system. No need to be polymorphic on monads, or using some Eff
. A concrete monad plus simple empty classes and redundant constraints can block you from many possible errors already.
This approach also has minimal performance penalty, if there is at all. Because it uses a concrete monad, ghc should be very willing to optimize it. I am investigating if it is possible to build an effects library with it, just like Tweag’s capability
for the ReaderT
pattern - see the WIP at https://github.com/re-xyr/availability.