The Remorseless Free Monad
What follows is a quick summary of the Reflection without Remorse paper. This summary:
- explains what's at the heart of the original
Free
's performance problem - explains using a very high-level "read the paper if you want to understand the 'type magic'" explanation for how the "reflection without remorse" version of
Free
fixes that performance problem.
Similar Shapes: The Free Monoid and the Free Monad
Surpisingly, these two "free" types bear a similar resemblence:
data List a = Nil | Cons a (List a )
data Free f a = Pure a | Impure (f (Free f a))
A list is just an unbalanced tree. Thus, Free
is essentially the same as List
, except that it stores a higher-kinded type (kind Type -> Type
) rather than a concrete type (kind Type
)
This similarity will be used to explain why Free
has performance problems.
The Direction Matters
Let's talk about Semigroup
:
class Semigroup a where
append :: a -> a -> a
infix 4 append as <>
instance Semigroup Int where
append i1 i2 = i1 + i2
Semigroup
requires its implementation to adhere to the law of association, meaning that, when append
is used on the output of a previous append
and some other value, the location of the parenthenses don't matter:
1 <> 2 <> 3 <> 4 <> 5 <> 6 <> 7 <> 8
-- no association: Tree-like structure
-- Start
1 <> 2 <> 3 <> 4 <> 5 <> 6 <> 7 <> 8
-- Add parentheesis, starting from the 'leaves'
(1 <> 2) <> (3 <> 4) <> (5 <> 6) <> (7 <> 8)
((1 <> 2) <> (3 <> 4)) <> ((5 <> 6) <> (7 <> 8))
-- Left association: List-like structure
-- Start
1 <> 2 <> 3 <> 4 <> 5 <> 6 <> 7 <> 8
-- Add parenthesis, starting from the left
(1 <> 2) <> 3 <> 4 <> 5 <> 6 <> 7 <> 8
((1 <> 2) <> 3) <> 4 <> 5 <> 6 <> 7 <> 8
(((1 <> 2) <> 3) <> 4) <> 5 <> 6 <> 7 <> 8
((((1 <> 2) <> 3) <> 4) <> 5) <> 6 <> 7 <> 8
(((((1 <> 2) <> 3) <> 4) <> 5) <> 6) <> 7 <> 8
((((((1 <> 2) <> 3) <> 4) <> 5) <> 6) <> 7) <> 8
-- Finish:
((((((1 <> 2) <> 3) <> 4) <> 5) <> 6) <> 7) <> 8
-- Right association: List-like structure
-- Start
1 <> 2 <> 3 <> 4 <> 5 <> 6 <> 7 <> 8
-- add parenthesis, starting from the right
1 <> 2 <> 3 <> 4 <> 5 <> 6 <> (7 <> 8)
1 <> 2 <> 3 <> 4 <> 5 <> (6 <> (7 <> 8))
1 <> 2 <> 3 <> 4 <> (5 <> (6 <> (7 <> 8)))
1 <> 2 <> 3 <> (4 <> (5 <> (6 <> (7 <> 8))))
1 <> 2 <> (3 <> (4 <> (5 <> (6 <> (7 <> 8)))))
1 <> (2 <> (3 <> (4 <> (5 <> (6 <> (7 <> 8))))))
-- Finish:
1 <> (2 <> (3 <> (4 <> (5 <> (6 <> (7 <> 8))))))
We can see that the output of adding up all these integers, regardless of where we put the parenthesis, will still be the same output value (that's the law of associativity).
However, functions that are "associative" sometimes take longer to output that value depending on which "direction" it goes. As an example, consider the Semigroup
instance for List
:
data List a
= Nil
| Cons a (List a)
infix 4 Cons as :
-- Nil == []
-- 1 : Nil == [1]
-- 1 : 2 : 3 : Nil == [1, 2, 3]
-- Right associative: Start
(1 : Nil) <> (2 : Nil) <> (3 : Nil) <> (4 : Nil) <> (5 : Nil) -- 0
(1 : Nil) <> (2 : Nil) <> (3 : Nil) <> ((4 : Nil) <> (5 : Nil)) -- 0
(1 : Nil) <> (2 : Nil) <> ((3 : Nil) <> ((4 : Nil) <> (5 : Nil))) -- 0
(1 : Nil) <> ((2 : Nil) <> ((3 : Nil) <> ((4 : Nil) <> (5 : Nil)))) -- 0
append (1 : Nil) ((2 : Nil) <> ((3 : Nil) <> ((4 : Nil) <> (5 : Nil)))) -- 1
1 : (append Nil ((2 : Nil) <> ((3 : Nil) <> ((4 : Nil) <> (5 : Nil))))) -- 2
1 : ((2 : Nil) <> ((3 : Nil) <> ((4 : Nil) <> (5 : Nil)))) -- 3
1 : (append (2 : Nil) ((3 : Nil) <> ((4 : Nil) <> (5 : Nil)))) -- 4
1 : (2 : (append Nil ((3 : Nil) <> ((4 : Nil) <> (5 : Nil)))) -- 5
1 : (2 : ((3 : Nil) <> ((4 : Nil) <> (5 : Nil)))) -- 6
1 : (2 : (3 : (append Nil <> ((4 : Nil) <> (5 : Nil))))) -- 7
1 : (2 : (3 : ((4 : Nil) <> (5 : Nil))))) -- 8
1 : (2 : (3 : (4 : (append Nil (5 : Nil))))) -- 9
1 : (2 : (3 : (4 : (5 : Nil)))) -- 10
1 : (2 : (3 : (4 : 5 : Nil))) -- 11
1 : (2 : (3 : 4 : 5 : Nil)) -- 12
1 : (2 : 3 : 4 : 5 : Nil) -- 13
1 : 2 : 3 : 4 : 5 : Nil -- 14
-- Left associative: Start
-- List1 List2 List3 List4 List5
(1 : Nil) <> (2 : Nil) <> (3 : Nil) <> (4 : Nil) <> (5 : Nil) -- 0
((1 : Nil) <> (2 : Nil)) <> (3 : Nil) <> (4 : Nil) <> (5 : Nil) -- 0
(((1 : Nil) <> (2 : Nil)) <> (3 : Nil)) <> (4 : Nil) <> (5 : Nil) -- 0
((((1 : Nil) <> (2 : Nil)) <> (3 : Nil)) <> (4 : Nil)) <> (5 : Nil) -- 0
(((append (1 : Nil) (2 : Nil)) <> (3 : Nil)) <> (4 : Nil)) <> (5 : Nil) -- 1
(((1 : (append Nil (2 : Nil))) <> (3 : Nil)) <> (4 : Nil)) <> (5 : Nil) -- 2
(((1 : (2 : Nil)) <> (3 : Nil)) <> (4 : Nil)) <> (5 : Nil) -- 3
(((1 : 2 : Nil ) <> (3 : Nil)) <> (4 : Nil)) <> (5 : Nil) -- 3
-- At this point, we will need to iterate
-- through the List1 all over again!
((append (1 : 2 : Nil) (3 : Nil)) <> (4 : Nil)) <> (5 : Nil) -- 4
((1 : (append (2 : Nil) (3 : Nil))) <> (4 : Nil)) <> (5 : Nil) -- 5
((1 : 2 : (append Nil (3 : Nil))) <> (4 : Nil)) <> (5 : Nil) -- 6
((1 : 2 : (3 : Nil)) <> (4 : Nil)) <> (5 : Nil) -- 7
((1 : 2 : 3 : Nil) <> (4 : Nil)) <> (5 : Nil) -- 7
-- At this point, we will need to iterate
-- through the List1 AND List2 all over again!
(append (1 : 2 : 3 : Nil) (4 : Nil)) <> (5 : Nil) -- 8
(1 : (append (2 : 3 : Nil) (4 : Nil))) <> (5 : Nil) -- 9
(1 : 2 : (append (3 : Nil) (4 : Nil))) <> (5 : Nil) -- 10
(1 : 2 : 3 : (append Nil (4 : Nil))) <> (5 : Nil) -- 11
(1 : 2 : 3 : (4 : Nil)) <> (5 : Nil) -- 12
(1 : 2 : 3 : 4 : Nil) <> (5 : Nil) -- 12
-- At this point, we will need to iterate
-- through the List1 AND List2 AND List3 all over again!
append (1 : 2 : 3 : 4 : Nil) (5 : Nil) -- 13
1 : (append (2 : 3 : 4 : Nil) (5 : Nil)) -- 14
1 : 2 : (append (3 : 4 : Nil) (5 : Nil)) -- 15
1 : 2 : 3 : (append (4 : Nil) (5 : Nil)) -- 16
1 : 2 : 3 : 4 : (append Nil (5 : Nil)) -- 17
1 : 2 : 3 : 4 : (5 : Nil) -- 18
1 : 2 : 3 : 4 : 5 : Nil -- 18
The law of associativity guarantees that the output of a non/left/right-associative function will always be the same. However, the above code demonstrates that one direction of function application (right association) can be faster than another (left association).
So, let's think about why this occured. Due to the way the type is defined, List
must use recursion to get to its tail Nil
before it can replace that tail, Nil
, with the list to which it is being appended. Since List
and Free
are structured similarly, then Free
will also suffer the same performance costs if it uses a left-associative function to reach its tail, Pure
. So, which function does Free
use that acts just like append
? The bind
function. Every bind
/>>=
call will iterate through the entire Free
structure, apply the function to its Pure a
value and then rewrap everything in an Impure
value:
-- Thus, this code...
freeMonad >>= f >>= g >>= h >>= ...
-- is synonymous with the runtime performance hit as this code...
(((list <> f) <> g) <> h) <> ...
When we call freeMonad >>= f
, we iterate through freeMonad
's entire structure. When we take that output and bind
/>>=
it to g
, we iterate through freeMonad
's entire structure plus any new nesting values that f
added to it. When we take that output and bind
/>>=
it to h
, the total cost is freeMonad + f's additional structure + g's additional structure
. As a result, Free
's performance suffers because of its recursive nature.
Is recursion by itself bad? No, recursion can be quite helpful; it's not the problem. Rather, the problem with List
's left-associative <>
function is the slow recursive-time access to List
's tail. Since List
represents a sequence of values, we can fix the append
problem by using a different sequence-like data structure that grants fast constant-time access to its tail. Similarly, to fix the problem with Free
, we should define it differently, so that the "data structure" to which it is similar also has constant-time tail access (i.e. constant time access to its Pure
value).
This sounds easy until you remember what bind
's type signature allows:
bind :: forall a b. m a -> (a -> m b) -> m b
.
In other words, bind
must work "for all a
and b
types" where these types can differ in-between multiple bind
calls.
Fortunately, the paper's authors figured out how to do this using a FingerTree
(data structure with constant time head and tail access) that stores a special type that represents a bind
's type signature and which can be composed just like multiple bind
functions. This "type magic" won't be explained here; you'll need to read the paper (see Section 4 and 5) on your own to understand it fully.
To quote from their documentation, Purescript's Free
monad is "the Free monad implemented in the spirit of [that] paper."
Now that we understand what the Free
monad is, let's see why it's useful.