ARTICLE
Abstracting Computations with Type Classes
From Haskell in Depth by Vitaly Bragilevsky
In this article we’ll discuss several type classes that originated in mathematics (mainly abstract algebra and category theory).
Take 40% off Haskell in Depth by entering fccbragilevsky into the discount code box at checkout at manning.com.
Unfortunately, there’s a long tradition of difficulty in grasping Haskell’s type classes, which can be partially attributed to the fact that Haskell’s creators kept the mathematical names for these type classes and created a myth that you have to know the math behind them in order to work with the corresponding concepts effectively. I strongly believe that the opposite is true: many programming languages have adopted what was thought of as mathematical ideas and transformed them into purely technical stuff that can be exploited without any fear. In Haskell, we have to stick with the somewhat scary names, but a little technical intuition helps you use these type classes for practical needs.
I recommend reading the Typeclassopedia by Brent Yorgey as a brilliant introduction to type classes for abstracting computations for the mathematically inclined.
Keep in mind that almost every Haskell type class provides you with some generalization across concrete types. Sometimes the same methods are used with values of different types in the same way thanks to type classes. We’ll do the same in this article; the only new thing is that we’ll generalize computations over values of various types. The type classes I’m going to discuss here are presented in Figure 1, where solid arrows represent extending type classes.
As seen in the figure, three groups of type classes stand out:
- Operation — For abstracting binary operations (combining two values into one of the same type)
- Computational context — For computing the value of some type in a context, and that computation can be accompanied by some effects
- Iteration — For repeated computations over some structure, potentially collecting the results in a context
We’ll discuss some aspects of these abstract descriptions and use practical examples.
The only operation, (<>)
When using these two type classes, Semigroup
and Monoid
, keep one simple rule in mind— provide a generic binary operation over the values of any datatype that defines their instances. The type class Semigroup
declares such an operation as:
(<>) :: Semigroup a => a -> a -> a
Many things can be combined with it. For example:
ghci> import Data.Semigroup ghci> [1,2,3] <> [4,5,6] [1,2,3,4,5,6] ghci> "Hello " <> "world" "Hello world" ghci> Sum 2 <> Sum 3 Sum {getSum = 5} ghci> Product 2 <> Product 3 Product {getProduct = 6}
Remember that a String
is a synonym for a list of Char
, and the first two examples share an instance for lists (and this operation is only list concatenation). The third and fourth examples here apply instances which represent addition and multiplication—two basic operations over numeric types. They feature newtype
wrappers Sum
and Product
for any type a
with an instance of Num a
.
The type class Monoid
adds another method:
mempty :: Monoid a => a
which returns a neutral element:
ghci> mempty :: [Int] [] ghci> mempty :: String ""
ghci> mempty :: Sum Int Sum {getSum = 0}
ghci> mempty :: Product Int
Product {getProduct = 1}
This element is called neutral because it’s expected to satisfy monoid laws, and for any element a
:
mempty <> a == a a <> mempty == a
The empty list, an empty String
, and the numbers Sum 0
and Product 1
satisfy these rules with respect to the corresponding operation.
In general, you’d want to use the operation (<>)
to accumulate information, like in concatenating lists and other containers (Data.Set
or Data.Sequence
to name a few), combining configuration properties, and such things. When you encounter a new datatype it’s always a good idea to look for provided instances. If you see instances for Semigroup
or
Monoid
, then you know that values of this datatype can be combined with (<>)
. For example:
ghci> import Data.Text ghci> :info Text data Text instance Monoid Text -- Defined in 'Data.Text' instance Semigroup Text -- Defined in 'Data.Text'
The presence of these instances makes the following possible:
ghci> :set -XOverloadedStrings ghci> h = "hello" :: Text ghci> c = ", " :: Text ghci> w = ", world" :: Text ghci> h <> c <> w "hello, world"
Other methods in Semigroup
and Monoid
call binary operations several times:
sconcat :: Semigroup a => Data.List.NonEmpty.NonEmpty a -> a mconcat :: Monoid a => [a] -> a
Note the type Data.List.NonEmpty.NonEmpty
in the type signature of sconcat
: we have to use it here because sconcat
has no idea what to return when given an empty list. Thankfully, the type signature prevents us from running it with an empty list as an argument. The value of the NonEmpty
type must have a head with a potentially empty tail.
You can construct such a list with the operation (:|)
as follows:
ghci> 0 :| [1,2,3] 0 :| [1,2,3] ghci> "x" :| [] "x" :| []
The function sconcat
takes a nonempty list and applies (<>)
several times, accumulating a total value. For example:
ghci> sconcat ("x" :| ["y", "z"]) "xyz"
The function mconcat
does the same thing without this limitation, because it can return mempty
whenever it’s given an empty list as an argument.
The definitions of the type classes Semigroup
and Monoid
are works in progress. Some time ago we only had Monoid
; then Semigroup
was added, but at that time there were no direct connections between these two classes. In GHC version 8.4 Semigroup
became a superclass of Monoid
. If you look at their definitions chances are you’ll see traces of their history, such as the method mappend
, which is the same as (<>)
. This mappend
method is expected to be removed from the Monoid
type class at some time in the future. You can read full details about this plan here: https://prime.haskell.org/wiki/Libraries/Proposals/SemigroupMonoid.
Mapping, applying, and sequencing in a computational context
Let’s pick apart the title of this subsection. Merriam–Webster gives the following definition of context: the interrelated conditions in which something exists or occurs
Computational context means these interrelated conditions occurred during the process of computation. In Haskell, you can use many different computational contexts: some of them allow you to have side effects, such as input/output (IO
) or mutable variables (State
, ST
), and others support the idea of computation with the potential absence of a result (Maybe
, Either
) or with the possibility of storing some additional information along with the computation (Writer
). You probably came across some of them when you started learning Haskell.
All these contexts share on abstract behavior by allowing you to map, apply, and sequence over them. This behavior is defined by three type classes with fancy names:
Functor
abstracts mapping over some value in a computational context.Applicative
enables applying a function in a context to a value in a context and injecting value into a context.Monad
allows sequencing of computations in a context to structure what’s done in the next computation depends on the result of the previous one.
This wording is a textual representation of the type signatures for the following methods:
fmap :: Functor f => (a -> b) -> f a -> f b pure :: Applicative f => a -> f a (<*>) :: Applicative f => f (a -> b) -> f a -> f b
(>>=) :: Monad m => m a -> (a -> m b) -> m b
Although these three type classes define other methods as well (you can explore them with the :info
GHCi command), these are the most crucial for understanding what’s going on here. We often refer to contexts as functorial, applicative, or monadic if there exists a corresponding instance and we want to specify a particular property.
The words value in a context relate to the type signatures f a
or m a
, where a
is a type of a value (the result of a computation) and f
or m
restricts the context. In the same way, f (a → b)
is a function in a context. Take a look also at the second argument in the type signature of (>>=)
, (a → m b)
—this is what’s meant by “the next computation depends on the result of the previous one” (we call it a monadic function). Some call monads “programmable semicolon” for this reason.
When you look at the particular contexts these methods are concretized and sometimes look quite different, but all of them implement the same ideas of mapping, applying, and sequencing.
You can find many introductory materials on these three type classes — the Typeclassopedia includes many links, and Unit 5 of Get Programming with Haskell by Will Kurt (Manning) describes them in great detail in a practical way — and I won’t attempt to repeat them. Instead, I’ll give you examples of three different contexts and operations over values in them, to make sure that we’re on the same page.
Exploring different contexts in parallel
The type classes we’re talking about share common ideas about behavior. It’s always instructive to go both ways, from common ideas to specific implementations and vice versa. In this subsection you’re advised to follow the path of understanding from particular examples toward general notions. We’ll focus on these three contexts:
IO
, which allows you to do input/outputWriter
, which supports logging of additional information along with the process of computations (you can get access to this context after importing the moduleControl.Monad.Writer
)[]
(a list), which is the most unusual context of nondeterministic computations
First, let’s look at a few examples of what a value in a context is:
getLine :: IO String
is a value of typeString
in a context which is responsible for acquiring it from a side effect of reading user input.writer (42, "step 1\n") :: Writer String Int
is a value42
of typeInt
which is accompanied by the log"step 1\n"
in the form of aString
.[1,2,3] :: [Int]
is one of the values1
,2
, or3
of typeInt
, but we aren’t sure which one is the result of the computations (this uncertainty is what I call nondeterminism).
Now let’s check the mapping using the method fmap
from the Functor
type class:
ghci> fmap (++"!") getLine Hi
"Hi!"
The second line, Hi
, is the user input, which becomes a value in a context during execution, and the last line is a result of mapping (++"!")
over that result.
What about Writer
? Take a look:
ghci> fmap (+1) (writer (42, "step 1\n")) :: Writer String Int WriterT (Identity (43,"step 1\n"))
Apart from the output mess with WriterT
and Identity
, you can clearly see the new value 43
(the result of mapping (+1)
over 42
) in an unaltered context—the log doesn’t change. As for the third context:
ghci> fmap (+1) [1,2,3] [2,3,4]
We have one of the values 2
, 3
, or 4
, each of them being a potential result of nondeterministic mapping of (+1)
over 1
, 2
, or 3
. Clearly it’s an ordinary map
for lists.
In the next step we’d like to look at these contexts in an applicative setting, to try to see any similarities:
ghci> pure (++) <*> getLine <*> getLine hello world "helloworld"
ghci> pure (+) <*> writer (42, "step 1\n") <*> writer (10, "step 2\n") :: Writer String Int WriterT (Identity (52,"step 1\nstep 2\n")) ghci> pure (+) <*> [1,2,3] <*> [4,5,6]
[5,6,7,6,7,8,7,8,9]
We have a two-argument function which is injected into the context and then applied to two values in a context, and we can clearly see the results of applying "helloworld"
, 52
, or one of the values in the list [5,6,7,6,7,8,7,8,9]
. We can also see that the context does its job by getting two user inputs, combining logs, and exploiting nondeterminism (we had three potential results in the first value in a context and three potential results in the second value in a context, and we got nine (=3*3) of them finally).
Let’s look at (>>=)
, called “bind” for our last example here:
ghci> getLine >>= putStrLn hello hello
ghci> writer (42, "step 1\n") >>= (\a -> writer (a+1, "step 2\n")) :: Writer String Int WriterT (Identity (43,"step 1\nstep 2\n")) ghci> [1,2] >>= (\x -> [x-1, x, x+1])
[0,1,2,1,2,3]
Try to work out for yourself what’s done here with the result of the first computation and how the contexts reveal themselves. Don’t forget to check the type of (>>=)
before you start thinking.
do notation
do
notation is a convenient syntactic mechanism for expressing monadic computations, which includes:
- Sequencing of operations by writing them one after another (as in imperative programming languages)
- Binding values in a context to names with
←
- Constructing succeeding operations with the names bound previously (this is the main feature of the
Monad
type class)
For example, the following code:
action t = do a <- f t
b <- g a
h a
return (k a b)
is de-sugared into:
action t = f t >>= \a ->
a >>= \b ->
a >>
return (k a b)
which is effectively:
action t = f t >>= \a -> g a >>= \b -> h a >> return (k a b)
Linearized definitions like this one are almost impossible to read and inconvenient to write; do
notation helps a lot.
Note that you can use do
notation in any monadic context. For example, the following three fragments are absolutely legitimate. The first context is IO
:
readNumber :: IO Int readNumber = do s <- getLine
return (read s)
The second is Writer
, where we recursively compute the sum of numbers from 0
to n
and log all intermediate arguments (logging is done via the auxiliary function tell
from Control.Monad.Writer
):
sumN :: Int -> Writer String Int sumN 0 = writer (0, "finish") sumN n = do tell (show n ++ ",")
s <- sumN (n-1)
return (n + s)
Running this function in the GHCi gives:
ghci> sumN 5 WriterT (Identity (15,"5,4,3,2,1,finish"))
You can see here that the computations resulted in the number 15
, and a full log is also provided.
Some monads support runners, functions that provide unwrapped results. In the case of Writer
we’ve the runWriter
function, which returns a pair (result, log)
:
ghci> runWriter (sumN 5) (15,"5,4,3,2,1,finish")
The third context is a list:
cartesianProduct :: [Int] -> [Int] -> [(Int, Int)] cartesianProduct xs ys = do x <- xs
y <- ys
return (x, y)
We can apply the function cartesianProduct
to some lists and see the results in the following GHCi session:
ghci> cartesianProduct [1..2] [1..3] [(1,1),(1,2),(1,3),(2,1),(2,2),(2,3)]
Every do
-block corresponds to exactly one monadic context, it’s instructive to understand which Monad
you work in every moment as the observable results crucially depend on that. It’s a common mistake to expect something which isn’t handled by the context in use.
do
notation is de-sugared into the following monadic operations:
(>>=) :: Monad m => m a -> (a -> m b) -> m b
(>>) :: Monad m => m a -> m b -> m b
where the last operation, (>>)
, is a simplified version of (>>=)
without dependency on the result of the previous computation. You can find details about desugaring do
notation in the Typeclassopedia (https://wiki.haskell.org/Typeclassopedia#do_notation) and many other sources.
Folding and traversing
The last two classes are Foldable
and Traversable
—both of them capture the idea of processing a container, a collection of values, with different possibilities.
Any Foldable
instance should implement one of the following methods:
class Foldable (t :: * -> *) where foldMap :: Monoid m => (a -> m) -> t a -> m
foldr :: (a -> b -> b) -> b -> t a -> b ...
Both of them should iterate over t a
, a container t
with elements of type a
, process every element and combine all the results. Foldable
contains sixteen methods. About half of them are various sorts of folding, namely processing container elements using specific operations; others include length
, elem
, and maximum
. All of these were once list methods that were later generalized to support other containers too.
The type class Traversable
is both simpler (it has only four methods, and two of them repeat the other two in a richer context, existing only for historical reasons) and more complex due to combining the effects of Functor
, Applicative
, Monad
, and Foldable
. This is the definition of Traversable
:
class (Functor t, Foldable t) => Traversable (t :: * -> *) where traverse :: Applicative f => (a -> f b) -> t a -> f (t b)
sequenceA :: Applicative f => t (f a) -> f (t a)
mapM :: Monad m => (a -> m b) -> t a -> m (t b)
sequence :: Monad m => t (m a) -> m (t a)
{-# MINIMAL traverse | sequenceA #-}
The method traverse
goes over every element in a container (the second argument) and applies its first argument to an element effectively, as in Applicative f
. Due to constraints to t
and f
, the actions performed in traverse
greatly depend on the particular instances, and it’s hard to talk about it in general.
Let’s look at a simple example. From the type signature of the method traverse
we can see that it requires two arguments: a function a → f b
returning a value in an applicative context and a container t a
of elements of type a
. Let’s take a list of Int
values as a container and IO
as an applicative context. Then we need some function returning a value in IO
—for example:
addNumber :: Int -> IO String
addNumber n = pure (++) <*> pure (show n ++ " ") <*> getLine
This function’s type is compatible with traverse
: it takes a number, turns it into a String
, and concatenates it with the line from the user input. Now we’re ready to see traverse
in action:
ghci> traverse addNumber [1..5] aaa bbb ccc ddd eee
["1 aaa","2 bbb","3 ccc","4 ddd","5 eee"]
Here, I’ve entered five lines and then numbered them. It’s always useful to check the types:
ghci> :t traverse addNumber [1..5] traverse addNumber [1..5] :: IO [String]
We’ve a list of String
in the context of IO
and this particular call to traverse
has the type:
(a -> f b) -> t a -> f (t b)
(Int -> IO String) -> [Int] -> IO [String]
The module Data.Foldable
provides traverse_
, a function similar to traverse
which doesn’t collect results of the individual computations:
traverse_ :: (Applicative f, Foldable t) => (a -> f b) -> t a -> f ()
Always follow the types; they help you a lot in understanding abstract functions. Try to use the same approach for sequenceA
: choose any container (take a list as the simplest one; Maybe
works too) and any applicative context (try Writer
!) and then construct a call to sequenceA
. You’ll definitely see what’s going on there.
Writing code with respect to Foldable
and Traversable
, as opposed to relying on a specific container or data structure, allows you to change the container whenever you like.
As a result, you can improve the efficiency of your code without rewriting it.
If you want to see more, check out the book on Manning’s liveBook platform here.