- Por Liam Griffin-Jowett
- ·
- Publicado 11 Feb 2019
Last post we looked at dealing with state when using our bank account. Here's a recap of the code we ended up with:
-- our bank functions
deposit :: Int -> State [Transaction] ()
deposit amount = modify (\transactions -> transactions ++ [Deposit amount])
withdraw :: Int -> State [Transaction] ()
withdraw amount = modify (\transactions -> transactions ++ [Withdrawal amount])
getStatement :: State [Transaction] String
getStatement = gets generateStatement -- the details of generateStatement are out of scope of this post
-- and here's the usage
main = do
let statement = evalState useMyBank []
print statement
useMyBank :: State [Transaction] String
useMyBank = do
deposit 200
withdraw 100
getStatement
Notice how the user has to get the statement and print it to the console, whereas the bank kata states that our library code should have that responsibility.
In order to print to the console, we need to use the IO
monad. Here's a function that prints using IO
:
putStr :: String -> IO ()
putStr
will take a String
to be printed and return IO
of unit. We want to use this function with the result of generateStatement
.
Our first attempt might look something like this:
printStatement :: State [Transaction] ()
printStatement = do
transactions <- get
let statement = generateStatement transactions
putStr statement
But this won't compile:
-- compiler output simplified for brevity
Couldn't match type ‘IO’
with ‘State [Transaction]’
Expected type: State [Transaction] ()
Actual type: IO ()
To use putStr
we need a type of IO
, but our type is State [Transaction]
! putStr
uses a different type of monad and that doesn't compose with our State
.
To use two or more monads together, you need to use a monad transformer. What does that mean? The simplest definition I've seen is this:
A monad transformer takes something that does one thing, and then adds the capability to do another.
In our case, our current monad does one thing (deals with state), and we want to add the capability to output text to the console.
To do this we will use a monad transformer called StateT
. This has all the functions State
has, plus the ability to use IO
or any other monad too. The StateT
general type is StateT s m a
, where s
is our type of state, m
is the monad capability we want to add, and a
is our return value. As you can see it almost the same type as State
, but with an added m
. With this we can write our printStatement
in almost the same way we specified earlier.
printStatement :: StateT [Transaction] IO ()
printStatement = do
transactions <- get
let statement = generateStatement transactions
lift (putStr statement)
Notice how the type of printStatement
has changed from State [Transaction] ()
to StateT [Transaction] IO ()
.
Let's explain lift
. The type of putStr "a string"
is IO ()
. This doesn't match the type of printStatement
, we need to make it match. This is what lift does for us.
lift :: m a -> t m a
-- more concretely for our use case
lift :: IO () -> StateT [Transaction] IO ()
putStr
will still work as we expect it to work, but now it's type matches so we can use it within printStatement
.
Users of our code can now tell us to print a statement instead of doing it themselves. To do this there is a runStateT
function, just as there is a runState
for State
types.
main = do
runStateT useMyBank []
pure () -- we need to return IO () for main
useMyBank :: StateT [Transaction] IO ()
useMyBank = do
deposit 200
withdraw 100
printStatement
Side note: we'll also need to change the type signature of our deposit
and withdraw
methods to StateT [Transaction] IO ()
, but the function implementations don't need to change which is pretty cool.
Uh oh, we've lost our ability to test the statement output, as it is printed as a side effect and not returned.
it "sends statement to the aether" $ do
runStateT printStatement [Deposit 100] `shouldBe` ... -- the return type is IO ((), [Transaction]), statement is gone
We need to abstract the printing in some way, as it is at the boundary of our system - just like we would in an OOP language. There are two main options that I know of:
printStatement
function (Inspired by this blog post).m
in StateT
, which specifies the statement printing behaviour we want. Think of this like an interface in C#/Java.Simple enough, we will make an inner printStatement
function that takes as a parameter something that prints. We will specify 'something that prints' to be a monad m ()
, i.e. something that does a side effect and returns nothing. Notice how we've generalised the type away from IO ()
, which means for testing we can specify a different monad which stores the side effect so that we can test the intended output.
printStatement :: StateT [Transaction] IO ()
printStatement = innerPrintStatement putStr
innerPrintStatement :: Monad m => (String -> m ()) -> StateT [Transaction] m ()
innerPrintStatement printer = do
transactions <- get
let statement = generateStatement transactions
lift (printer statement)
We can now test the innerPrintStatement. Since the m
is polymorphic, we can swap out the IO
for a different monad - Writer String
, which will store our printed statement for us to test.
testPrintStatement :: StateT [Transaction] (Writer String) ()
testPrintStatement = innerPrintStatement (\statement -> tell statement)
it "prints a statement" $ do
-- evalStateT works just like evalState, except it will return us a `Writer String ()` instead of just `()`
-- we can then use execWriter to get the String from Writer String ()
execWriter (evalStateT testPrintStatement [Deposit 100, Withdrawal 50]) `shouldBe` "Deposited 100\nWithdrew 50"
That wasn't so bad :) but there are two things I personally don't like.
innerPrintStatement
rather than the function actually being used by our users.Monad m =>
is far too generic and doesn't relay the intent of the statement printing code.Not to worry, from here it's quite easy to refactor to our other solution, which solves these problems.
In haskell, type constraints are used to so that we have access to more functions to deal with our datatypes. As a small example, consider this.
areTheseEqual :: a -> a -> Bool
areTheseEqual a b = a == b
Trying to compile this throws an error: No instance for (Eq a) arising from a use of ‘==’
. Our type a
in the signature is as polymorphic as it gets. We know nothing about it, including whether two of that type can be compared for equality.
The answer to this is hinted in the compiler output - we need to specify that a
is an instance of the Eq
class. If we do that we know we will have an ==
method available.
Brief explanation aside, let's create a typeclass that represents the intent of printing a statement.
class MonadStatementPrinter m where
printStmt :: String -> m ()
Now we can add a type constraint to our m
in printStatement
, such that any m
that is used must have a printStmt
function with the type signature above.
printStatement :: (Monad m, MonadStatementPrinter m) => StateT [Transaction] m ()
printStatement = do
transactions <- get
let statement = generateStatement transactions
lift $ printStmt statement
Cool. Building this makes the compiler spew an error (we'll talk about the compiler errors in the tests later):
Main.hs:18:3: error:
• No instance for (MonadStatementPrinter IO)
arising from a use of ‘printStatement’
We have our typeclass constraint (interface), but nothing implementing it! Let's make IO
implement our interface so we can print to the console.
instance MonadStatementPrinter IO where
printStmt = putStr
Even cooler. This works without any changes to the usage of our code. What's also good is that though we have our default implementation for IO, our users can also specify their own instance should they need to do something else.
Now for testing. We're getting a similar error to above: No instance for (MonadStatementPrinter (Writer String))
. We just need an instance of Writer
for our MonadStatementPrinter
typeclass!
instance MonadStatementPrinter (Writer String) where
printStmt = tell
Awesome. Now let's clear up the testing for deposit
and withdraw
. We don't need our MonadStatementPrinter
constraint for these functions so we can use a simpler monad called Identity
that does nothing, and returns our result.
it "deposits money" $ do
runIdentity (execStateT (deposit 100) newBank) `shouldBe` [Deposit 100]
it "withdraws money" $ do
runIdentity (execStateT (withdraw 100) newBank) `shouldBe` [Withdrawal 100]
Et voila! Our functions are both dealing with state and printing, and are covered by tests.
Software es nuestra pasión.
Somos Software Craftspeople. Construimos software bien elaborado para nuestros clientes, ayudamos a los/as desarrolladores/as a mejorar en su oficio a través de la formación, la orientación y la tutoría. Ayudamos a las empresas a mejorar en la distribución de software.