Previously we looked at the hidden meanings appended to the term “functional programming” by many Haskell developers that is assumed in their usage of the term that goes beyond most definitions of functional programming.
Today we embark on a winding journey into Haskell’s techniques for containing those troublesome side effects that so bedevil our pursuit of pure functional bliss. But before skipping merrily down this path, let us pause to recall key insights from our prior installment…
In our last post in this series, we explored Haskell’s unyielding commitment to well-typedness , like a monk devoted to maintaining perfect mindfulness. We saw how Haskell empowers developers to construct rich type systems that elegantly capture domain-specific constraints and invariants.
Haskell’s Approach to Containing Side Effects
Yet real-world programs cannot live by purity alone. Applications must interact with imperfect external environments rife with non-determinism and side effects. What is a functional programmer to do? Despair? Abandon hope? Retreat to a mountain cave for contemplation and prayer? Thankfully not! Haskell offers us multiple techniques to isolate and manage side effects in a functional way to varying degrees.
Today we inspect Haskell’s primary tool for segregating impurity: the IO
monad. This construct allows us to embed side-effecting actions in a pure functional setting.
Dividing Pure and Impure Code
Haskell2010 draws a clear line between pure and impure code.
Pure functions are only based on their inputs (arguments), without any side effects (or global constants). This means pure functions are independent of the external world, making them easier to reason about, test, and understand.
Impure code interacts with files, databases, the network, or other behavior yielding non-deterministism. In Haskell, (I am using Haskell 2010) we confine side effecting code within the confines of the IO
monad (at the lowest level) and call them actions.
Setting up GHC with Nix
Let’s get started with a simple Nix flake that will support our code exploration in this post.
TODO: Fill this in after work tomorrow.
The IO
Monad: Isolating Impurity
Let’s illustrate this with an example. Consider the following pure function that takes a String
and produces another String
uppercased:
upcase :: String -> String
upcase = map toUpper
The type signature is a pure function, taking a String
and returning a String
.
Now let’s say we wanted to print the computation to the user’s terminal? This requires we interact with the environment beyond the unit of code’s boundary’s, so we have side effects. To use Haskell’s lowest level of encapsulating side effects we can wrap the action and pure computation in IO
like so:
upcaseAndPrint :: String -> IO ()
upcaseAndPrint = upcase <&> putStrLn
We can see by inspecting the type signature of upcaseAndPrint
that we are wrapping side effecting code yielding a ()
in the context of IO
. But what does this really do for us?
Benefits of the IO Monad over Leaking Side Effects
The IO monad delivers several jolly benefits:
- Demarcates pure vs impure portions, aiding reason and testing
- Sequences effects in a controlled manner based on bindings
- Isolates effects from infecting other code
- Provides monadic guarantees about the encapsulated effects
So IO enables harnessing effects while maintaining separation of concerns and reasonability. Well played, Haskell!
A Better Example
Let’s suppose we need to build the simplest document management system. We will assume:
- we can get the user value for a given user identity if it exists
- we can get the list of documents associated with a given user identity
We will assume through all the effect systems that the following data type definitions exist:
newtype UserId = UserId Int
data User
= User
{ userId :: UserId
, login :: String
, fullName :: String
}
data Document
= Document
{ title :: String
, content :: String
}
This roughly translates to the following type signatures:
getUserById
:: UserId
-> User
getDocumentsByUserId
:: UserId
-> [Document]
The problem with these type signatures is in the case where we need to interact with the world outside our program, like querying a database, a file systems or reading transactional memory.
So we will leverage IO
to wrap the return types up to denote this non-determinism, like so:
getUserById
:: UserId
-> IO User
getDocumentsByUserId
:: UserId
-> IO [Document]
Let’s assume we wanted to build these based on top of a Linux system where we shelled out to finger
to get user data and then read the Linux user’s home directory for a list of files for the documents for that user.
But we might also want to provide another implementation where we query a database. There are many possible choices of database types too.
In each of these cases our type signature remains the same and the application developer would need to know which module to pull implementations from to use for intended behavior.
Reasoning About Purity
With the separation of pure and impure code, we gain the power to reason about program behavior with clarity. We can analyze and test pure functions independently, confident in their deterministic nature. Pure functions become reliable building blocks, free from the entanglements of side effects.
The IO
monad facilitates this reasoning by providing a clear sequencing of impure actions through the Monad
typeclass. We can comprehend the step-by-step execution of IO
actions, understanding the order of side effects and their relationships. This control over the flow of side effects enables us to reason about the behavior of our programs, ensuring that results can be reproduced with ease.
Effect Systems beyond IO
Haskell’s approach of wrapping the main entry point result in an IO
wrapper, backed by the Monad
typeclass, brings a harmonious balance between purity and control. By separating pure code from impure actions, Haskell allows us to manually reason about program behavior with clarity, test pure functions independently, and understand the flow of side effects in a controlled manner. This philosophy enhances the reliability, maintainability, and predictability of Haskell programs, making them a formidable force in the realm of functional programming.
As we’ve seen, the IO
monad provides a basic way to isolate effects in Haskell. But over time, limitations of the single IO monad have become apparent.
MTL-Style
The MTL-style (monad transformer library) allows creating new monads by composing together monad transformers that each add a capability like state, error handling, logging etc.
For example:
newtype AppM a = AppM
{ runAppM :: ReaderT Config
(StateT UserState
(ExceptT AppError
(LoggerT IO))) a }
This stacks together reader, state, error handling and logging transformers to create a custom AppM
monad.
We can now write effectful functions that use the capabilities we’ve added:
getUser :: AppM User
getUser = do
config <- ask
state <- get
maybeUser <- liftIO $ queryConfigDB config
case maybeUser of
Just user -> return user
Nothing -> throwError UserNotFound
MTL-style provides a powerful effect ecosystem through monad transformers but tends to lead to large monad stacks.
Freer
Since new effect system libraries are released on Hackage on the daily now, I’m going to provide a very high-level review one of the better known effect libraries called freer
for the purpose of comparing it to MTL and IO
approaches using a simple illustration.
The freer package provides the core Effect type along with helper functions for constructing and running effects. This is the low-level foundation.
freer
allows modeling effects as data constructors called operations that are interpreted separately.
For example:
data MyEff next where
ReadFile :: FilePath -> MyEff String
LogMsg :: Text -> MyEff ()
readFile :: Member MyEff effs => FilePath -> Eff effs String
readFile fp = send $ ReadFile fp
We can interpret these operations separately to IO, or even interpret them differently in testing:
runMyEff :: Eff MyEff a -> IO a
runMyEff = interpret $ \case
ReadFile fp -> readFileText fp
LogMsg msg -> putStrLn msg
The freer
approach provides more composability and modularity, with the ability to flexibly interpret effect operations. But encoding effects algebraically can require more work up front.
Other Haskell Effect Libraries
Haskell offers numerous libraries for working with freer algebraic effects, each with their own strengths and tradeoffs.
freer-effects
freer-effects
builds on top of freer
to provide effect instances for common effects like state, error handling, logging etc. This can save time compared to defining them yourself.
freer-simple
freer-simple
is an alternative package with a simplified Effect
type that doesn’t depend on freer
. It also includes common effect instances out of the box.
Competitors
Other effect libraries include polysemy and eff which provide their own take on effects. These both have different tradeoffs.
freer
provides the low-level foundations but requires defining effects yourselffreer-effects
adds pre-made effect instances for conveniencefreer-simple
is simpler to use but lacks some advanced featuresPolysemy
andeff
offer alternative effect implementations
There is no single best choice - it depends on the project requirements and developer preferences. For quick prototyping, freer-simple may be easiest to start. For production use, freer-effects offers a good balance. Polysemy is also popular with some unique capabilities like higher-kinded effects.
A notable alternative effect library is fused-effects
which takes yet a different approach with different tradeoffs with respect to developer ergonomics and performance of programs written this way. At the time of publishing this article (July 2023), fused-effects
offered better performance and lower overhead compared to the libraries listed above and greater flexibility in some realms but less of a focus on developer error messages than some of the above which can make it harder to troubleshoot gnarly type errors for the uninitiated.
There are even more effect libraries in Haskell which I haven’t mentioned here so do not consider the above an exhaustive list but rather a well known effect libraries to start exploring the design space levers and options through.
Comparative Advantages
Summarizing the IO monad’s tradeoffs:
- Provides a basic way to isolate effects from pure code
- Only allows one concrete effect: IO actions
- Everything is sequenced in one monolithic computation
- No way to interpret effects differently
Reviewing MTL-style effects:
- Allows composing multiple effects together via monad transformers
- Effects are “baked in” at the type level up front
- Leads to large monad transformer stacks
- Individual effects can’t be extracted or interpreted separately
Comparing the other effect libraries mentions loosely acknowledging there is a large breath of design space they occupy collectively:
- Effects are encoded as data types (algebraic effects)
- Composable - can combine multiple effect types
- Effects can be interpreted separately
- Provides more flexibility compared to MTL
- Can require more upfront work to define effects algebraically
Wrapping Up
IO
provides a simple container for effects but isn’t very flexible. MTL gives application devleopers more structure but effects can get entangled quickly. The freer
approach provides the most composability and modularity, at the cost of defining effects algebraically but there exists effect system libraries all over the design space that may suit your project at the cost of smaller communities, less documentation/examples, or higher complexity.
Haskellers choose their own adventure with a dizzying array of choices available yet Haskellers seem to agree that attempting to contain side effects is important whereas other functional languages choose not to as a rule.poison
If you enjoyed this content, please consider sharing this link with a friend, following my GitHub, Twitter/X or LinkedIn accounts, or subscribing to my RSS feed.