Using IO without the IO Monad!
(This post is also available as a YouTube video!)
In last week's article, I explained what effects really are in the context of Haskell and why Haskell's structures for dealing with effects are really cool and distinguish it from other programming languages.
Essentially, Haskell's type system allows us to set apart areas of our code that might require a certain effect from those that don't. A function within a particular monad can typically use a certain effect. Otherwise, it can't. And we can validate this at compile time.
But there seems to be a problem with this. So many of Haskell's effects all sort of fall under the umbrella of the IO monad. Whether that's printing to the terminal, or reading from the file system, using threads and concurrency, connecting over the network, or even creating a new random number generator.
putStrLn :: String -> IO ()
readFile :: FilePath -> IO String
readMVar :: MVar a -> IO a
httpJSON :: (MonadIO m, FromJSON a) => Request -> m (Response a)
getStdGen :: MonadIO m => m StdGen
Now I'm not going to tell you "oh just re-write your program so you don't need as much IO." These activities are essential to many programs. And often, they have to be spread throughout your code.
But the IO monad is essentially limitless in its abilities. If your whole program uses the IO monad, you essentially don't have any of the guarantees that we'd like to have about limiting side effects. If you need any kind of IO, it seems like you have to allow all sorts of IO.
But this doesn't have to be the case. In this article we're going to demonstrate how we can get limited IO effects within our function. That is, we'll write our type signature to allow a couple specific IO actions, without opening the door to all kinds of craziness. Let's see how this works.
An Example Game
Throughout this video we're going to be using this Nim game example I made. You can see all the code in Game.hs
.
Our starting point for this article is the instances
branch.
The ending point is the monad-class
branch.
You can take a look at this pull request to see all the changes we're going to make in this article!
This program is a simple command line game where players are adding numbers to a sum and want to be the one to get to exactly 100. But there are some restrictions. You can't add more than 10, or add a negative number, or add too much to put it over 100. So if we try to do that we get some of these helpful error messages. And then when someone wins, we see who that is.
Our Monad
Now there's not a whole lot of code to this game. There are just a handful of functions, and they mostly live in this GameMonad
we created. The "Game Monad" keeps track of the game state (a tuple of the current player and current sum value) using the State
monad. Then it also uses the IO
monad below that, which we need to receive user input and print all those messages we were seeing.
newtype GameMonad a = GameMonad
{ gameAction :: StateT (Player, Int) IO a
} deriving (Functor, Applicative, Monad)
We have a couple instances, MonadState
, and MonadIO
for our GameMonad
to make our code a bit simpler.
instance MonadIO GameMonad where
liftIO action = GameMonad (lift action)
instance MonadState (Player, Int) GameMonad where
get = GameMonad get
put = GameMonad . put
Now the drawback here, as we talked about before, is that all these GameMonad
functions can do arbitrary IO. We just do liftIO
and suddenly we can go ahead and read a random file if we want.
playGame :: GameMonad Player
playGame = do
promptPlayer
input <- readInput
validateResult <- validateMove input
case validateResult of
Nothing -> playGame
Just i -> do
# Nothing to stop this!
readResult <- liftIO $ readFile "input.txt"
...
Making Our Own Class
But we can change this with just a few lines of code. We'll start by creating our own typeclass. This class will be called MonadTerminal
. It will have two functions for interacting with the terminal. First, logMessage
, that will take a string and return nothing. And then getInputLine
, that will return a string.
class MonadTerminal m where
logMessage :: String -> m ()
getInputLine :: m String
How do we use this class? Well we have to make a concrete instance for it. So let's make an instance for our GameMonad
. This will just use liftIO
and run normal IO actions like putStrLn
and getLine
.
instance MonadTerminal GameMonad where
logMessage = liftIO . putStrLn
getInputLine = liftIO getLine
Constraining Functions
At this point, we can get rid of the old logMessage
function, since the typeclass uses that name now. Next, let's think about the readInput
expression.
readInput :: GameMonad String
readInput = liftIO getLine
It uses liftIO
and getLine
right now. But this is exactly the same definition we used in MonadTerminal
. So let's just replace this with the getInputLine
class function.
readInput :: GameMonad String
readInput = getInputLine
Now let's observe that this function no longer needs to be in the GameMonad
! We can instead use any monad m
that satisfies the MonadTerminal
constraint. Since the GameMonad
does this already, there's no effect on our code!
readInput :: (MonadTerminal m) => m String
readInput = getInputLine
Now we can do the same thing with the other two functions. They call logMessage
and readInput
, so they require MonadTerminal
. And they call get
and put
on the game state, so they need the MonadState
constraint. But after doing that, we can remove GameMonad
from the type signatures.
validateMove :: (MonadTerminal m, MonadState (Player, Int) m) => String -> m (Maybe Int)
...
promptPlayer :: (MonadTerminal m, MonadState (Player, Int) m) => m ()
...
And now these functions can no longer use arbitrary IO! They're still using using the true IO effects we wrote above, but since MonadIO
and GameMonad
aren't in the type signature, we can't just call liftIO
and do a file read.
Of course, the GameMonad
itself still has IO on its Monad stack. That's the only way we can make a concrete implementation for our Terminal class that actually does IO!
But the actual functions in our game don't necessarily use the GameMonad
anymore! They can use any monad that satisfies these two classes. And it's technically possible to write instances of these classes that don't use IO. So the functions can't use arbitrary IO functionality! This has a few different implications, but it especially gives us more confidence in the limitations of what these functions do, which as a reminder, is considered a good thing in Haskell! And it also allows us to test them more easily.
Conclusion: Effectful Haskell
Hopefully you think at least that this is a cool idea. But maybe you're thinking "Woah, this is totally game changing!" If you want to learn more about Haskell's effect structures, I have an offer for you!
If you head to this page you'll learn about our Effectful Haskell course. This course will give you hands-on experience working with the ideas from this video on a small but multi-functional application. The course starts with learning the different layers of Haskell's effect structures, and it ends with launching this application on the internet.
It's really cool, and if you've read this long, I think you'll enjoy it, so take a look! As a bonus, if you subscribe to Monday Morning Haskell, you can get a code for 20% off on this or any of our courses!