I'm attempting to structure an AST using the Free monad based on some helpful literature that I've read online.
I have some questions about working with these kinds of ASTs in practice, which I've boiled down to the following example.
Suppose my language allows for the following commands:
{-# LANGUAGE DeriveFunctor #-}
data Command next
= DisplayChar Char next
| DisplayString String next
| Repeat Int (Free Command ()) next
| Done
deriving (Eq, Show, Functor)
and I define the Free monad boilerplate manually:
displayChar :: Char -> Free Command ()
displayChar ch = liftF (DisplayChar ch ())
displayString :: String -> Free Command ()
displayString str = liftF (DisplayString str ())
repeat :: Int -> Free Command () -> Free Command ()
repeat times block = liftF (Repeat times block ())
done :: Free Command r
done = liftF Done
which allows me to specify programs like the following:
prog :: Free Command r
prog =
do displayChar 'A'
displayString "abc"
repeat 5 $
displayChar 'Z'
displayChar '\n'
done
Now, I'd like to execute my program, which seems simple enough.
execute :: Free Command r -> IO ()
execute (Free (DisplayChar ch next)) = putChar ch >> execute next
execute (Free (DisplayString str next)) = putStr str >> execute next
execute (Free (Repeat n block next)) = forM_ [1 .. n] (\_ -> execute block) >> execute next
execute (Free Done) = return ()
execute (Pure r) = return ()
and
λ> execute prog
AabcZZZZZ
Okay. That's all nice, but now I want to learn things about my AST, and execute transformations on it. Think like optimizations in a compiler.
Here's a simple one: If a Repeat
block only contains DisplayChar
commands, then I'd like to replace the whole thing with an appropriate DisplayString
. In other words,
I'd like to transform repeat 2 (displayChar 'A' >> displayChar 'B')
with displayString "ABAB"
.
Here's my attempt:
optimize c@(Free (Repeat n block next)) =
if all isJust charsToDisplay then
let chars = catMaybes charsToDisplay
in
displayString (concat $ replicate n chars) >> optimize next
else
c >> optimize next
where
charsToDisplay = project getDisplayChar block
optimize (Free (DisplayChar ch next)) = displayChar ch >> optimize next
optimize (Free (DisplayString str next)) = displayString str >> optimize next
optimize (Free Done) = done
optimize c@(Pure r) = c
getDisplayChar (Free (DisplayChar ch _)) = Just ch
getDisplayChar _ = Nothing
project :: (Free Command a -> Maybe u) -> Free Command a -> [Maybe u]
project f = maybes
where
maybes (Pure a) = []
maybes c@(Free cmd) =
let build next = f c : maybes next
in
case cmd of
DisplayChar _ next -> build next
DisplayString _ next -> build next
Repeat _ _ next -> build next
Done -> []
Observing the AST in GHCI shows that this work correctly, and indeed
λ> optimize $ repeat 3 (displayChar 'A' >> displayChar 'B')
Free (DisplayString "ABABAB" (Pure ()))
λ> execute . optimize $ prog
AabcZZZZZ
λ> execute prog
AabcZZZZZ
But I'm not happy. In my opinion, this code is repetitive. I have to define how to traverse through my AST every time I want to examine it, or define functions like my project
that give me a view into it. I have to do this same thing when I want to modify the tree.
So, my question: is this approach my only option? Can I pattern-match on my AST without dealing with tonnes of nesting? Can I traverse the tree in a consistent and generic way (maybe Zippers, or Traversable, or something else)? What approaches are commonly taken here?
The whole file is below:
{-# LANGUAGE DeriveFunctor #-}
module Main where
import Prelude hiding (repeat)
import Control.Monad.Free
import Control.Monad (forM_)
import Data.Maybe (catMaybes, isJust)
main :: IO ()
main = execute prog
prog :: Free Command r
prog =
do displayChar 'A'
displayString "abc"
repeat 5 $
displayChar 'Z'
displayChar '\n'
done
optimize c@(Free (Repeat n block next)) =
if all isJust charsToDisplay then
let chars = catMaybes charsToDisplay
in
displayString (concat $ replicate n chars) >> optimize next
else
c >> optimize next
where
charsToDisplay = project getDisplayChar block
optimize (Free (DisplayChar ch next)) = displayChar ch >> optimize next
optimize (Free (DisplayString str next)) = displayString str >> optimize next
optimize (Free Done) = done
optimize c@(Pure r) = c
getDisplayChar (Free (DisplayChar ch _)) = Just ch
getDisplayChar _ = Nothing
project :: (Free Command a -> Maybe u) -> Free Command a -> [Maybe u]
project f = maybes
where
maybes (Pure a) = []
maybes c@(Free cmd) =
let build next = f c : maybes next
in
case cmd of
DisplayChar _ next -> build next
DisplayString _ next -> build next
Repeat _ _ next -> build next
Done -> []
execute :: Free Command r -> IO ()
execute (Free (DisplayChar ch next)) = putChar ch >> execute next
execute (Free (DisplayString str next)) = putStr str >> execute next
execute (Free (Repeat n block next)) = forM_ [1 .. n] (\_ -> execute block) >> execute next
execute (Free Done) = return ()
execute (Pure r) = return ()
data Command next
= DisplayChar Char next
| DisplayString String next
| Repeat Int (Free Command ()) next
| Done
deriving (Eq, Show, Functor)
displayChar :: Char -> Free Command ()
displayChar ch = liftF (DisplayChar ch ())
displayString :: String -> Free Command ()
displayString str = liftF (DisplayString str ())
repeat :: Int -> Free Command () -> Free Command ()
repeat times block = liftF (Repeat times block ())
done :: Free Command r
done = liftF Done
If your issue is with boilerplate, you won't get around it if you use
Free
! You will always be stuck with an extra constructor on each level.But on the flip side, if you are using
Free
, you have a very easy way to generalize recursion over your data structure. You can write this all from scratch, but I used therecursion-schemes
package:If you are unfamiliar with this (read the documentation), but basically all you need to know is
project
takes some data, likeFree f a
, and "un-nests" it by one level, producing something like(f :+: Const a) (Free f a)
. Now, you have given regular functions likefmap
,Data.Foldable.foldMap
, etc, access to the structure of your data, since the argument of the functor is the sub-tree.Executing is very simple, although not much more concise:
However, simplification becomes much easier. We can define simplification over all datatypes which have
Foldable
andUnfoldable
instances:The simplification rule only needs to simplify one level of the AST (namely, the top-most level). Then, if the simplification can apply to the substructure, it will perform it there too. Note that the above
reduce
works bottom up; you can also have a top down reduction:Your example simplification rule can be written very simply:
Because of the way you've defined your datatype, you don't have access to the 2nd arguement of
Repeat
, so for things likerepeat' 5 (repeat' 3 (displayChar 'Z')) >> done
, the innerrepeat
can't be simplified. If this is a situation you expect to deal with, you either change your datatype and accept a lot more boilerplate, or write an exception:Using
recursion-schemes
or the like will probably make your code more easily extensible. But it isn't necessary by any means:getChrs
can't accessPure
, and your programs will be of the formFree Command ()
, so before you apply it, you have to get replace()
withMaybe String
.Note that
reduce
is almost the exact same as before, except for two things:project
andembed
are replaced with pattern matching onFree
andFree
, respectively; and you need a separate case forPure
. This should tell you thatFoldable
andUnfoldable
generalize things that "look like"Free
.All the other functions are modified similarly.
You can certainly do this easier. There's still some work to be done because it won't perform a full optimization in the first pass, but after two passes it fully optimizes your example program. I'll leave that exercise up to you, but otherwise you can do this very simply with pattern matching on the optimizations you want to make. It's still a bit repetitive, but removes a lot of the complication you had:
All I did was pattern match on
repeat n (displayChar c)
,displayChar c1 >> displayChar c2
,displayChar c >> displayString s
,displayString s >> displayChar c
, anddisplayString s1 >> displayString s2
. There are other optimizations that can be done, but this was pretty easy and doesn't depend on scanning anything else, just iteratively stepping over the AST recursively optimizing.Here's my take using syb (as mentioned on Reddit):
Output:
It might be possible to get rid of the *Free*s using GHC 7.8 Pattern Synonyms, but for some reason the above code only works using GHC 7.6, the Data instance of Free seems to be missing. Should look into that...
Please don't think of zippers, traversals, SYB or lens until you've taken advantage of the standard features of
Free
. Yourexecute
,optimize
andproject
are just standard free monad recursion schemes which are already available in the package:Since your components each have at most one continuation you can probably find a clever way to get rid of all those
>> next
too.