Haskell/Prologue: IO, an applicative functor
For shorter links to this chapter, be they within the book or off-wiki, you can use the Haskell/Applicative prologue redirect. |
The emergence of functors is a watershed in the course of this book. The reasons for that will begin to reveal themselves in this prologue, as we set the stage for the next several chapters of the book. While the code examples we will work with here are very simple, we will use them to bring several new and important ideas into play, ideas that will be revisited and further developed later in the book. That being so, we recommend that you study this chapter at a gentle pace, which will give you space for thinking about the implications of each step, as well as trying out the code samples in GHCi.
Scene 1 : Applicative
edit
Our initial examples will use the function readMaybe
, which is provided by the Text.Read
module.
GHCi> :m +Text.Read
GHCi> :t readMaybe
readMaybe :: Read a => String -> Maybe a
readMaybe
provides a simple way of converting strings into Haskell values. If the provided string has the correct format to be read as a value of type a
, readMaybe
gives back the converted value wrapped in Just
; otherwise, the result is Nothing
.
GHCi> readMaybe "3" :: Maybe Integer
Just 3
GHCi> readMaybe "foo" :: Maybe Integer
Nothing
GHCi> readMaybe "3.5" :: Maybe Integer
Nothing
GHCi> readMaybe "3.5" :: Maybe Double
Just 3.5
Note
To use readMaybe
, we need to specify which type we are trying to read. Most of the time, that would be done through a combination of type inference and the signatures in our code. Occasionally, however, it is more convenient to just slap in a type annotation rather than writing down a proper signature. For instance, in the first example above the :: Maybe Integer
in readMaybe "3" :: Maybe Integer
says that the type of readMaybe "3"
is Maybe Integer
.
We can use readMaybe
to write a little program in the style of those in the Simple input and output chapter that:
- Gets a string given by the user through the command line;
- Tries to read it into a number (let's use
Double
as the type); and - If the read succeeds, prints your number times 2; otherwise, prints an explanatory message and starts over.
Note
Before continuing, we suggest you try writing the program. Beyond readMaybe
, you will likely find getLine
, putStrLn
and show
useful. Have a look at the Simple input and output chapter if you need a reminder about how to do reading from and printing to the console.
Here is a possible implementation:
import Text.Read
interactiveDoubling = do
putStrLn "Choose a number:"
s <- getLine
let mx = readMaybe s :: Maybe Double
case mx of
Just x -> putStrLn ("The double of your number is " ++ show (2*x))
Nothing -> do
putStrLn "This is not a valid number. Retrying..."
interactiveDoubling
GHCi> interactiveDoubling Choose a number: foo This is not a valid number. Retrying... Choose a number: 3 The double of your number is 6.0
Nice and simple. A variation of this solution might take advantage of how, given that Maybe
is a Functor
, we can double the value before unwrapping mx
in the case statement:
interactiveDoubling = do
putStrLn "Choose a number:"
s <- getLine
let mx = readMaybe s :: Maybe Double
case fmap (2*) mx of
Just d -> putStrLn ("The double of your number is " ++ show d)
Nothing -> do
putStrLn "This is not a valid number. Retrying..."
interactiveDoubling
In this case, there is no real advantage in doing that. Still, keep this possibility in mind.
Application in functors
editNow, let's do something slightly more sophisticated: reading two numbers with readMaybe
and printing their sum (we suggest that you attempt writing this one as well before continuing).
Here is one solution:
interactiveSumming = do
putStrLn "Choose two numbers:"
sx <- getLine
sy <- getLine
let mx = readMaybe sx :: Maybe Double
my = readMaybe sy
case mx of
Just x -> case my of
Just y -> putStrLn ("The sum of your numbers is " ++ show (x+y))
Nothing -> retry
Nothing -> retry
where
retry = do
putStrLn "Invalid number. Retrying..."
interactiveSumming
GHCi> interactiveSumming
Choose two numbers:
foo
4
Invalid number. Retrying...
Choose two numbers:
3
foo
Invalid number. Retrying...
Choose two numbers:
3
4
The sum of your numbers is 7.0
interactiveSumming
works, but it is somewhat annoying to write. In particular, the nested case
statements are not pretty, and make reading the code a little difficult. If only there was a way of summing the numbers before unwrapping them, analogously to what we did with fmap
in the second version of interactiveDoubling
, we would be able to get away with just one case
:
-- Wishful thinking...
case somehowSumMaybes mx my of
Just z -> putStrLn ("The sum of your numbers is " ++ show z)
Nothing -> do
putStrLn "Invalid number. Retrying..."
interactiveSumming
But what should we put in place of somehowSumMaybes
? fmap
, for one, is not enough. While fmap (+)
works just fine to partially apply (+)
to the value wrapped by Maybe
...
GHCi> :t (+) 3
(+) 3 :: Num a => a -> a
GHCi> :t fmap (+) (Just 3)
fmap (+) (Just 3) :: Num a => Maybe (a -> a)
... we don't know how to apply a function wrapped in Maybe
to the second value. For that, we would need a function with a signature like this one...
(<*>) :: Maybe (a -> b) -> Maybe a -> Maybe b
... which would then be used like this:
GHCi> fmap (+) (Just 3) <*> Just 4
Just 7
The GHCi prompt in this example, however, is not wishful thinking: (<*>)
actually exists, and if you try it in GHCi, it will actually work! The expression looks even neater if we use the infix synonym of fmap
, (<$>)
:
GHCi> (+) <$> Just 3 <*> Just 4
Just 7
The actual type (<*>)
is more general than what we just wrote. Checking it...
GHCi> :t (<*>)
(<*>) :: Applicative f => f (a -> b) -> f a -> f b
... introduces us to a new type class: Applicative
, the type class of applicative functors. For an initial explanation, we can say that an applicative functor is a functor which supports applying functions within the functor, thus allowing for smooth usage of partial application (and therefore functions of multiple arguments). All instances of Applicative
are Functor
s, and besides Maybe
, there are many other common Functor
s which are also Applicative
.
This is the Applicative
instance for Maybe
:
instance Applicative Maybe where
pure = Just
(Just f) <*> (Just x) = Just (f x)
_ <*> _ = Nothing
The definition of (<*>)
is actually quite simple: if neither of the values are Nothing
, apply the function f
to x
and wrap the result with Just
; otherwise, give back Nothing
. Note that the logic is exactly equivalent to what the nested case
statement of interactiveSumming
does.
Note that beyond (<*>)
there is a second method in the instance above, pure
:
GHCi> :t pure
pure :: Applicative f => a -> f a
pure
takes a value and brings it into the functor in a default, trivial way. In the case of Maybe
, the trivial way amounts to wrapping the value with Just
– the nontrivial alternative would be discarding the value and giving back Nothing
. With pure
, we might rewrite the three-plus-four example above as...
GHCi> (+) <$> pure 3 <*> pure 4 :: Num a => Maybe a
Just 7
... or even:
GHCi> pure (+) <*> pure 3 <*> pure 4 :: Num a => Maybe a
Just 7
Just like the Functor
class has laws which specify how sensible instances should behave, there is a set of laws for Applicative
. Among other things, these laws specify what the "trivial" way of bringing values into the functor through pure
amounts to. Since there is a lot going on in this stretch of the book, we will not discuss the laws now; however, we will return to this important topic in a not too distant future.
Note
In any case, if you are curious feel free to make a detour through the Applicative functors chapter and read its "Applicative functor laws" subsection. If you choose to go there, you might as well have a look at the "ZipList" section, which provides an additional example of a common applicative functor that can be grasped using only what we have seen so far.
To wrap things up, here is a version of interactiveSumming
enhanced by (<*>)
:
interactiveSumming = do
putStrLn "Choose two numbers:"
sx <- getLine
sy <- getLine
let mx = readMaybe sx :: Maybe Double
my = readMaybe sy
case (+) <$> mx <*> my of
Just z -> putStrLn ("The sum of your numbers is " ++ show z)
Nothing -> do
putStrLn "Invalid number. Retrying..."
interactiveSumming
Scene 2 : IO
edit
In the examples above, we have been taking I/O actions such as getLine
for granted. We now find ourselves at an auspicious moment to revisit a question first raised many chapters ago: what is the type of getLine
?
Back in the Simple input and output chapter, we saw the answer to that question is:
GHCi> :t getLine
getLine :: IO String
Using what we learned since then, we can now see that IO
is a type constructor with one type variable, which happens to be instantiated as String
in the case of getLine
. That, however, doesn't get to the root of the issue: what does IO String
really mean, and what is the difference between that and plain old String
?
Referential transparency
editA key feature of Haskell is that all expressions we can write are referentially transparent. That means we can replace any expression whatsoever by its value without changing the behaviour of the program. For instance, consider this very simple program:
addExclamation :: String -> String
addExclamation s = s ++ "!"
main = putStrLn (addExclamation "Hello")
Its behaviour is wholly unsurprising:
GHCi> main
Hello!
Given that addExclamation s = s ++ "!"
, we can rewrite main
so that it doesn't mention addExclamation
. All we have to do is replace s
with "Hello"
on the right-hand side of the addExclamation
definition and then replace addExclamation "Hello"
with the resulting expression. As advertised, the program behaviour does not change:
GHCi> let main = putStrLn ("Hello" ++ "!")
GHCi> main
Hello!
Referential transparency ensures that this sort of substitution works. This guarantee extends to anywhere in any Haskell program, which goes a long way towards making programs easier to understand, and their behaviour easier to predict.
Now, suppose that the type of getLine
were String
. In that case, we would be able to use it as the argument to addExclamation
, as in:
-- Not actual code.
main = putStrLn (addExclamation getLine)
In that case, however, a new question would spring forth: if getLine
is a String
, which String
is it? There is no satisfactory answer: it could be "Hello"
, "Goodbye"
, or whatever else the user chooses to type at the terminal. And yet, replacing getLine
with any String
breaks the program, as the user would not be able to type the input string at the terminal any longer. Therefore getLine
having type String
would break referential transparency. The same goes for all other I/O actions: their results are opaque, in that it is impossible to know them in advance, as they depend on factors external to the program.
Cutting through the fog
editAs getLine
illustrates, there is a fundamental indeterminacy associated with I/O actions. Respecting this indeterminacy is necessary for preserving referential transparency. In Haskell, that is achieved through the IO
type constructor. getLine
being an IO String
means that it is not any actual String
, but both a placeholder for a String
that will only materialise when the program is executed and a promise that this String
will indeed be delivered (in the case of getLine
, by slurping it from the terminal). As a consequence, when we manipulate an IO String
we are setting up plans for what will be done once this unknown String
comes into being. There are quite a few ways of achieving that. In this section, we will consider two of them; we will add a third one in the next few chapters.
The idea of dealing with a value which isn't really there might seem bizarre at first. However, we have already discussed at least one example of something not entirely unlike it without batting an eyelid. If mx
is a Maybe Double
, then fmap (2*) mx
doubles the value if it is there, and works regardless of whether the value actually exists.[1] Both Maybe a
and IO a
imply, for different reasons, a layer of indirection in reaching the corresponding values of type a
. That being so, it comes as no surprise that, like Maybe
, IO
is a Functor
, with fmap
being the most elementary way of getting across the indirection.
To begin with, we can exploit the fact that IO
is a Functor
to replace the let
definitions in interactiveSumming
from the end of the previous section with something more compact:
interactiveSumming :: IO ()
interactiveSumming = do
putStrLn "Choose two numbers:"
mx <- readMaybe <$> getLine -- equivalently: fmap readMaybe getLine
my <- readMaybe <$> getLine
case (+) <$> mx <*> my :: Maybe Double of
Just z -> putStrLn ("The sum of your numbers is " ++ show z)
Nothing -> do
putStrLn "Invalid number. Retrying..."
interactiveSumming
readMaybe <$> getLine
can be read as "once getLine
delivers a string, whatever it turns out to be, apply readMaybe
to it". Referential transparency is not compromised: the value behind readMaybe <$> getLine
is just as opaque as that of getLine
, and its type (in this case IO (Maybe Double)
) prevents us from replacing it with any determinate value (say, Just 3
) as it would violate referential transparency.
Beyond being a Functor
, IO
is also an Applicative
, which provides us with a second way of manipulating the values delivered by I/O actions. We will illustrate it with an interactiveConcatenating
action, similar in spirit to interactiveSumming
. A first version is just below. Can you anticipate how to simplify it with (<*>)
?
interactiveConcatenating :: IO ()
interactiveConcatenating = do
putStrLn "Choose two strings:"
sx <- getLine
sy <- getLine
putStrLn "Let's concatenate them:"
putStrLn (sx ++ sy)
Here is a version exploiting (<*>)
:
interactiveConcatenating :: IO ()
interactiveConcatenating = do
putStrLn "Choose two strings:"
sz <- (++) <$> getLine <*> getLine
putStrLn "Let's concatenate them:"
putStrLn sz
(++) <$> getLine <*> getLine
is an I/O action which is made out of two other I/O actions (the two getLine
). When it is executed, these two I/O actions are executed and the strings they deliver are concatenated. One important thing to notice is that (<*>)
maintains a consistent order of execution between the actions it combines. Order of execution matters when dealing with I/O – examples of that are innumerable, but for starters consider this question: if we replace the second getLine
in the example above with (take 3 <$> getLine)
, which of the strings entered at the terminal will be cut down to three characters?
As (<*>)
respects the order of actions, it provides a way of sequencing them. In particular, if we are only interested in sequencing and don't care about the result of the first action we can use \_ y -> y
to discard it:
GHCi> (\_ y -> y) <$> putStrLn "First!" <*> putStrLn "Second!"
First!
Second!
This is such a common usage pattern that there is an operator specifically for it: (*>)
.
u *> v = (\_ y -> y) <$> u <*> v
GHCi> :t (*>)
(*>) :: Applicative f => f a -> f b -> f b
GHCi> putStrLn "First!" *> putStrLn "Second!"
First!
Second!
It can be readily applied to interactiveConcatenating
example:
interactiveConcatenating :: IO ()
interactiveConcatenating = do
putStrLn "Choose two strings:"
sz <- (++) <$> getLine <*> getLine
putStrLn "Let's concatenate them:" *> putStrLn sz
Or, going even further:
interactiveConcatenating :: IO ()
interactiveConcatenating = do
sz <- putStrLn "Choose two strings:" *> ((++) <$> getLine <*> getLine)
putStrLn "Let's concatenate them:" *> putStrLn sz
Note that each (*>)
replaces one of the magical line breaks of the do
block that lead actions to be executed one after the other. In fact, that is all there is to the replaced line breaks: they are just syntactic sugar for (*>)
.
Earlier, we said that a functor adds a layer of indirection for accessing the values within it. The flip side of that observation is that the indirection is caused by a context, within which the values are found. For IO
, the indirection is that the values are only determined when the program is executed, and the context consists in the series of instructions that will be used to produce these values (in the case of getLine
, these instructions amount to "slurp a line of text from the terminal"). From this perspective, (<*>)
takes two functorial values and combines not only the values within but also the contexts themselves. In the case of IO
combining the contexts means appending the instructions of one I/O action to those of the other, thus sequencing the actions.
The end of the beginning
editThis chapter was a bit of a whirlwind! Let's recapitulate the key points we discussed in it:
Applicative
is a subclass ofFunctor
for applicative functors, which are functors that support function application without leaving the functor.- The
(<*>)
method ofApplicative
can be used as a generalisation offmap
to multiple arguments. - An
IO a
is not a tangible value of typea
, but a placeholder for ana
value that will only come into being when the program is executed and a promise that this value will be delivered through some means. That makes referential transparency possible even when dealing with I/O actions. IO
is a functor, and more specifically an instance ofApplicative
, that provides means to modify the value produced by an I/O action in spite of its indeterminacy.- A functorial value can be seen as being made of values in a context. The
(<$>)
operator (that is,fmap
) cuts through the context to modify the underlying values. The(<*>)
operator combines both the contexts and the underlying values of two functorial values. - In the case of
IO
,(<*>)
, and the closely related(*>)
, combine contexts by sequencing I/O actions. - A large part of the role of
do
blocks is simply providing syntactic sugar for(*>)
.
As a final observation, note that there is still a major part of the mystery behind do
blocks left to explain: what does the left arrow do? In a do
-block line such as...
sx <- getLine
... it looks like we are extracting the value produced by getLine
from the IO
context. Thanks to the discussion about referential transparency, we now know that must be an illusion. But what is going on behind the scenes? Feel free to place your bets, as we are about to find out!
Notes
- ↑ The key difference between the two situations is that with
Maybe
the indeterminacy is only apparent, and it is possible to figure out in advance whether there is an actualDouble
behindmx
– or, more precisely, it is possible as long as the value ofmx
does not depend on I/O!