Migrating code to the Reader Monad
The Reader Monad is one of the famous trio: Reader/Writer/State. They are all really powerful abstractions and fit specific scenarios like being able to read global configuration, being able to trace the execution of the program or being able to run stateful computations.
In this post, I will share how to evolve a program using explicit configuration parameter into a program that uses the Reader Monad.
The base program
Imagine we wrote a Haskell program which:
- Establishes a connection to the memcache server using the memcache package
- Reads and prints the current value for the
counter
key - Stores
"1"
under thecounter
key - Reads and prints the current value for the above key
- Stores
"2"
under thecounter
key - Reads and prints the current value for the above key
Here are the necessary imports:
{-# LANGUAGE OverloadedStrings #-}
module Main where
import qualified Database.Memcache.Client as MemcacheClient
import qualified Database.Memcache.Types as MemcacheTypes
import Data.Maybe (Maybe(..))
A type alias for memcache result:
type MemcacheResult = Maybe (MemcacheTypes.Value, MemcacheTypes.Flags, MemcacheTypes.Version)
A set of methods to establish the connection, store values in and retrieve from the server:
connection :: IO MemcacheClient.Client
connection = MemcacheClient.newClient [MemcacheClient.def] MemcacheClient.def
-- Example: set connection "counter" "1"
set :: MemcacheClient.Client -> MemcacheTypes.Key -> MemcacheTypes.Value -> IO MemcacheTypes.Version
set connection key value = MemcacheClient.set connection key value 0 0
-- Example: get connection "counter"
get :: MemcacheClient.Client -> MemcacheTypes.Key -> IO MemcacheResult
get = MemcacheClient.get
And finally - the actual program:
main :: IO ()
main = do
conn <- connection
get conn "counter" >>= print
set conn "counter" "1"
get conn "counter" >>= print
set conn "counter" "2"
get conn "counter" >>= print
return ()
-- First run:
-- Nothing
-- Just ("1",0,1)
-- Just ("2",0,2)
--
-- Consequent run:
-- Just ("2",0,2)
-- Just ("1",0,3)
-- Just ("2",0,4)
The transition to the Reader Monad
After installing the mtl package we can make use of the Reader Monad to make the above functions a bit easier to use.
We start by adding some additional imports:
import Data.Function (flip)
import Control.Monad.Reader (ReaderT, runReaderT, ask)
import Control.Monad.IO.Class (liftIO)
Next, we change the get
and set
helper functions:
-- Example: set "counter" "1"
set :: MemcacheTypes.Key -> MemcacheTypes.Value -> ReaderT MemcacheClient.Client IO MemcacheTypes.Version
set key value = do
connection <- ask
liftIO $ MemcacheClient.set connection key value 0 0
-- Example: get "counter"
get :: MemcacheTypes.Key -> ReaderT MemcacheClient.Client IO MemcacheResult
get key = do
connection <- ask
liftIO $ MemcacheClient.get connection key
and finally we change the main
function to call runReaderT
with the connection
used as the configuration/environment being read with ask
calls:
main :: IO ()
main = do
conn <- connection
flip runReaderT conn $ do
get "counter" >>= liftIO . print
set "counter" "1"
get "counter" >>= liftIO . print
set "counter" "2"
get "counter" >>= liftIO . print
return ()
-- First run:
-- Nothing
-- Just ("1",0,1)
-- Just ("2",0,2)
--
-- Consequent run:
-- Just ("2",0,2)
-- Just ("1",0,3)
-- Just ("2",0,4)
Full code listing: src/Main.hs
Summary
With these small changes, we managed to transform the base program to pass connection to helper functions from one place thanks to how the reader monad works. The actual behavior of the program didn't change.
In the real world, one would pass a record full of configuration options — database host, api endpoints, environment type etc. — to the runReaderT
call.
Do you find the second version better or do you think it's worse? Let me know on twitter - @maciejsmolinski.