Please Buy now at Leanpub
Written and illustrated by GetContented.
Published on 2024-05-24.
In this chapter we’ll look at all the ways we can make code that gives results that depend on values.
First up, let’s look at a program that takes a name, then prints a message, depending on what the name is.
message :: String -> String
message name = if name == "Dave"
then "I can't do that."
else "Hello."
main :: IO ()
main = putStrLn (message "Dave")
We see message
is a function that takes a String
and returns a String
. The String
it takes as an argument is named name
in the body of the function. It’s pattern-matched into the name
variable, is another way to say this.
The if...then...else
expression is one way we can control what will happen in Haskell programs.
It’s very good for what’s called a two-way branch; that is, when there are two “paths” to take: the path for True
or the path for False
. (It’s either True
that name
is "Dave"
, or it’s False
).
The result of the if
expression is dependent on the variable named name
.
So what about this (==)
operator that we saw in that program? Let’s look at its type:
(==) :: Eq a => a -> a -> Bool
Here we have a new typeclass constraint, this time called Eq
. This operator works out if its two arguments evaluate to the same value. If so, it returns the Bool
value True
, otherwise False
.
Bool
is the type which comprises just the values True
and False
. These are used to express whether things are true or not in Haskell.
Eq
provides the (==)
and (/=)
operators. They mean “is equal to”, and “is not equal to” respectively. Eq
is short for equal or equality.
An if
expression has three sections, and it must always have three sections. The first section is an expression that must evaluate to a Bool
value. You can begin to see why (==)
is quite an important operator now, can’t you? When it evaluates to True
, the expression that follows “then” is returned, otherwise the expression after the “else” is returned.
The entire if
expression always results in a single value, both of its return expressions must have the same type.
So, what if we want to actually test for more than just two alternatives? (maybe “Dave”, “Sam” or other).
Well we can “chain” these if
expressions by putting another one into the first one:
message :: String -> String
message name =
if name == "Dave"
then "I can't do that."
else if name == "Sam"
then "Play it again."
else "Hello."
So now, it’ll check if the name is Dave: if so, it’ll respond with “I can’t do that” as before, otherwise if the name is Sam, it’ll respond with “Play it again”, and if it’s neither, it’ll be “Hello”. Phew! Look at that if expression! What a mouthful. And this will only get more annoying as we add more options.
Let's see the same program but using the (/=)
operator instead:
message :: String -> String
message name =
if name /= "Dave"
then if name == "Sam"
then "Play it again."
else "Hello."
else "I can't do that."
No surprises here, we just have to flip the branches around as we’ve done.
Having this many branches in our if
expressions is not very easy to read. Let’s look at a better way to do the same thing. Before we do this, though, it should be mentioned if you're writing these programs in, then realise that the spacing matters in Haskell! So, we must indent our lines properly. There is actually another way we can write haskell which uses lots of punctuation instead of spacing, but spacing looks nicer, so we will use that.
Here we’re using a case expression. You can see how it works pretty easily when comparing it to the nested if expressions from our previous example.
message :: String -> String
message name =
case name of
"Dave" -> "I can't do that."
"Sam" -> "Play it again."
_ -> "Hello."
main :: IO ()
main = putStrLn (message "Dave")
To evaluate a case
expression, the expression between “case
” and “of
” is first evaluated, then Haskell will run through all the patterns we have given it on the left of the ->
symbols, and try to pattern-match the value with them. If it finds a match, it returns the corresponding expression to the right of the ->
symbol.
Case
expressions are incredibly powerful because of the pattern matching we can do. Here we’ve just shown you an extremely basic example where the single name expression name
is matched to simple value String
patterns.
What about the underscore (_
) pattern? This pattern matches everything in Haskell, and it's included to make sure any time our function is called in the future with something we didn't anticipate, it will still work. Notice that the order matters. Dave will be matched before Sam. In this example it's not so important. Take a look at the following example, though:
message :: String -> String
message name =
case name of
_ -> "Hello."
"Dave" -> "I can't do that."
"Sam" -> "Play it again."
main :: IO ()
main = putStrLn (message "Dave")
The order matters! In this case, even if name
is "Dave"
, the code will never get that far, because the underscore matches on everything, and it’s first in the list!
In this case, our program will compile just fine, but it’s not what we want. This is called a logical error, because while it’s syntactically correct, it doesn’t have the correct logic. If we compile this with a Haskell compiler such as GHC, it will issue us a pattern-match overlap warning, letting us know that we’ve got multiple paths of logic flow for the same inputs.
Do note, also, that all of the types of the result expressions have to be the same. The same rule applies from the if
expressions, above. You can’t have a different result type in any of the expressions on the right. The whole case
expression is a single expression, so it must result in a value of a single type.
Let’s look at yet another way to do the same thing, this time using what’s called a guard pattern:
message :: String -> String
message name
| name == "Dave" = "I can't do that."
| name == "Sam" = "Play it again."
| otherwise = "Hello."
So we immediately notice that the function definition’s =
symbol is gone from the right hand side. It no longer says message name =
... but rather there are multiple “definitions” each with a pipe (|
) symbol in front of them.
The way this works is that the expressions on the left are tested for equality to True
, in order. When a True
value is found, it returns the expression to the right of the = sign that corresponds to that expression. This is not pattern matching like in the case expression, but rather test for truth, so it’s subtlely different. This is quite good for when you to test for several things.
Also notice that where a case
or if
expression can actually be inserted anywhere you like, this one is only usable in named function definitions. You can't use this form within a lambda, for example.
You can also see that we’re using something called otherwise
here as our default expression value. The otherwise
identifier is defined very simple, as True
, so we could have written this:
message :: String -> String
message name
| name == "Dave" = "I can't do that."
| name == "Sam" = "Play it again."
| True = "Hello."
Which of course, because it’s testing for True
, will always match. The only difference is, otherwise
actually means something to human programmers, so that’s why we use it.
Wherever possible, we should endeavour to make our programs as clear as possible for our future selves, and others who may want to read our code. Sometimes it’s quite literally the difference between our code being used or not.
There’s still one more way we can write this, so let’s see that now. This is just a simple regular function definition, but with many definitions, and using pattern matching on values in the argument list:
message :: String -> String
message "Dave" = "I can't do that."
message "Sam" = "Play it again."
message _ = "Hello."
main :: IO ()
main = putStrLn (message "Dave")
Here we’re using pattern matching again, but directly on the argument list. Notice that the name
variable is gone entirely, and our old friend the underscore is back. That’s because we’re directly pattern matching on what the name
value would be.
In the simple examples we’ve shown, none of the techniques stand out as obviously better or worse, except the if expression when we want to match on more than one thing.
The case expression is probably the best fit, because there’s less repetition. Each have benefits and drawbacks, and we’ll see more of each of them as we proceed. This section is mostly to get you familiar with them.
As what we need changes, the different conditionals will make more or less sense. What if we needed to detect if the name started with a “D”? In that case, the if expression or guard pattern would make more sense to use than the others. The case expression, or other direct pattern matching style examples wouldn’t make sense there.
Let’s finish by adding another simple clause to our case expression example:
message :: String -> String
message name =
case name of
"Dave" -> "I can't do that."
"Sam" -> "Play it again."
"Creep" -> "Feeling lucky?"
_ -> "Hello."
main :: IO ()
main = putStrLn (message "Dave")
Two parts to your homework. First is to try all the examples yourself. Experiment with changing "Dave"
to other names, then do an internet search for examples of each type of conditional technique and recognise the pieces.
Don’t get too worried that they won’t look as simple as our examples here. Just use our examples as a kind of guide, and try to pick out the pieces you do recognise, and don’t get confused if the examples you find look crazy. Ignore the crazy for now and look for the parts you do recognise.
The second part of your homework is to help reinforce your understanding of currying. Maybe you forgot what that was. It’s when we use two or more functions wrapped around themselves to make a way for a function to take multiple arguments.
Let’s look at a function that might look a little bit strange at first:
addThem :: Int -> Int -> Int -> Int -> Int
addThem a b c d = a + b + c + d
You can probably work out that this function adds its four arguments together. Of course, what we really mean is that it has the effect of having four arguments and adding them together, but we understand what is really happening when we define a function like this. What is really happening is that Haskell builds a “multi-level” function that looks like the following (notice the indentation and how each function argument lines up with an Int
in the type signature):
addThem :: Int -> (Int -> (Int -> (Int -> Int)))
addThem = \a -> (\b -> (\c -> (\d -> a + b + c + d)))
And, because the way function application works in Haskell, we can apply values to it in this way addThem 1 2 3 4
, then it will return 10
.
So, we can see from this that addThem
is defined as a function that takes a variable called a
and returns a function. That function uses a
deep inside its bowels; that second function is a function that takes a variable called b
, and so on, until you get to the inner function body of the function that takes d
as its argument. You can see how these variables match up to the type signature above it, which we’ve put parentheses around to group them, showing the way Haskell builds the functions up more clearly.
Well, to unpack this a little bit more, let’s make five different definitions to show each step of this process, and provide you with type annotations of each of those definitions. First up, we’ll create a definition that where we apply 1
to the a
variable, and so we can remove its outer function wrapper, the one with the a
variable in it:
-- here we have applied the addThem function to 1
addThemOne :: Int -> Int -> Int -> Int
addThemOne = addThem 1
-- which looks like this:
-- addThem 1 == \b -> (\c -> (\d -> 1 + b + c + d))
-- this is the same thing as applying the value 1
-- to that function / lambda:
-- (\a -> (\b -> (\c -> (\d -> a + b + c + d)))) 1
We can see from that quite clearly that 1
has been substituted in for a
within the function. We don’t need the outer part of the function syntax \a ->
any more because we’ve applied that function to the value 1
, which makes it disappear.
Next we’ll satisfy the b
variable with the value 2
, and we can therefore also similarly remove that function wrapper, because it has been applied:
-- here we have injected 2 into the addThemOne function
addThemOneToTwo :: Int -> Int -> Int
addThemOneToTwo = addThemOne 2
-- which is the same thing as
-- addThem 1 2
-- and would look like this:
-- addThemOne 2 == (\c -> (\d -> 1 + 2 + c + d))
-- which is the same thing as this
-- function application, ie applying 2 to it:
-- (\b -> (\c -> (\d -> 1 + b + c + d))) 2
Next we will place 3
into the c
variable. Are you noticing the type signatures? They’re getting smaller by one with each application of our function. This should make sense because each time we’re building a new function by applying a value to a previous function which produces a function of one less argument. Let’s continue:
-- here we have applied the addThemOneToTwo
-- function to the value 3
addThemOneToThree :: Int -> Int
addThemOneToThree = addThemOneToTwo 3
-- which is the same thing as
-- addThem 1 2 3
-- or addThemOne 2 3
-- and would look like this:
-- addThemOneToTwo 3 == (\d -> 1 + 2 + 3 + d)
-- again, this is the same as this
-- function application:
-- (\c -> (\d -> 1 + 2 + c + d)) 3
Finally, we put the value 4
into d
:
-- here we take 4 and put it into
-- the addThemOneToThree function
addThemOneToFour :: Int
addThemOneToFour = addThemOneToThree 4
-- which is the same thing as
-- addThem 1 2 3 4 or
-- addThemOne 2 3 4 or
-- addThemOneToTwo 3 4 or
-- and would look like this:
-- addThemOneToThree 4 == 1 + 2 + 3 + 4
-- which equals 10
-- again, this is the same as this
-- function application:
-- (\d -> 1 + 2 + 3 + d) 4
We hope this is clear, if it’s not at all, we’d really appreciate your feedback. You can give it by following the feedback link on the main page.
If you’ve enjoyed reading this, please consider purchasing a copy at Leanpub today
Please follow us, and check out our videos Follow @HappyLearnTutes
Also, Volume 2 is now in beta and being written! Show your support and register your interest at its Leanpub site.