10 Minute Vim!

Wrangling State In Haskell

· Read in about 5 min · (883 Words)

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 Filename can be easily unnamed. We did use it in loadFile and clearFile. It does allow the “differences” to stand out more. For example, clearFile is just a saveFile with an empty string for the first parameter. We can see the differences clearly without the extra parameter adding noise.

We added it to appendToFile, 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 <- ask
  liftIO $ Str.writeFile fileName (BS.pack contents)

clearFile :: ReaderT Filename IO ()
clearFile = saveFile ""

appendToFile :: String -> 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 appendToFile and clearFile have the signature: ReaderT Filename IO (), indicating that anything below them can ask for the Filename, while still performing an IO action. The “entry-point” calls in main need to be initialized with the runReaderT and the Filename we want to pass.

For this case, the ReaderT is substantially more readable. The “business value” functions appendToFile and clearFile do not have to define and pass the parameters needed for the lower level functions saveFile and loadFile. Reader Type gives us the value of the Unnamed Parameters for legibility!

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 Filename are able to call ask 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 to Clojure, 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 appendToFile use point-free style.


software developer, manager, author, speaker