Intro to Haskell

Updated: 17 November 2024

Notes based on Learn you a Haskell

Prerequisites

Setup a Haskell Environment as per the Getting Started Docs as well as install the Haskell extension for your editor

This installation process is a little slow, but stay with it I guess. (it’s still easier than the Ocaml install)

Syntax Basics

To write the first bits of Haskell we can use the REPL which, once installed, can be accessed with the ghci command

Math can be done in a fairly normal fashion:

1
gchi> 5 + 17
2
22
3
4
gchi> 5 * 42
5
210
6
7
gchi> 5 / 2
8
2.5

And booleans similarly:

1
ghci> True && False
2
False
3
4
gchi> not False
5
True

And equality:

1
ghci> 5 == 5
2
True
3
4
gchi> 5 == 10
5
False
6
7
gchi> 5 /= 10
8
True
9
10
gchi> "hello" /= "world"
11
True

All these basic operators are functions in Haskell that work using infix notation. We can also refer to them purely as a function by wrapping them in brackets, e.g. (+) or (==)

Functions can be called using the name of the function followed by the arguments, separated by spaces

1
gchi> succ 5
2
6

Or with multiple arguments

1
ghci> min 9 10
2
9

If a function takes two parameters we can call it using infix notation with backticks surrounding the function name, for example the min function:

1
ghci> 9 `min` 10
2
9

Functions

We can define our own functions using a syntax like:

double.hs
1
doubleMe x = x + x

We can put that into a file, e.g. called double.hs, and load it into the REPL with :l:

1
ghci> :l double
2
[1 of 2] Compiling Main ( double.hs, interpreted )
3
Ok, one module loaded.

We can then use the function that was defined

1
ghci> doubleMe 5
2
10

A function that takes multiple arguments can be defined by separating the arguments by a space

double.hs
1
doubleMe x = x + x
2
3
doubleUs x y = doubleMe x + doubleMe y

In the above cases, Haskell is able to determine the types of our function as: doubleUs:: Num a => a -> a -> a

We can make a function that does some conditional stuff using thenif .. then .. else expression

double.hs
1
doubleSmall x =
2
if x < 100
3
then x * 2
4
else x

Note this is not whitespace sensitive

Functions that take no parameters are also called definitions or names, we can defined a function that is equal to some string value as follows:

1
bobsName = "Bob Smith"

Function names must start with a lowercase letter and can contain a ', e.g. bob'sName = "BobSmith"

Lists

Lists store elements of the same type. Lists can be defined using [] with elements separated by commas

1
myList = [1, 2, 3, 4]

They can also be concattenated with ++

1
biggerList = [1, 2, 3, 4] ++ [5, 6]

++ can also be used with strings:

1
"hello" ++ " " ++ "world"

When concatenating elements Haskell needs to traverse the entire list which can negatively impact performance. An alternative way of adding an item to a list is the the cons (:) operator:

1
ghci> 1 : [2, 3, 4]

Elements can be accessed from a list using !!

1
ghci> [1, 2, 3, 4] !! 2
2
3

Lists can also be compared. Comparison of lists are done item by item, for example a list

1
ghci> [1,2,3] > [4,5,6]
2
False
3
ghci> [1,2,3] > [0,1,2]
4
True

Some functions for working with lists are:

  • head - gets the first item
  • tail - gets everything other than the first element
  • last - gets the last item
  • init - gets everything other than the last element
  • length - gets the length of a list
  • null - checks if the list is empty
  • reverse - reverses a list
  • take - takes a certain number of elements from a list
  • drop - drops a certain number of elements from a list
  • minimum - gets the smallest element of a list
  • maximum - gets the largest element of a list
  • sum - adds up items of a list
  • product - multiplies all numbers in a list
  • elem - checks if an element is in a list, usually called as an infix method since it’s more readable that way
  • replicate - produces a list by repeating some value a certain number of times

Calling head, tail, last, or init on an empty list will throw an error

Ranges

Ranges allow us to define sequences of enumerable values. Ranges can be defined using a start and end value

1
ghci> [1..10]
2
[1,2,3,4,5,6,7,8,9,10]
3
4
ghci> ['a'..'k']
5
"abcdefghijk"

They can even be defined using the first two values in the case where we want to use a step larger than 1

1
ghci> [1,3..20]
2
[1,3,5,7,9,11,13,15,17,19]
3
4
ghci> ['a','d'..'z']
5
"adgjmpsvy"

Additionally, ranges can also be infinite by not specifying the end of the range. We can see the result of doing this by taking some elements from the range as below:

1
ghci> take 10 [1..]
2
[1,2,3,4,5,6,7,8,9,10]
3
4
ghci> take 30 ['a'..]
5
"abcdefghijklmnopqrstuvwxyz{|}~"

There are also a few useful functions for working with ranges such as:

  • cycle - which repeats the elements of a given list to produce an infinite range
  • repeat - creates an infinite range of a repeated value

List Comprehensions

List comprehensions are like Set Comprehensions in math and allow us to define some general sets using a set of predicates

A set comprehension follows the structure of [ expression | variable <- values, predicate]. This is more clear in practice:

1
ghci> [x^3 - 1 | x <- [1..5]]
2
[0,7,26,63,124]
3
4
ghci> [x^3 - 1 | x <- [1..10], x `mod` 2 == 0]
5
[7,63,215,511,999]
6
7
ghci> [x^3 - 1 | x <- [1..10], x `mod` 2 /= 0]
8
[0,26,124,342,728]

These can also be used with multiple variables in order to produce permutations of a multi-variable expression

1
ghci> [a ++ " " ++ b | a <- ["hello", "hi"], b <- ["world", "bob"]]
2
["hello world","hello bob","hi world","hi bob"]

Tuples

Tuples work pretty much the same as in other languages. A tuple lets us store multiple values together. Items in a tuple can be different types, we can use this as we’d expect

1
ghci> (1, 'a')
2
(1,'a')
3
4
ghci> [(1, 'a'), (2, 'b')]
5
[(1,'a'),(2,'b')]
6
7
ghci> [(x, y) | x <- [1..3], y <- ['a'..'c']]
8
[(1,'a'),(1,'b'),(1,'c'),(2,'a'),(2,'b'),(2,'c'),(3,'a'),(3,'b'),(3,'c')]

When a tuple has only two elements we refer to it as a pair. Pairs have some methods for working with them, namely:

  • fst - gets the first item from a tuple
  • snd - gets the second item from a tuple

It’s not too difficult to implement these functions, an example implementation may look like so:

1
fst' (x,_) = x
2
3
snd' (_,y) = y

And we can compare our usage with the base implementation

1
ghci> fst' (1,2)
2
1
3
ghci> snd' (1,2)
4
2
5
6
ghci> fst (1,2)
7
1
8
ghci> snd (1,2)
9
2

Another nice function for working with tuples is zip which takes two lists and iterates over them and outputs a list of tuples

1
ghci> zip [1,2,3] ['a','b','c']
2
[(1,'a'),(2,'b'),(3,'c')]

Types

Haskell is statically typed. Thus far we haven’t had to annotate the types of our functions since the language is pretty good at inferring the types of stuff

We can use :t in the REPL to view the type of something

1
ghci> :t 5
2
5 :: Num a => a
3
4
ghci> :t 'b'
5
'b' :: Char
6
7
ghci> :t "Hello"
8
"Hello" :: String

We can specify the types of a function by writing it above a function implementation, for example:

1
doubleMe :: Int -> Int
2
doubleMe x = x + x

Type Variables

We can look at the types of some more complex values

1
ghci> :t fst
2
fst :: (a, b) -> a
3
4
ghci> :t (+)
5
(+) :: Num a => a -> a -> a
6
7
ghci> :t (==)
8
(==) :: Eq a => a -> a -> Bool

In the above functions, we can also see that some functions make use of a type variable, these types are denoted by the lowercase character in the type signature as in fst :: (a, b) -> a.

We can also see that functions can specify a constraint on their type variables, for example (+) :: Num a => a -> a -> a constrains a to be of type Num. The constraint is denoted before the => symbol in a type definition

Lastly, it’s also important to note that functions don’t differentiate between multiple inputs an outputs. So a function that takes multiple input just uses -> to separate values, this is because functions can partially apply them which yields another functions

Type Classes

A typeclass is like an interface that defines some kind of behavior. (They seem a bit like traits in Rust)

Some common typeclasses are:

  • Eq - which makes something comparable with == or /=
  • Ord - used for ordering, makes something comparable with >, <, or ==. So implementation of Ord requires Eq as well
  • Show - means that something can be printed using show
  • Read - means that something can be read from a String into some type with the read function

The read function is interesting because it lets us read a value into multiple different types. In cases where this type cannot be inferred you can specify the type that something needs to be read into using :: Type which lets us provide a value to a type variable

1
ghci> read "7" :: Int
2
7
3
4
ghci> read "7" :: Float
5
7.0
6
7
ghci> read "[1,2,3]" :: [Float]
8
[1.0,2.0,3.0]

Additionally, we also have some number types:

  • Num - the base number type
  • Int - a bounded integer
  • Integer - a non-bounded integer
  • Float - a single precision, floating point number
  • Double - a double precision, floating point number
  • Enum - an enumerable value, these can be used in ranges

When working with numbers the fromIntegral function can be used to convert between number types, namely swapping between floating point and integer number types which is sometimes needed

Functions

Functions in Haskell have some interesting functionality when compared to most other languages. The first of this is how we can define different implementations for functions using pattern matching

Pattern Matching

Pattern matching when defining a function is done by essentially providing multiple implementations for a function that match to the same type signature but different concrete parameters. For example, the below function reacts differently depending on the name that is provided:

1
sayHi :: String -> String
2
sayHi "Bob" = "Good morning, sir!"
3
sayHi name = "Hello " ++ name

And when using this we can see how the inputs are handled:

1
ghci> sayHi "Jeff"
2
"Hello Jeff"
3
4
ghci> sayHi "Bob"
5
"Good morning, sir!"

We can use this idea to implement something like an implementation of the triangular number calculation that uses a but differentiating the recursive base case as a pattern

1
triangleNum :: (Integral a) => a -> a
2
triangleNum 0 = 0
3
triangleNum n = n + triangleNum (n - 1)

And we can use that to get the first 10 triangular numbers:

1
ghci> [triangleNum x | x <- [1..10]]
2
[1,3,6,10,15,21,28,36,45,55]

Pattern matching can also be done in more interesting ways, for example we can use it with tuples like so:

1
isFirstOne :: (Integral a, Integral b) => (a, b) -> String
2
isFirstOne (1, _) = "Yes"
3
isFirstOne (_, _) = "No"

And similarly for lists, for example:

1
tryFirstTwo :: [a] -> [a]
2
tryFirstTwo (x : y : xs) = [x, y]
3
tryFirstTwo (x : xs) = [x]
4
tryFirstTwo [] = []

When defining patterns, it’s important to keep the most specific patterns first, since they are matched in order (the compiler will warn you if your order is incorrect so that’s nice). Note that non-exhaustive patterns will be a runtime error so it’s important to ensure that we handle these as well

Another nice benefit of pattern matching is that we can use it for destructuring elements, for example with a tuple

1
addPair :: (Num a) => (a, a) -> a
2
addPair (x, y) = x + y

Guards

Guards allow us to branch based on a condition. They’re very similar to if/else statements and are evaluated in order

A simple guard can check some conditions:

1
howBig num
2
| num <= 0 = "Nothing really"
3
| num < 1 = "Really small"
4
| num >= 10 = "Wow that's a big number"
5
| otherwise = "Nothing notable here"

Where Bindings

Where bindings let us define local values within a function and can be done by using where with each binding on a different line:

1
f x = m * x + c
2
where
3
m = 10
4
c = 20

These are also usually indented for readability

Where bindings can also pattern match just as we would when defining a function:

1
f x = m * x' + c
2
where
3
(m, c) = (10, 20)
4
x' = sqrt x

Let Expressions

These are very similar to where bindings but can be used anywhere and are expressions in themselves, the values in the let part are accessible in the in part

Otherwise, they work as you’d expect

1
f x =
2
let
3
m = 10
4
c = 20
5
in m * x + c

Or with pattern matching:

1
f x =
2
let m = 10
3
c = 20
4
x' = sqrt x
5
in m * x' + c

An important distinction is that let expressions can be used in any place where we may need an expression, and evaluate to the in part

1
f xs =
2
[ let m = 10
3
c = 20
4
in m * x + c
5
| x <- xs
6
]

You can even define functions in them, of course:

1
f xs =
2
[ let f' x = 10 * x + 20
3
in f' x
4
| x <- xs
5
]

You can also use it in the predicate section of a list comprehension and do stuff with that:

1
f xs =
2
[y | x <- xs, let y = 10 * x + 20, y > 30.0]

This seems a bit syntax-dense to me, but I see why being able to refer to the value bound in the let expression could be useful

Case Expression

Case expressions let us pattern match within a function and get the resulting expression. This is pretty much as you’d expect as well

1
nameOf c = case c of
2
'a' -> "Alice"
3
'b' -> "Bob"
4
c -> [c]

Pattern matching in function definitions is actually just syntax sugar for case expressions so you can define the name function using a where binding with which would be the same as a case expression:

1
hi c = "Hi " ++ name c
2
where
3
name 'a' = "Alice"
4
name 'b' = "Bob"
5
name c = [c]

Recursion

Recursion works as normal. A nice benefit we have in Haskell is that we can define the recursive base case as a pattern definition:

1
biggest [] = error "list is empty"
2
biggest [x] = x
3
biggest (x : xs)
4
| x > rest = x
5
| otherwise = rest
6
where
7
rest = biggest xs

We can also define infinitely recursive functions, for example:

1
ones = 1 : ones

And because Haskell supports infinite lists, we don’t have to have an escape condition. This is useful and can be composed with other things, for example:

1
ghci> take 5 ones
2
[1,1,1,1,1]

And that just works as expected

1
quicksort :: (Ord a) => [a] -> [a]
2
quicksort [] = []
3
quicksort (x : xs) =
4
quicksort smaller ++ [x] ++ quicksort bigger
5
where
6
smaller = [a | a <- xs, a <= x]
7
bigger = [a | a <- xs, a > x]

Higher Order Functions

Functions that can take a function as parameters or return another function are called higher order functions

Curried Functions

Every function in Haskell only takes a single parameter. Functions that look like they take multiple parameters are in fact functions that return functions that take the remaining parameters. We can call these curried functions

An example of this is the max function, it can be used in the following two equivalent manners:

1
ghci> max 1 2
2
2
3
4
ghci> (max 1) 2
5
2

This is because max 1 returns a function that takes the second value. This idea lets us partially apply functions that require multiple parameters, for example:

1
triple :: a -> b -> c -> (a, b, c)
2
triple a b c = (a, b, c)
3
4
with1 :: b -> c -> (Integer, b, c)
5
with1 = triple 1
6
7
with12 :: c -> (Integer, Integer, c)
8
with12 = triple 1 2

In the above, we can see that the with1 and with2 are partially applied versions of the triple function in which a or a and b are provided

Infix functions can also be applied partially using sections - a section is done by surrounding the function with parenthesis and putting a parameter on one side. This means that we can also apply it differently depending on how we want to use it, for example:

1
divByTwo = (/ 2)
2
3
twoDivBy = (2 /)

It’s clear when we use it how this works:

1
ghci> divByTwo 10
2
5.0
3
4
ghci> twoDivBy 10
5
0.2

Higher Order Functions

We can define higher order functions as normal functions

1
applyTwice :: (t -> t) -> t -> t
2
applyTwice f x = f (f x)

We can then provide it with a function:

1
excited s = s ++ "!"
2
veryExcited = applyTwice excited

A very useful higher order function is flip, which is basically flips the arguments to a function:

1
flip f x y = f y x

Maps and Filters

Two common higher order functions are map and filter

  • map - applies a function to each value of a list
  • filter - returns a list with only items that return true from a predicate function

These functions are defined in the standard library. A basic definition for them might look as follows:

1
map' :: (a -> b) -> [a] -> [b]
2
map' _ [] = []
3
map' f (x : xs) = f x : map' f xs
4
5
filter' :: (a -> Bool) -> [a] -> [a]
6
filter' f (x : xs)
7
| f x = x : filter' f xs
8
| otherwise = filter' f xs

Or even using list comprehension:

1
map' f xs = [f x | x <- xs]
2
3
filter' f xs = [x | x <- xs, f x]

And using it as such:

1
ghci> map' (*2) [1,2,3,4]
2
[2,4,6,8]
3
4
ghci> filter' even [1,2,3,4]
5
[2,4]

As we can see from the implementation, this behavior can be done using list comprehension pretty much directly. The usage of each really just depends on what is more readable ina given scenario

Since Haskell is lazy, mapping or filtering lists multiple times still only iterates through the list once

Lambdas

When working with higher order functions we often have a function that we’d just like to use once, to do this we can define a lambda which is an anonymous function. They are defined using the \p1 p2 -> expression

For example:

1
\x -> x + 5

Often we need to surround them in parenthesis since they will extend to the end of the line otherwise. Using it is done as follows:

1
ghci> map (\x -> x + 5) [1..5]
2
[6,7,8,9,10]

Folds and Scans

When working with recursion we often run into an edge case with the empty list, this is a pretty common pattern called folding. Folds reduce a list to some single value

We can use the fold-left method foldl to implement a sum function:

1
sum' xs = foldl (\acc x -> acc + x) 0 xs

foldl takes a lambda to handle the accumulation, the initial value, and the array. Note that for the above case in which we’re writing the sum function, it can be replaced more simply as:

1
sum' :: (Num a) => [a] -> a
2
sum' = foldl (+) 0

The same idea can be applied to the elem method:

1
elem' y = foldl (\acc x -> x == y || acc) False

Equally, you can use a fold-right which does the same but iterates from the right. Additionally, the lambda arguments are flipped around:

1
elem' y = foldr (\x acc -> x == y || acc) False

Folds are pretty powerful and can be used to implement lots of other standard library functions

A nice compositional example is how we can use these methods with flip to do something like reverse a list:

1
reverse' xs = foldl (flip (:)) [] xs

Function Application

Function application is done using the $ operator and is defined as such:

1
($) :: (a -> b) -> a -> b

The purpose of this is to reduce the precedence of function application. This means that instead of doing e(f(g x)) we can do e $ f $ g x

So we can rewrite something like:

1
ghci> sum (filter (> 10) (21:[7..15]))
2
86

As

1
ghci> sum $ filter (> 10) $ 21:[7..15]
2
86

Thus, effectively reducing the precedence of function application

Additionally, this can also be used to apply a function dynamically, for example:

1
ghci> map ($ 10) [(1+), (2+), (3+)]
2
[11,12,13]

Function Composition

Function composition is defined mathematically as (f.g)(x) = f(g(x)). In Haskell this is the same:

1
(.) :: (b -> c) -> (a -> b) -> a -> c

Using this, we can convert:

1
ghci> negate (abs (product [-1, 5, 4]))
2
-20

Into:

1
ghci> (negate . abs . product) [-1, 5, 4]
2
-20

Or using what we just learnt about the function application:

1
ghci> negate . abs . product $ [-1, 5, 4]
2
-20

This mechanism also makes it possible to write functions in a more point-free style, for example:

1
f :: (Num a) => [a] -> a
2
f = negate . abs . product

Modules