Intro to Haskell
Updated: 28 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:
And booleans similarly:
And equality:
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
Or with multiple arguments
If a function takes two parameters we can call it using infix notation with backticks surrounding the function name, for example the min
function:
Functions
We can define our own functions using a syntax like:
We can put that into a file, e.g. called double.hs
, and load it into the REPL with :l
:
We can then use the function that was defined
A function that takes multiple arguments can be defined by separating the arguments by a space
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
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:
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
They can also be concattenated with ++
++
can also be used with strings:
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:
Elements can be accessed from a list using !!
Lists can also be compared. Comparison of lists are done item by item, for example a list
Some functions for working with lists are:
head
- gets the first itemtail
- gets everything other than the first elementlast
- gets the last iteminit
- gets everything other than the last elementlength
- gets the length of a listnull
- checks if the list is emptyreverse
- reverses a listtake
- takes a certain number of elements from a listdrop
- drops a certain number of elements from a listminimum
- gets the smallest element of a listmaximum
- gets the largest element of a listsum
- adds up items of a listproduct
- multiplies all numbers in a listelem
- checks if an element is in a list, usually called as an infix method since it’s more readable that wayreplicate
- 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
They can even be defined using the first two values in the case where we want to use a step larger than 1
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:
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 rangerepeat
- 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:
These can also be used with multiple variables in order to produce permutations of a multi-variable expression
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
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 tuplesnd
- gets the second item from a tuple
It’s not too difficult to implement these functions, an example implementation may look like so:
And we can compare our usage with the base implementation
Another nice function for working with tuples is zip
which takes two lists and iterates over them and outputs a list of tuples
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
We can specify the types of a function by writing it above a function implementation, for example:
Type Variables
We can look at the types of some more complex values
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 ofOrd
requiresEq
as wellShow
- means that something can be printed usingshow
Read
- means that something can be read from aString
into some type with theread
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
Additionally, we also have some number types:
Num
- the base number typeInt
- a bounded integerInteger
- a non-bounded integerFloat
- a single precision, floating point numberDouble
- a double precision, floating point numberEnum
- 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:
And when using this we can see how the inputs are handled:
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
And we can use that to get the first 10 triangular numbers:
Pattern matching can also be done in more interesting ways, for example we can use it with tuples like so:
And similarly for lists, for example:
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
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:
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:
These are also usually indented for readability
Where bindings can also pattern match just as we would when defining a function:
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
Or with pattern matching:
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
You can even define functions in them, of course:
You can also use it in the predicate section of a list comprehension and do stuff with that:
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
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:
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:
We can also define infinitely recursive functions, for example:
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:
And that just works as expected
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:
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:
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:
It’s clear when we use it how this works:
Higher Order Functions
We can define higher order functions as normal functions
We can then provide it with a function:
A very useful higher order function is flip
, which is basically flips the arguments to a function:
Maps and Filters
Two common higher order functions are map
and filter
map
- applies a function to each value of a listfilter
- 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:
Or even using list comprehension:
And using it as such:
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:
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:
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:
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:
The same idea can be applied to the elem
method:
Equally, you can use a fold-right which does the same but iterates from the right. Additionally, the lambda arguments are flipped around:
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:
Function Application
Function application is done using the $
operator and is defined as such:
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:
As
Thus, effectively reducing the precedence of function application
Additionally, this can also be used to apply a function dynamically, for example:
Function Composition
Function composition is defined mathematically as (f.g)(x) = f(g(x))
. In Haskell this is the same:
Using this, we can convert:
Into:
Or using what we just learnt about the function application:
This mechanism also makes it possible to write functions in a more point-free style, for example:
Modules
A module is a collection of related functions and types. A program is a collection of modules
Modules can be imported in a few different ways, for the sake of example we will use some standard library modules
To import everything:
To import everything but require the fully qualified name:
Import a module but provide an alias for qualifying:
Import specific stuff:
Import everything except some stuff:
Data.List
Has some useful methods for working with lists, some of these that are useful are:
In addition to the list functions from the Lists section, we also have:
intersperse
- puts an element between list itemsintercalate
- puts an element between list items and then flattens the resulttranspose
- transposes a list of listsfold1'
andfoldl1'
- stricter versions of the lazyfold
methods, used when the basefold
implementation yields a stack overflow for large listsconcat
- flattens a list of lists one levelconcatMap
- the same as mapping into a list and then concatenating the resultsand
- takes a list of booleans and returns if all items areTrue
or
- takes a list of booleans and returns if any items areTrue
all
- likeand
, but takes a predicate and a listany
- likeor
, but takes a predicate and a listiterate
- takes an iterator function and a starting value, returns an infinite listsplitAt
- splits a list at that many itemstakeWhile
- takes all items of list while a predicate isTrue
dropWhile
- drops all items of list while a predicate isTrue
span
- returns a tuple of the result oftakeWhile
anddropWhile
break
- splits a list where the predicate is firstTrue
(kind of the opposite of span)sort
- sorta a list ofOrd
group
- groups adjacent elements if they’re equalinits
- likeinit
but recursively gets all growing lists from the starttails
- liketail
but recursively gets all growing lists from the endisInfixOf
- is list contained in other listisPrefixOf
- list starts with other listisSuffixOf
- list ends with other listisSubsequenceOf
- list contains other listpartition
- takes a predicate and separates items that meet the predicate from those that don’tfind
- finds an element that meets a specific predicate, returns aMaybe
elemIndex
- gets aMaybe
index of the element in the listelemIndices
- gets a indices of the elements in the listfindIndex
- gets aMaybe
index of the element that satisfies a predicatefindIndices
- gets list of indices of elements that satisfy predicatezipWith3
,zipWith4
, etc. - zips a list of varying sizeslines
- splits string into lineswords
- splits string into wordsnub
- deduplicates listdelete
- deletes the first occurrence of element in list\\
- removes all elements to the right from the left:left \\ right
union
- returns a union of two lists, removes duplicates from the second listintersect
- intersection of two listsinsert
- inserting into a sorted list keeps the list sortedsortBy
,groupBy
,insertBy
,maximumBy
, etc. - take a function to determine the order of two elements and apply that to the relevant method
Data.Char
Functions for working with Chars
isControl
isSpace
- any whitespace characterisLower
isUpper
isAlpha
isAlphaNum
isPrint
isDigit
isOctDigit
isHexDigit
isLetter
- `isMark
isNumber
isPunctuation
isSymbol
isSeparator
isAscii
isAsciiUpper
isAsciiLower
digitToInt
- converts a char to an int if it is in ranges0..9
,a..f
orA..F
intToDigit
- converts0..15
to0..9,a..f
ord
- convert character to their numberchr
- convert number to character
Data.Map
Methods for working with association lists or dictionaries
Maps are made from a list of tuples with a key and value
Map.fromList
- creates a map from a list of tuplesMap.empty
- creates an empty mapMap.insert
- inserts an item into a mapMap.null
- checks if a map is emptyMap.size
- gets the size of a mpaMap.singleton
- creates a singleton map from a key and valueMap.map
- works likeList.map
Map.filter
- works likeList.filter
Map.member
- check if item is member of mapMap.toList
- creates a list of key/value pairsMap.fromListWith
- likeMap.fromList
but takes a function for creating a map from the items for each key, since this can have duplicatesMap.insertWith
- same asMap.fromListWith
but for insertionMap.lookup
- gets the item from a map
Data.Set
All elements in a set are unique. Sets are implemented as trees and are ordered. Operations like inserting, deleting, checking for existence, are much faster than for lists
Many of these methods are similar to their List
or Map
equivalents
Set.fromList
Set.difference
Set.null
Set.union
Set.singleton
Set.size
Set.insert
Set.isSubsetOf
Set.isProperSubsetOf
Set.filter
Defining a Module
When defining a module we specify the name of the module and then the items that it exports followed by where
, for example:
The name of the module file should also match name of the module, e.g. QuickMath.hs
for the above
Custom Types and TypeClasses
Algebraic data types
Algebraic types can be defined using data Name = T1 | T2
, for example, we can define a type of Food
like so:
However, if we try to print out our Food
we have an error, in order to make it printable we need to make it Showable, that can be done as:
We can introduce intermediate types as well, e.g. for our flavours:
We can make methods that work with the custom data types we defined as normal:
So we can use this as:
Records
If we want to have lots of properties in our type it would be nice if we could label them a little better, we can do that using records which are an alternative way to write data types:
And used as:
Type Parameters
Algebraic types can also type type parameters using the syntax as:
We can have one or more parameters and use it as needed as we can see above
Derived Instances
As we’ve seen with deriving typeclasses, we can derive behavior for these types, they’re a little like interfaces or traits in other languages. Some typeclasses that we can derive are: Show
, Read
, Eq
, Ord
, Bounded
, Enum
For a simple example, we can define a data type for our meals:
And that can be used as:
Type Synonyms
These provide a way for us to give an alternative name to an existing type, for example, rewriting our Food
data type above:
This makes it a bit easier to understand the intention of the different fields in our type
We can also use these to define concrete types that specify type constructors (generic types):
Recursive Data Structures
Data structures can also be recursive, an example of this is how the List
type is defined:
We can define our own type for recipes recursively like so:
Infix Function Definitions
Functions that are made up of only special characters are automatically infix. So we can define a function for composing recipes:
In the above, we see that we can also define the fixity
of a function, this lets us specify the precedence of this function. The above function lets us defined a recipe in “reverse” as so:
The resulting recipe looks like so:
Note this isn’t really a natural way to depict this, but for the purpose of depicting fixity it gives us something to work with
Typeclasses
As mentioned previously, typeclasses provide additional behavior to types, the Eq
typeclass is defined in the prelude as:
In this definitions, equality is defined recursively.
We can also implement our own definition of equality by ensuring we have implemented all the relevant function definitions. For the sake of equality we only need to implement one of the definitions since they’re defined recursively
We can implement equality for two food types provided their mass
is the same, so:
Or, alternatively, written in infix notation:
We can also implement Show
:
Note that in the where
we can simply define the show
function using some pattern matching. We can also define this externally and reuse it inside our typeclass instance:
Since this has been defined, the Food
type no longer needs to derive Show
on it’s own since it has a concrete implementation
It’s also possible to derive for type constructors, for example, you could derive equality for Maybe
like:
Note that since we are comparing x == y
we need to ensure that the types are Eq
, so we can only implement a typeclass for a type constructor provided that the given type implements Eq
, this is similar to generic constraints in functions
The Functor Typeclass
The Functor typeclass is basically used for anything that can be mapped over, e.g. Lists
implement this, this is defined as follows:
For lists, map
is defined simply as:
We can implement Functor
for Maybe
as:
Kinds
Type constructors are like functions that work over types to get concrete types. A Kind
is a way to talk about the “type of a type”, we can view the kind of something using :k
in GHCI, for example:
The *
represents where a concrete type is required. For example Int
returns a concrete type, whereas Maybe
takes one concrete type and returns a concrete type