Part 2 of my series “Wrangling State”. Part 1 Wrangling State In Clojure
Haskell is a pure language, so you can only deal with application state by passing parameters to functions. It is possible to pass parameters more conveniently, but ultimately, every parameter needs to be passed.
Here is a simple application for logging a timestamp to a file.
First, Pass As Parameter
loadFile :: Filename -> IO String loadFile fileName = BS.unpack Str.readFile fileName saveFile :: Filename -> String -> IO () saveFile fileName contents = Str.writeFile fileName (BS.pack contents) clearFile :: Filename -> IO () clearFile fileName = saveFile fileName "" appendToFile :: Filename -> String -> IO () appendToFile fileName stuff = do contents <- loadFile fileName saveFile fileName (contents++stuff) main fileName "-c" = clearFile fileName main fileName "-log" = do now <- getCurrentTime appendToFile fileName ((show now)++ "n")
We take in the file name and the command to perform, either to clear the file or to append a new timestamp. While simple, this gets cumbersome in a large application. Imagine passing a database connection through every single function that eventually calls the database.
Haskell can have unnamed parameters that are not defined in the argument list. Sometimes this can improve legibility, other times it can worsen it. To use this feature, the function signature must contain the value missing. The parameter(s) must be the “last” parameter(s) to the function for this to work.
Here is the same code with Unnamed Parameters
loadFile :: Filename -> IO String loadFile = (liftM BS.unpack) . Str.readFile saveFile :: String -> Filename -> IO () saveFile contents fileName = Str.writeFile fileName (BS.pack contents) clearFile :: Filename -> IO () clearFile = saveFile "" appendToFile :: String -> Filename -> IO () appendToFile stuff = (>>=) loadFile ((. (++stuff)) . (flip saveFile)) main fileName "-c" = clearFile fileName main fileName "-log" = do now <- getCurrentTime appendToFile ((show now)++ "n") fileName
Not all usages of
can be easily unnamed. We did use it in
. It does allow the “differences” to stand out more. For example,
is just a
with an empty string for the first parameter. We can see the differences clearly without the extra parameter adding noise.
We added it to
, using point-free style. I find that it makes it much harder to scan and read.
Lastly, it is possible to encode such values into the type. The type of the function itself can imply a value that can be retrieved. For example, the Reader type can be combined with the IO type using ReaderT.
Here is the code using the Reader Type
loadFile :: ReaderT Filename IO String loadFile = do fileName <- ask liftIO $ BS.unpack Str.readFile fileName saveFile :: String -> ReaderT Filename IO () saveFile contents = do fileName ReaderT Filename IO () appendToFile stuff = do contents <- loadFile saveFile (contents++stuff) main fileName "-c" = runReaderT clearFile fileName main fileName "-log" = do now <- getCurrentTime runReaderT (appendToFile ((show now)++ "n")) fileName
Notice now how
have the signature:
ReaderT Filename IO ()
, indicating that anything below them can
for the Filename, while still performing an
action. The “entry-point” calls in
need to be initialized with the
we want to pass.
For this case, the
is substantially more readable. The “business value” functions
do not have to define and pass the parameters needed for the lower level functions
. Reader Type
gives us the value of the Unnamed Parameters
For something like a database connection that might be used pervasively, the Reader Type
is essential for legible code. The low level functions that need the
are able to call
to retrieve it.
|Dependencies||Complexity||Adding New State||Best When|
|Pass As Parameter||Explicit||Less Complex||Harder||State only needed in a few functions|
|Unnamed Parameter||Explicit||Less Complex||Harder||Functions can be made more readable|
|Reader Type||Explicit||More Complex||Easier||State needed throughout the application|
Compared toClojure, Haskell has no way to call a function “incorrectly”. All in-memory state is passed explicitly.
Haskell’s type system prevents the programmer from forgetting state. Unfortunately, it is still possible to pass as any parameter a value that is invalid. The explicit nature of Haskell parameters does not prevent passing a database connection string that does not exist, or a pointer to an incorrectly setup data structure.
Haskell is opinionated, and forces you to consider all the state up front before calling a function. While this makes it harder to forget about state, it also makes abstractions more leaky. Instead of relying on a function which may or may not use a database, you must know and pass the database connection.
Even though I believe the Haskell type system makes abstractions more leaky, I prefer having to think up front about all my state. I find it makes the code more clear, and helps me control what functions have access to state.
Edit: Thanks to /u/kccqzy
on reddit for offering a way to make
use point-free style.