January 10, 2018
After some discussion on reddit following Luka’s post on optimizing
tagless final computations, I realized a trick for inspecting monadic
programs, but in an unsafe manner. Luka’s technique of optimizing computations is
rooted in the idea of the analyzeFreeAp
function of the Free Applicative (or
runAp_
in Haskell’s free package). This idea is applied in the context
of tagless final programs, but the fact still remains that it is a safe
way of inspecting programs. Safe meaning that whatever handler we write,
it can be applied to applicative programs without any runtime error.
Unsafe in this context means that we will be cheating the type system
and as a result runtime errors might surface when the wrong handler is
combined with the wrong program.
For this reason I would disadvise using this trick in actual code, make sure you are aware of the risks and are able to deal with them if you ever intend to use this.
The examples are given in Purescript. The effect of laziness (for example in Haskell) on this trick is discussed in section ‘Laziness’.
As opposed to computations with Applicative
constraints, this post
will only handle computations which have Monad
constraints.
For example consider the following computation:
prog1 :: forall f r a.
Monad f =>
{ get :: String -> f a
, put :: String -> a -> f Unit | r } ->
a -> f (Array a)
prog1 k mouse = do
f <- k.get "Cats"
s <- k.get "Dogs"
k.put "Mice" mouse
t <- k.get "Cats"
pure [f, s, t]
The type signature tells us that we need a handler k
which interprets at
least the get
and put
operations, it also takes an input for the "Mice"
location named mouse
. The computation returns all retrieved data as an f (Array a)
,
f
being the type constructor the handler interprets to.
We want to interpret the computation into two Set
s, one to gather all keys
from get
operations and the other to gather all keys and values from put
operations.
These Set
s enable the creation of an optimized program: a unique
get and put for each location, but we won’t go into detail on this here.
However, we cannot use applicative inspection using Const
to realize this. The program
is written in do
notation and thus has a Monad
constraint and
Const
doesn’t have a Monad
instance. This is where the trick comes
in. We will write an interpretation to Writer
. Normally we would
consider this impossible because of the following problem:
We need to write an interpretation for the get
and put
operation.
However, note the spots marked with ?
. We need to construct the writer
by giving it both a value a
and a value of (Set, Set)
. Remember
that Writer w a
is isomorphic to (w, a)
. The value a
is missing
since we are not actually running the computation, merely inspecting it.
get1 :: forall a. String -> Writer (Tuple (S.Set String) (S.Set (Tuple String Int))) a
get1 key = writer (Tuple ? (Tuple (S.singleton key) (S.empty)))
put1 :: forall a. String -> Int -> Writer (Tuple (S.Set String) (S.Set (Tuple String Int))) a
put1 key value = writer (Tuple ? (Tuple (S.empty) (S.singleton (Tuple key value))))
The trick is actually very simple, we will use a function which tricks the type system:
undef :: forall a. a
undef = unsafeCoerce "oops!"
We use this new undef
as a replacement for ?
,
essentially cheating the type system by saying the value a
is available while it actually isn’t.
However, we never intend to do anything with this value, so this fact doesn’t matter.
We replace ? by undef
to obtain the handler:
get1 :: forall a. String -> Writer (Tuple (S.Set String) (S.Set (Tuple String Int))) a
get1 key = writer (Tuple undef (Tuple (S.singleton key) (S.empty)))
put1 :: forall a. String -> Int -> Writer (Tuple (S.Set String) (S.Set (Tuple String Int))) a
put1 key value = writer (Tuple undef (Tuple (S.empty) (S.singleton (Tuple key value))))
This is already sufficient to inspect prog1
and give us the result
we want, for the gets [“Cats”, “Dogs”] and for the puts [(“Mice”, 1)]:
> snd $ runWriter $ prog1 {get:get1, put:put1} 1
(Tuple (fromFoldable ("Cats" : "Dogs" : Nil)) (fromFoldable ((Tuple "Mice" 1) : Nil)))
We can also see the type system lying to us (because we cheated!):
> :t fst $ runWriter $ prog1 {get:get1, put:put1} 1
Array Int
Which, when evaluated, gives us ["oops!","oops!","oops!"]
, definitely not an array of Int
s!
But, again, the intention of the inspection is to ignore the actual result of the computation, the a
part of the writer is intended to never be used.
So, while somewhat weird, this isn’t really the problem with the trick.
Laziness has some interesting implication for this trick.
Consider a slightly altered version of our scenario. The get
operation
returns Maybe a
instead of a
:
prog2_strict :: forall f r a.
Monad f =>
{ get :: String -> f (Maybe a)
, put :: String -> a -> f Unit | r } ->
a -> f (Array a)
prog2_strict k mouse = do
f <- k.get "Cats"
s <- k.get "Dogs"
k.put "Mice" mouse
t <- k.get "Cats"
pure (catMaybes [f, s, t])
The only change we have to make is returning the final array using catMaybes
to compress Array (Maybe a)
into Array a
.
What happens if we inspect this program?
> snd $ runWriter $ prog2_wrong {get:get1,put:put1} "a"
Uncaught Error: Failed pattern match at Data.Maybe line ...
Whoops! Purescript is not lazy, so it tries to interpret the "oops!"
s as Maybe a
s because it wants to know if it is a Just a
or Nothing
, due to the catMaybes
call, even though we never requested to calculate the result of the computation…
When using Haskell, this problem shouldn’t surface because it is lazy by default. And if we never request the result array, the dark secret won’t be revealed.
A solution in Purescript is to use opt-in laziness and create a thunk out of the result array:
prog2_lazy :: forall f r a.
Monad f =>
{ get :: String -> f (Maybe a)
, put :: String -> a -> f Unit | r } ->
a -> f (Lazy (Array a))
prog2_lazy k mouse = do
f <- k.get "Cats"
s <- k.get "Dogs"
k.put "Mice" mouse
t <- k.get "Cats"
pure (defer (\_ -> catMaybes [f, s, t]))
Which makes the example work again:
> snd $ runWriter $ prog2_lazy {get:get1, put:put1} 1
(Tuple (fromFoldable ("Cats" : "Dogs" : Nil)) (fromFoldable ((Tuple "Mice" 1) : Nil)))
So, why go through the trouble to use this unsafe trick when a perfectly viable alternative exists? The reason is that the alternative is not as perfect as it seems on first sight.
Categorizing computations as applicative or monadic is very coarse-grained.
This results in Monad
constraints popping up in all sorts of situations, which disables the applicative inspection.
But it doesn’t mean that these programs are not inspectable at all.
In some sense this coarse modeling of computations results in the type system preventing us of running some inspections we would want to do.
Take prog3
for example:
prog3 :: forall f r a.
Monad f =>
{ get :: String -> f a
, put :: String -> a -> f Unit | r } ->
f (Array a)
prog3 k = do
f <- k.get "Cats"
s <- k.get "Dogs"
k.put "Mice" f
t <- k.get "Cats"
pure [f, s, t]
The difference with prog1
is that we pass the value f
from k.get "Cats"
to k.put "Mice" f
.
Obviously, we cannot inspect this computation to return the values of all puts, they are not statically known.
But, we are able to inspect all locations which will be touched by only gathering the keys.
We can easily adapt the handler:
get2 :: forall a. String -> Writer (Tuple (S.Set String) (S.Set String)) a
get2 key = writer (Tuple undef (Tuple (S.singleton key) (S.empty)))
put2 :: forall a. String -> Int -> Writer (Tuple (S.Set String) (S.Set String)) a
put2 key value = writer (Tuple undef (Tuple (S.empty) (S.singleton key)))
We see the sets [“Cats”, “Dogs”] and [“Mice”] as result of our inspection:
> snd $ runWriter $ prog3 {get:get2, put:put2}
(Tuple (fromFoldable ("Cats" : "Dogs" : Nil)) (fromFoldable ("Mice" : Nil)))
Another example of a Monad
constraint popping up is when we add some logging to our computation:
prog4 :: forall f r a.
Monad f =>
Show a =>
{ get :: String -> f a
, put :: String -> a -> f Unit
, log :: String -> f Unit | r } ->
a -> f (Array a)
prog4 k mouse = do
f <- k.get "Cats"
k.log ("Cats: " <> show f)
s <- k.get "Dogs"
k.log ("Dogs: " <> show s)
k.put "Mice" mouse
t <- k.get "Cats"
k.log ("Cats: " <> show t)
pure [f, s, t]
I am aware that we could rewrite the program to fit the applicative inspection style, but it feels quite awkward that this is necessary.
By essentially turning off the logging to prevent any errors from surfacing (although I don’t think it is a problem even if it actually logged things):
log1 :: forall f a. Applicative f => String -> f a
log1 s = pure undef
We can analyze the computation as well, without having to modify its definition:
> snd $ runWriter $ prog4 {get:get1, put:put1, log:log1} 1
(Tuple (fromFoldable ("Cats" : "Dogs" : Nil)) (fromFoldable ((Tuple "Mice" 1) : Nil)))
There are more situations this applies to, but hopefully the point is clear.
I want to highlight the fact that if we mismatch a handler and computation we will get into weird behaviour territory as well. This is in fact the main issue I see with actually using this trick. You are essentially throwing away the safety given by the type system to apply this trick.
For example inspecting prog3
with get1
and put1
, which assume that all put
values are statically available will result in an erroneous value since the computation will catch the unsafe undef
value.
> snd $ runWriter $ prog3 {get:get1, put:put1}
(Tuple (fromFoldable ("Cats" : "Dogs" : Nil)) (fromFoldable ((Tuple "Mice" oops!) : Nil)))
Notice the "oops!"
value in the second set, if it was used as an Int
it would most likely result in a runtime error somewhere.
I think this trick highlights possibilities for alternative categorizations of computations, which are more fine-grained than applicative/monad, in fact also more fine-grained than applicative/arrow/monad. The actual trick in itself, due to it actively cheating the type system, should probably not be used in practice however.
You can check out the full code in this gist, which can be pasted into Try Purescript to run it.