Haskell: type classes and higher-order polymorphism

Table of Contents

1. Review

1.1. Maybe

A commonly used data type in Haskell is Maybe a:

data Maybe a = Just a | Nothing deriving (Show, Eq)

Maybe a is like the data type a on its own, but with extra information about whether the a computational succeeded for failed.

1.2. Key-value pairs

As an example, say we have the following type representing a table of key-value pairs, with String being the type of the key and Integer being the type of the value:

type Table = [(String, Integer)]

Then, we can define a function for looking up the value associated with any given key, as follows:

lookUp :: String -> Table -> Maybe Integer
lookUp s [] = Nothing
lookUp s ((s', i) : t) = if s' == s then Just i  else lookUp s t

For instance, given the following table

yearFounded :: Table
yearFounded =
  [ ("The Smiths", 1982)
  , ("Joy Division", 1976)
  , ("New Order", 1980) ]

one could do

ghci> lookUp "The Smiths" yearFounded
Just 1982
ghci> lookUp "The Stone Roses" yearFounded
Nothing

Importantly, no actual exception was thrown when I tried to look up "TheStone Roses". Instead, lookUp returned a value, Nothing, inhabitting the data type Maybe Integer.

One way to think of the Maybe a data type is that it represents an action your computer can perform—throwing an error—as data. This illustrates what in Haskell is a pretty commonly used technique of bluring the lines between effects and data.

1.3. div

Let's look at the function

div :: Integer -> Integer -> Integer

which behaves as follows:

ghci> div 4 2
2
ghci> div 4 0
*** Exception: divide by zero

div is a partial function; it is only defined when its second argument is not 0.

A total version of div would be the following function safeDiv which uses maybe types:

safeDiv :: Integer -> Integer -> Maybe Integer
safeDiv m n = if n == 0 then Nothing else Just (div m n)

1.4. Either

A generalization of Maybe a is the data type Either a b:

data Either a b = Left a | Right b deriving (Show, Eq)

Here, Left means failure, and Right means success. Either types are a generalization of Maybe types, in the sense that we could encode Maybe as:

type Maybe' a = Either () a
nothing' :: Maybe' a
nothing' = Left ()
just' :: Maybe a
just' = Right

1.5. Type classes

Type classes are one of the most famous distinguishing features of Haskell. A type class allows you to provide multiple implementations of what looks like the same function on different types. These implementations are called instances. Here are some examples:

1.6. Show

We have already seen type classes when we've used deriving Show in data type declarations. But we can actually implement Show instances ourselves. Imagine that we defined a new data type Table (instead of merely using Table as a type alias):

data Table = Table [(String, Integer)] deriving (Show, Eq)

Given the following declaration of the type class Show:

class Show a where
  show :: a -> String

we may define the Table instance of Show as follows:

instance Show Table where
  show (Table []) = "Empty Table"
  show (Table [(b, y)]) = b ++ " : " ++ show y
  show (Table ((b, y) : t)) = b ++ " : " ++ show y ++ " | " ++ show (Table t)

This will give us the following behavior, for example:

ghci> Table [("The Smiths", 1982), ("Joy Division", 1976)]
The Smiths : 1982 | Joy Division : 1976

1.7. Eq

Another useful type class is Eq:

class Eq a where
  (==) :: a -> a -> Bool
  (/=) :: a -> a -> Bool
  a /= b = not (a == b)

Like Show instances, Eq instances can be derived. But we can also define our own Eq instances, e.g., for the Table data type:

instance Eq Table where
  Table a == Table b = a == b

1.8. Declaring type classes

We can also declare our own type classes. To give an example of this, let's first define the following data types encoding some models of different personal computer brands from the 80s:

data Apple
  = IIc | IIe | GS 
  deriving (Show, Eq) 

data IBM
  = PC | PCJr | XT | AT 
  deriving (Show, Eq) 

data Commodore
  = C64 | C128 
  deriving (Show, Eq)

Let's say we want to define a type class which, given a brand, provides a function that determines which reboot keys are available for each of its models. Given the following data type Key

data Key  
  = Ctrl 
  | Alt 
  | Del 
  | Option 
  | Apple 
  | Reset 
  | PowerButton 
  deriving (Show, Eq)

we can declare a type class Rebootable:

class Rebootable a where
  rebootKeys :: a -> [Key]

And then we can declare the following instances:

instance Rebootable IBM where 
  rebootKeys _ = [Ctrl, Alt, Del]

instance Rebootable Apple where 
  rebootKeys _ = [Ctrl, Option, Reset] 

instance Rebootable Commodore where 
  rebootKeys C64 = [PowerButton] 
  rebootKeys C128 = [Reset]

Each of these instances of Rebootable determines how to map any model onto a list of keys for some brand.

1.9. Kinds of polymorphism

Each of the type classes we've looked at provide what is known as ad hoc polymorphism. This polymorphism is ad hoc because only some data types need provide instances for the relevant type variable (e.g., the a in Show a). In addition, different instances can have fundamentally different definitions from each other.

Ad hoc polymorphism is therefore contrasted with parametric polymorphism, which has the following features:

  • We can't constrain the instantiating data type ahead of time.
  • Parametric polymorphic functions come in families all of whose members act fundamentally the same way.

Examples of parametric polymorphism are the functions on lists map, filter, foldl, and foldr. In general, it doesn't matter what type of data any given list holds; these functions will behave the same on that list, regardless.

Now, for a couple more important type classes…

1.10. Foldable

foldr and foldl are actually methods of a type class Foldable:

class Foldable t where
  foldl :: (b -> a -> b) -> b -> t a -> b
  foldr :: (a -> b -> b) -> b -> t a -> b

Thus lists aren't the only things which are foldable; for example, Maybe is, as well:

instance Foldable Maybe where
  foldl f b Nothing = b
  foldl f b (Just a) = f b a
  foldr f b Nothing = b
  foldr f b (Just a) = f a b

1.11. Functor

Functors provide another class, with a single method fmap:

class Functor f where
  fmap :: (a -> b) -> f a -> f b

Importantly, fmap is expected to satisfy certain laws (the Functor laws):

-- fmap id = id -- Identity
-- fmap (f . g) = fmap f . fmap g -- Composition

Here, (.) is function composition, i.e.:

(f . g) x = f (g x)

What these laws effectively say, therefore, is that turning a function g :: a -> b into a function fmap g :: f a -> f b on data which has been lifted into the functor via f doesn't fiddle with the identity function and isn't sensitive to whether you have composed two functions before or after doing the lifting.

For some examples, we have the following instances of the class Functor:

instance Functor [] where
  fmap = map

instance Functor Maybe where
  fmap f (Just a) = Just (f a)
  fmap f Nothing = Nothing

instance Functor (Either a) where
  fmap f (Left a) = Left a
  fmap f (Right b) = Right (f b)

2. Exercises

2.1. Part 1

Let's define the new data type Tree a, which packages inhabitants of any type a up into a tree-like data structure:

data Tree a = Empty | Leaf a | Node a (Tree a) (Tree a) deriving (Eq, Show)

In words, a Tree a can be either Empty (it can store nothing), a Leaf a (it can store exactly one thing of type a, or a Node a (Tree a) (Tree a) (it can store one thing of type a, and may also branch into two more trees constituting its left and right daughters).

For example, let's say we wanted to represent a tree that holds data of type Integer. We could define the following tree

exampleTree :: Tree Integer
exampleTree = Node 1 (Leaf 2) (Node 3 (Leaf 4) Empty)

which represents the following tree:

  1
 _|_
|   |
2   3
   _|_
  |   |
  4

Write a function

depth :: Tree a -> Integer

which, given a Tree a, returns the length of the longest path from its root to one of its leaves. For example, depth should behave as follows:

ghci> depth exampleTree
3
ghci> depth Empty
0
ghci> depth (Leaf 7)
1

2.2. Part 2

Make Tree an instance of the class Functor. Does the instance you provide satisfy the Functor laws?

2.3. Part 3

Make Tree an instance of the class Foldable. You have different options here, in principle. I really just want you to make sure that the answer you provide is well typed!

2.4. Part 4

Can you write a definition of depth that invokes foldl, foldr, and/or fmap? If this seems difficult, is there a way to modify the definitions of any of these functions in order to accomplish this? If you modify fmap, does it break any of the Functor laws?

Author: Julian Grove

Created: 2023-09-12 Tue 10:49

Validate