CSE 320: Programming Languages @ CSU, San Bernardino
Lab 3: Effects
Welcome to the third and final lab using Haskell. In this lab, we will discuss effects, how to control them, and how a collection of typeclasses makes working with them more pleasant.
Until now, we’ve tiptoed around the type signature for the
main function in Haskell. In case you were wondering, it looks like this:
main :: IO ()
This means that
main is a function that takes in no parameters, and returns an
IO struct containing nothing (indicated by
(), the unit type, as discussed in the first lab).
So what is this
IO struct? Think of it as a “context” in which a value of some type can be placed. When you do input-output in Haskell, it’s done in this
IO context. So, for example, the function
putStrLn looks like this:
putStrLn :: String -> IO ()
This means that
putStrLn takes in a Haskell string, and returns an empty
IO context (there’s no return value for printing something, so there’s no value for the
IO context to hold after this operation).
If you’re doing input, say, to get a single character, it looks like this:
getChar :: IO Char
This means that
getChar takes in no parameters, and returns a
Char in the
IO actually comes with a bunch of functions for doing input-output. Here they are:
putChar :: Char -> IO () putStr :: String -> IO () putStrLn :: String -> IO () print :: (Show a) => a -> IO () getChar :: IO Char getLine :: IO String getContents :: IO String interact :: (String -> String) -> IO () type FilePath = String readFile :: FilePath -> IO String writeFile :: FilePath -> String -> IO () appendFile :: FilePath -> String -> IO () readIO :: Read a => String -> IO a readLn :: Read a => IO a
Notice that all the functions doing output return some
IO (), while all the functions getting input return some
IO type (
String, depending on whether you’re getting a character or string).
All input-output in Haskell goes through these
Operating in the Context
The question then is what to do with the context once you have it. For example, let’s say you have some
IO Char value, and want to modify the character inside. How would you do that? You need some way to modify the value inside, maybe like this:
modify :: IO Char -> IO Char modify (IO c) = IO (toLower c)
But hey, doesn’t it seem weird to have a function that only works for
IO characters? Let’s split out the part specific to
IO from the part specific to
modifyIO :: IO Char -> IO Char modifyIO c = mapIO modify c mapIO :: (a -> b) -> IO a -> IO b mapIO f (IO x) = IO (f x) modify :: Char -> Char modify c = toLower c
So now we have a pure, non-
IO function for modifying the character, and a generic function for applying the non-
IO function in the
It turns out there are other places this same pattern can be used. What if we have a value of type
Maybe Char, and we want to modify the
Char in the same way we just did with
IO. It looks like this:
modifyMaybe :: Maybe Char -> Maybe Char modifyMaybe c = mapMaybe modify c mapMaybe :: (a -> b) -> Maybe a -> Maybe b mapMaybe f (Just x) = Just (f x) mapMaybe f (Nothing) = Nothing modify :: Char -> Char modify c = toLower c
mapMaybe’s type signature is almost identical to the one for
mapIO! Can we make a function that works for both? We can!
class Functor f where fmap :: (Functor f) => (a -> b) -> f a -> f b
That’s it! We’ve defined a new type class, called
Functor that contains one function,
fmap, which is the generic version of those
mapMaybe functions we wrote ourselves. It turns out that both the
IO type and
Maybe type are
Functors! In fact, any type that can be mapped over in the same way is a functor, meaning we can operate on values in the context of that type, without polluting the functions we’re applying by forcing them to know things about the context.
Sequencing Contextual Operations
Okay, so we can operate on a value in a context, without having to mess up our functions by making them care about the context itself. What if we want to sequence contextual operations? Can we do it?
Imagine we have a function in a context, like so:
$ let x = Just (+ 3) x :: (Num a ) => Maybe (a -> a)
We now have a function that maps numbers to numbers, inside the
Maybe context. Unfortunately, we can’t give this function to
fmap, because its type signature is wrong! It expects a function
(a -> b), not
f (a -> b). But could we write a function that does work with
class Functor f => Applicative f where pure :: a -> f a (<*>) :: (Applicative f) => f (a -> b) -> f a -> f b
<*> “apply.” “Apply” is a function that takes in a function in a context, a value in the same context, and uses the function to modify the value, returning a new value, still in the same context.
pure is a simple function to take a value and put it into the context.
But just what kind of context is it? Well, it’s an applicative one! An applicative context is a context that can implement the
Applicative typeclass. Simple as that.
But how does this help with sequencing? Well, let’s look at
instance Applicative Maybe where pure x = Just x (<*>) (Just f) x = fmap f x (<*>) (Nothing) _ = Nothing
pure simply wraps the value in a
Just, and “apply” applies the function if it’s there, or passes on
Nothing if there’s no function. Let’s see it in action!
$ pure (+) <*> Just 3 <*> Just 5 Just 8 $ pure (+) <*> Just 3 <*> Nothing Nothing $ pure (+) <*> Nothing <*> Just 5 Nothing
pure (+), takes
(+) (which has type
Num a => a -> a -> a) and puts it into the
Maybe context, resulting in a value of just
Num a => Maybe (a -> a -> a).
“Apply” then first applies the function to a value of type
Maybe 3, resulting in a value of type
Num a => Maybe (a -> a), thanks to partial application.
The second “apply” applies the function to
Just 5, resulting in a value of type
Maybe a. In this case, the value is
8, which is the result of
3 + 5.
That is how
Applicative provides sequencing! With partial application, you give it a function with multiple parameters, and then apply that function to those parameters, all while staying in the same context! And thanks to
pure, you still don’t need to pollute your functions by making them care about the context in which they’ll be applied!
Working with Contextual Functions
So, we know how to apply a pure function to a contextual value (with
fmap), and we know how to apply a contextual function to contextual values. But what if we have a function like
putChar, from earlier?
putChar has the following type signature:
putChar :: String -> IO ()
This function takes in a pure value and returns a contextual value. This type signature isn’t quite right for a
Functor, and it’s not quite right for an
Applicative. What is it?
class Applicative f => Monad f where (>>=) :: f a -> (a -> f b) -> f b
This function, which we’ll call “bind,” takes in a contextual value, and a function (like
putChar) from a pure value to a contextual value, and returns a contextual value. Using it might look like this:
$ pure 'a' >>= putChar
pure 'a' gives us a value of type
IO Char, and
putChar has the type
Char -> IO (). These match up perfectly with “bind”! So
IO is a monad!
With the monad, we have the ability to sequence operations together! To show this, let’s write a little helper function, which we’ll call “sequence”:
(>>) :: Monad f => f a -> f b -> fb (>>) x y = x >>= (\_ -> y)
This function says to run
x first, throw away the result, and then run
y. So, if we wanted to print multiple characters, it could look this this!
$ (pure 'a' >>= putChar) >> (pure 'b' >>= putChar)
In fact, Haskell provides a convenient notation for sequencing
IO operations, that desugars into exactly this!
main = do x <- getLine putStrLn ("You typed: " ++ x)
This turns into:
main = getLine >>= (\x -> putStrLn ("You typed: " ++ x))
None of this is specific to
IO! We can actually use it for any type that implements the
Monad interface, like
 (the list type).
So, we make sure effects can only happen inside a particular context by having all the functions with effects only operate in that context. Then we use
Monad to make working with those contexts nice and easy, giving us the ability to operate on contextual values (with
Functor), sequence contextual operations (with
Applicative), and have that sequencing carry values (with
Monad). Collectively, these provide an extremely strong and useful interface for controlling effects.
For this lab, I want something a little different. Please type a response to the following question: “what makes
Monad different? Why are all
Functors, and why are all
Applicatives (and therefore also
Functors). Email this typed response to me, as a PDF, before the next lab.”