Haskell/Type basics II

From Wikibooks, open books for an open world
< Haskell
Jump to: navigation, search

Up to now we have shrewdly avoided number types in our examples. In one exercise, we even went as far as asking you to "pretend" the arguments to (+) had to be of type Int. So, from what are we hiding?

The main theme of this module will be how numerical types are handled in Haskell. While doing so, we will introduce some important features of the type system. Before diving into the text, though, pause for a moment and consider the following question: what should be the type of the function (+)[1]?

The Num class[edit]

As far as everyday Mathematics is concerned, there are very few restrictions on which kind of numbers we can add together. 2 + 3 (two natural numbers), (-7) + 5.12 (a negative integer and a rational number), \frac{1}{7} + \pi (a rational and an irrational)... all of these are valid – indeed, any two real numbers can be added together. In order to capture such generality in the simplest way possible we would like to have a very general Number type in Haskell, so that the signature of (+) would be simply

(+) :: Number -> Number -> Number

That design, however, does not fit well with the way computers perform arithmetic. While integer numbers in programs can be quite straightforwardly handled as sequences of binary digits in memory, that approach does not work for non-integer real numbers[2], thus making it necessary for a more involved encoding to support them: floating point numbers. While floating point provides a reasonable way to deal with real numbers in general, it has some inconveniences (most notably, loss of precision) which make using the simpler encoding worthwhile for integer values. We are thus left with at least two different ways of storing numbers, one for integers and another one for general real numbers, which should correspond to different Haskell types. Furthermore, computers are only able to perform operations like (+) on a pair of numbers if they are in the same format. That should put an end to our hopes of using a universal Number type – or even having (+) working with both integers and floating-point numbers...

It is easy, however, to see reality is not that bad. We can use (+) with both integers and floating point numbers:

Prelude>3 + 4
7
Prelude>4.34 + 3.12
7.46

When discussing lists and tuples, we saw that functions can accept arguments of different types if they are made polymorphic. In that spirit, one possible type signature for (+) that would account for the facts above would be:

(+) :: a -> a -> a

(+) would then take two arguments of the same type a (which could be integers or floating-point numbers) and evaluate to a result of type a. There is a problem with that solution, however. As we saw before, the type variable a can stand for any type at all. If (+) really had that type signature we would be able to add up two Bool, or two Char, which would make no sense – and is indeed impossible. Rather, the actual type signature of (+) takes advantage of a language feature that allows us to express the semantic restriction that a can be any type as long as it is a number type:

(+) :: (Num a) => a -> a -> a

Num is a typeclass - a group of types which includes all types which are regarded as numbers[3]. The (Num a) => part of the signature restricts a to number types – or, more accurately, instances of Num.

Numeric types[edit]

But what are the actual number types – the instances of Num that a stands for in the signature? The most important numeric types are Int, Integer and Double:

  • Int corresponds to the vanilla integer type found in most languages. It has fixed precision, and thus maximum and minimum values (in 32-bit machines the range goes from -2147483648 to 2147483647).
  • Integer also is used for integer numbers, but unlike Int it supports arbitrarily large values – at the cost of some efficiency.
  • Double is the double-precision floating point type, and what you will want to use for real numbers in the overwhelming majority of cases (there is also Float, the single-precision counterpart of Double, which in general is not an attractive option due to more loss of precision).

These types are available by default in Haskell, and are the ones you will generally deal with in everyday tasks.

Polymorphic guesswork[edit]

There is one thing we haven't explained yet, though. If you tried the examples of addition we mentioned at the beginning you know that something like this is perfectly valid:

Prelude> (-7) + 5.12
-1.88

Here, it seems we are adding two numbers of different types – an integer and a non-integer. Shouldn't the type of (+) make that impossible?

To answer that question we have to see what the types of the numbers we entered actually are:

Prelude> :t (-7)
(-7) :: (Num a) => a

And, lo and behold, (-7) is neither Int nor Integer! Rather, it is a polymorphic constant, which can "morph" into any number type if need be. The reason for that becomes clearer when we look at the other number...

Prelude> :t 5.12
5.12 :: (Fractional t) => t

5.12 is also a polymorphic constant, but one of the Fractional class, which is more restrictive than Num – every Fractional is a Num, but not every Num is a Fractional (for instance, Ints and Integers are not).

When a Haskell program evaluates (-7) + 5.12, it must settle for an actual type for the numbers. It does so by performing type inference while accounting for the class specifications. (-7) can be any Num, but there are extra restrictions for 5.12, so its type will define what (-7) will become. Since there is no other clues to what the types should be, 5.12 will assume the default Fractional type, which is Double; and, consequently, (-7) will become a Double as well, allowing the addition to proceed normally and return a Double[4].

There is a nice quick test you can do to get a better feel of that process. In a source file, define

x = 2

then load the file in GHCi and check the type of x. Then, change the file to add a y variable,

x = 2
y = x + 3

reload it and check the types of x and y. Finally, modify y to

x = 2
y = x + 3.1

and see what happens with the types of both variables.

Monomorphic trouble[edit]

The sophistication of the numerical types and classes occasionally leads to some complications. Consider, for instance, the common division operator (/). It has the following type signature:

(/) :: (Fractional a) => a -> a -> a

Restricting a to fractional types is a must because the division of two integer numbers in general will not result in an integer. Nevertheless, we can still write something like

Prelude> 4 / 3
1.3333333333333333

because the literals 4 and 3 are polymorphic constants and therefore assume the type Double at the behest of (/). Suppose, however, we want to divide a number by the length of a list[5]. The obvious thing to do would be using the length function:

Prelude> 4 / length [1,2,3]

Unfortunately, that blows up:

<interactive>:1:0:
    No instance for (Fractional Int)
      arising from a use of `/' at <interactive>:1:0-17
    Possible fix: add an instance declaration for (Fractional Int)
    In the expression: 4 / length [1, 2, 3]
    In the definition of `it': it = 4 / length [1, 2, 3]

As usual, the problem can be understood by looking at the type signature of length:

length :: [a] -> Int

The result of length is not a polymorphic constant, but an Int; and since an Int is not a Fractional it can't fit the signature of (/).

There is a handy function which provides a way of escaping from this problem. Before following on with the text, try to guess what it does only from the name and signature:

fromIntegral :: (Integral a, Num b) => a -> b

fromIntegral takes an argument of some Integral type (like Int or Integer) and makes it a polymorphic constant. By combining it with length we can make the length of the list fit into the signature of (/):

Prelude> 4 / fromIntegral (length [1,2,3])
1.3333333333333333

While this complication may look spurious at first, this way of doing things makes it easier to be rigorous when manipulating numbers. If you define a function that takes an Int argument you can be entirely sure that it will never be converted to an Integer or a Double unless you explicitly tell the program to do so (for instance, by using fromIntegral). As a direct consequence of the refinement of the type system, there is a surprising diversity of classes and functions for dealing with numbers in Haskell.

Classes beyond numbers[edit]

There are many other use cases for typeclasses beyond arithmetic. For example, the type signature of (==) is:

(==) :: (Eq a) => a -> a -> Bool

Like (+) or (/), (==) is a polymorphic function. It compares two values of the same type, which must belong to the class Eq, and returns a Bool. Eq is simply the class of types of values which can be compared for equality, and includes all of the basic non-functional types[6].

Typeclasses are a very general language feature which adds a lot to the power of the type system. Later in the book we will return to this topic to see how to use them in custom ways, which will allow you to appreciate their usefulness in its fullest extent.


Notes[edit]

  1. If you followed our recommendations in "Type basics", chances are you have already seen the rather exotic answer by testing with :t... if that is the case, consider the following analysis as a path to understanding the meaning of that signature.
  2. One of the reasons being that between any two real numbers there are infinitely many real numbers – and that can't be directly mapped into a representation in memory no matter what we do.
  3. That is a very loose definition, but will suffice until we are ready to discuss typeclasses in more detail.
  4. For seasoned programmers: This appears to have the same effect of what programs in C (and many other languages) would manage with an implicit cast – in which case the integer literal would be silently converted to a double. The difference is that in C the conversion is done behind your back, while in Haskell it only occurs if the variable/literal is explicitly made a polymorphic constant. The difference will become clearer shortly, when we show a counter-example.
  5. A reasonable scenario – think of computing an average of the values in a list.
  6. Comparing two functions for equality is considered to be intractable