[Haskell-cafe] MonadPrompt + Gtk2Hs = ?
Felipe Lessa
felipe.lessa at gmail.com
Sun Jan 13 11:53:14 EST 2008
(This e-mail is a literate Haskell file.)
Ryan Ingram enlightened us with MonadPrompt as a very nice abstraction for
turn-based games, allowing easy programming and testing.
http://www.mail-archive.com/haskell-cafe@haskell.org/msg33040.html
http://ryani.freeshell.org/haskell/
I wonder how nicely it fits on a Gtk2Hs application. =)
> {-# OPTIONS_GHC -fglasgow-exts -Wall #-}
> import Control.Concurrent
> import Control.Concurrent.MVar
> import Control.Monad
> import Control.Monad.Fix
> import Graphics.UI.Gtk
> import System.IO
> import System.Random
Needed for the GADTs and some type signatures.
(yes, I'm stuck with GHC 6.6.1 for now =/ )
While you read the e-mail, choose the appropriate main function
> main :: IO ()
> main = consoleMain
> -- main = gtkApp forkAttempt
> -- main = gtkApp subLoopAttempt
> -- main = gtkApp lastAttempt
For the purposes of this e-mail, I'll present here a simplified version
of his MonadPrompt:
> class Monad m => MonadPrompt p m | m -> p where
> prompt :: p a -> m a
>
> data Prompt (p :: * -> *) r where
> PromptDone :: r -> Prompt p r
> Prompt :: p a -> (a -> Prompt p r) -> Prompt p r
>
> instance Monad (Prompt p) where
> return = PromptDone
> PromptDone x >>= f = f x
> Prompt p cont >>= f = Prompt p ((>>= f) . cont)
>
> instance MonadPrompt p (Prompt p) where
> prompt p = Prompt p return
With the monad above, we may program a simple guessing game. This is
based on http://ryani.freeshell.org/haskell/Main.lhs and you
can do every analysis done here with his sources, but I'm again trying
to keep everything as simple as possible.
> guessGame :: MonadPrompt GuessP m => Int -> m Int
> guessGame answer = guessMe 1 where
> guessMe tries = do
> prompt (Print $ "Guess #" ++ show tries ++ ":")
> guess <- prompt Guess
> if guess == answer
> then do prompt (Print "Right!")
> return tries
> else do prompt (Print $ "You guessed too " ++
> if guess < answer then "low" else "high" ++
> "! Try again.")
> guessMe (tries + 1)
Okay, so our game do prompts over the GuessP data type,
> data GuessP a where
where you may ask the user for a guess
> Guess :: GuessP Int
or you may show him some info about the game.
> Print :: String -> GuessP ()
To play our little game, we have to execute the Prompt. We have
> runPromptM :: Monad m => (forall a. p a -> m a) -> Prompt p r -> m r
> runPromptM _ (PromptDone result) = return result
> runPromptM f (Prompt p cont) = f p >>= runPromptM f . cont
which basically maps prompts to actions on some monad -- in particular, IO.
Doing a console interface isn't hard at all:
> consolePrompt :: forall a. GuessP a -> IO a
> consolePrompt (Print s) = putStrLn s
> consolePrompt Guess = fmap read getLine
> guessGameNew :: MonadPrompt GuessP m => IO (m Int)
> guessGameNew = randomRIO (1, 10) >>= return . guessGame
> consoleMain :: IO ()
> consoleMain = do
> hSetBuffering stdout NoBuffering
> game <- guessGameNew
> attempts <- runPromptM consolePrompt game
> putStrLn $ "You took " ++ show attempts ++ " attempts."
That's really cool =). However, mapping runPromptM into a Gtk2Hs application
isn't easy at all (we'll see why shortly). Before that, let's take the common
blocks of code away.
All of our attempts will develop a function that takes a function for showing
info, an entry for reading guesses and a button that will be clicked to make
the guess.
> type AttemptCode = (String -> IO ()) -> Entry -> Button -> IO ()
>
> gtkApp :: AttemptCode -> IO ()
> gtkApp run = do
> unsafeInitGUIForThreadedRTS -- allows runghc
> w <- windowNew
> w `onDestroy` mainQuit
> container <- vBoxNew False 7
> w `containerAdd` container
>
> let showInfo info = do m <- messageDialogNew (Just w) [] MessageInfo
> ButtonsOk info
> dialogRun m >> widgetDestroy m
>
> entry <- entryNew
> boxPackStart container entry PackNatural 0
>
> button <- buttonNewWithLabel "Guess"
> boxPackStart container button PackNatural 0
>
> widgetShowAll w
> timeoutAdd (run showInfo entry button >> return False) 0
> mainGUI
With everything in place, let's start!
The first naïve attempt would be to just connect every point
> {-
> naïveAttempt :: AttemptCode
> naïveAttempt showInfo entry button = do
> game <- guessGameNew
> let mapping (Print s) = showInfo s
> mapping Guess = -- Oops
> -}
It's easy to see that we can't get the guess from our user using a function
like 'getLine'. The problem lies in the fact that Gtk is event-driven, so
every time we ask the user for something, we have to wait for the
corresponding event that will bring us his answer. 'runPromptM' basically
creates one big monolithic monad that will run from the beginning to the end
of the game -- exactly the same thing 'mainGUI' does!
The standard way of solving the problem of running two sequential things at
once is using threads, and this solution is specially appealing since
Control.Concurrent simplifies the matters *a lot*. So let's try this instead
> forkAttempt :: AttemptCode
> forkAttempt showInfo entry button = do
> game <- guessGameNew
> forkIO $ do attempts <- runPromptM forkedMapping game
> postGUIAsync (showInfo $ "You took " ++ show attempts ++
> " attempts.")
> return ()
> where
> forkedMapping :: forall a. GuessP a -> IO a
> forkedMapping (Print s) = postGUIAsync (showInfo s)
> forkedMapping Guess = do
> v <- newEmptyMVar
> cid <- postGUISync $ onClicked button $ entryGetText entry
> >>= putMVar v
> guess <- takeMVar v -- f1
> postGUISync (signalDisconnect cid) -- f2
> return (read guess)
Problem solved? Not really:
- This kind of implementation hides lots of subtle bugs. For example,
because of postGUIAsync being used in Print case, the user will see
multiple dialog boxes at once and -- strangely enough -- he'll see
first the last message printed. It isn't always easy to see
this kind of bug at first sight, and it can be very hard to track it down.
- Another problem may happen with scheduling. For some reason, there are
times in which it takes some time for the control to pass from the Gtk
thread to the forkIO one, effectively 'freezing' the game for some time.
Unfortunately this problem doesn't show up above, but I have experienced
it on a larger game I'm currently programming using Prompt.
- It is possible that the user clicks on the button between f1 and f2.
Again, on this very simple example nothing seems to go wrong, but there
shouldn't be anything between f1 and f2 as the GUI is on an inconsistent
state.
It should be noted that the scheduling problem can be mitigated using 'yield'
on some key spots. This not only feels hackish, but also doesn't scale very
well.
Another approach that is sometimes adopted to solve this kind of problem is
creating a main "sub-loop" with 'mainIteration'. This essentially removes the
need for those nasty evil threads =).
> subLoopAttempt :: AttemptCode
> subLoopAttempt showInfo entry button = do
> game <- guessGameNew
> attempts <- runPromptM subLoopMapping game
> showInfo $ "You took " ++ show attempts ++ " attempts."
> where
> subLoopMapping :: forall a. GuessP a -> IO a
> subLoopMapping (Print s) = showInfo s
> subLoopMapping Guess = do
> v <- newEmptyMVar
> cid <- onClicked button $ entryGetText entry >>= putMVar v
> guess <- subLoopGetMVar v
> signalDisconnect cid
> return (read guess)
Here comes the magic!
> subLoopGetMVar :: MVar a -> IO a
> subLoopGetMVar v = do
> m <- tryTakeMVar v
> case m of
> Just r -> return r
> Nothing -> do
> quitting <- mainIteration
> when quitting (fail "quitting")
> subLoopGetMVar v
There are a couple of pitfalls in this approach as well:
- The quitting code doesn't work very well anymore. Try to close the
window before guessing right and you'll see an "user error" on the console.
Some 'bracket' magic is needed to get out of the subloop without throwing
errors at the user's face.
- Every time we get into a subloop we add an overhead for every event the
application receives. I don't know if this is important at all, maybe
with lots of nested subloop.
- The real problem is: what if the MVar gets full but does not generate
an event? While 'subLoopGetMVar' waits for 'mainIteration', the game
code should be executing already for a long time!
Unfortunately I couldn't come up with a simple example that exposes the last
problem listed above. But, for example, some network code could fill that
MVar in a multiplayer internet game. In this case the game wouldn't proceed
until an event was generated. It's known that to partially solve this problem
it is possible to create a signal generator that runs every X milliseconds,
giving an upper bound to the amount of time between putMVar being called and
subLoopGetMVar finishing. This feels hackish and fragile as well, specially
because it is difficult to hunt down bugs.
But Prompt has a very *very* nice property we're missing to take advantage of
here: it's is *pure*. In fact, I think that is one of the reasons why Ryan
bothered sending us an e-mail -- you can not only plug different interfaces
codes, but also plug no interface at all to make tests, with purity making
matters very simple.
Ryan also notes that
"If you wanted to add undo, all you have to do is save off the current Prompt
in the middle of runPromptM; you can return to the old state at any time."
Eventually this feature rang some bells: you can save not only when you
want to undo, but also when you want to ask something to the user.
Unfortunately, I still haven't come up with a nice higher order function
that generalizes this work without reinventing Prompt on an isomorphic type.
Enough said, let's see how:
> lastAttempt :: AttemptCode
> lastAttempt showInfo entry button = do
> game <- guessGameNew
> runPromptM' game
> where
> -- Signature required
> runPromptM' :: Prompt GuessP Int -> IO () -- note: not (IO Int)!
> runPromptM' (Prompt (Print s) c) = showInfo s >> runPromptM' (c ())
> runPromptM' (Prompt Guess c) = do
> mfix $ \cid -> do
> let cont guess = do {signalDisconnect cid;
> runPromptM' (c $ read guess)}
> onClicked button $ entryGetText entry >>= cont
> return ()
> runPromptM' (PromptDone attempts) = do
> showInfo $ "You took " ++ show attempts ++ " attempts."
After so many attempts looking with some much similar code, it may be hard
to see exactly how our last attempt work. No threads, no MVars, no nothing.
So I took out features, and now everything is better? =)
Basically, we first assume that every time 'runPromptM'' is called, we're
on Gtk's thread (which is easy in this case since there are no threads at all).
Next, we see if we need to *wait* for something.
In the Print case, we just call 'showInfo', so the code is the same as
expanding 'subLoopMapping (Print s)' inside 'runPromptM'. This means that
the Gtk event that called 'runPromptM'' will continue to execute the the
next call to 'runPromptM'' as well. This is very nice, since it introduces
no delays to the user (imagine a non-blocking showInfo -- e.g. printing on
a textview). The PromptDone case is also very similar to what has been
done before.
However, in the Guess case we connect a signal to the button and return!
This is where things get very different from the other approaches. When using
'forkIO', the forked thread would run from the game start until its end. When
using 'mainIteration', the event that called 'subLoopAttempt' (in our case,
the timeout) would execute until the end of the game. Instead, 'lastAttempt'
will run only until the first Guess.
Okay, so how do we proceed from here? The continuation of the 'Prompt'
constructor goes inside 'cont's closure. When the user clicks on the button,
it disconnects the signal and calls 'runPromptM'' again. Everything happens
sequentially as I'm saying because there aren't any other threads playing
with our continuation, so this not only removes the need for MVars but also
fixes the whole problem of "will this run in the middle of that?". Even if
the user could click twice at the same time, the Gtk main loop would execute
only one event callback "concurrently" and by the time the other event gets
its chance to execute we'll have disconnected its signal handler already.
It's true that *another* callback would be connected if the guess was not
right, but that doesn't cause any inconsistencies at all. Note also that
we could have, for example, two buttons sharing the same continuation if
their callbacks disconnected both 'ConnectID's before going on.
No freezes, no races, no exceptions, no overheads. No generic
'runPromptM' abstraction as well, but I think the price is worth paying,
specially because the other approaches have nasty subtle bugs. And we continue
to have the possibility of using 'runPromptM' in our tests, for example.
--
Oh, well. This e-mail got longer than I initially imagined, thanks God I
started typing it on emacs already =).
I'm looking forward suggestions on improvements and critics about problems
in this last approach. Unfortunately the game I'm developing is still in an
early stage, but I promise to release it under the GPL sometime soon =).
Also, I welcome any meaningful comparison between 'lastAttempt' and 'callCC'.
Thanks for reading until here,
--
Felipe.
More information about the Haskell-Cafe
mailing list