A system of constructor classes (1993)

This paper describes a flexible type system which combines overloading and higher-order polymorphism in an implicitly typed language using a system of constructor classes – a natural generalization of type classes in Haskell. We present a wide range of examples which demonstrate the usefulness of such a system. In particular, we show how constructor classes can be used to support the use of monads in a functional language. The underlying type system permits higher-order polymorphism but retains many of many of the attractive features that have made the use of Hindley/Milner type systems so popular. In particular, there is an effective algorithm which can be used to calculate principal types without the need for explicit type or kind annotations. A prototype implementation has been developed providing, amongst other things, the first concrete implementation of monad comprehensions known to us at the time of writing. 1 An overloaded map function Many functional programs use the map function to apply a function to each of the elements in a given list. The type and definition of this function as given in the Haskell standard prelude [6] are as follows: map :: (a → b) → [a] → [b] map f [ ] = [ ] map f (x : xs) = f x : map f xs It is well known that the map function satisfies the familiar laws: map id = id map f . map g = map (f . g) A category theorist will recognize these observations as indicating that there is a functor from types to types whose object part maps any given type a to the list type [a] and whose arrow part maps each function f :: a → b to the function map f :: [a] → [b]. A functional programmer will recognize that similar constructions are also used with a wide range of other data types, as illustrated by the following examples: data Tree a = Leaf a | Tree a :ˆ: Tree a mapTree :: (a → b) → (Tree a → Tree b) mapTree f (Leaf x ) = Leaf (f x ) mapTree f (l :ˆ: r) = mapTree f l :ˆ: mapTree f r data Opt a = Just a | Nothing mapOpt :: (a → b) → (Opt a → Opt b) mapOpt f (Just x ) = Just (f x ) mapOpt f Nothing = Nothing Each of these functions has a similar type to that of the original map and also satisfies the functor laws given above. With this in mind, it seems a shame that we have to use different names for each of these variants. A more attractive solution would allow the use of a single name map, relying on the types of the objects involved to determine which particular version of the map function is required in a given situation. For example, it is clear that map (1+) [1 , 2 , 3 ] should be a list, calculated using the original map function on lists, while map (1+) (Just 1 ) should evaluate to Just 2 using mapOpt . Unfortunately, in a language using standard Hindley/Milner type inference, there is no way to assign a type to the map function that would allow it to be used in this way. Furthermore, even if typing were not an issue, use of the map function would be rather limited unless some additional mechanism was provided to allow the definition to be extended to include new datatypes perhaps distributed across a number of distinct program modules. 1.1 An attempt to define map using type classes The ability to use a single function symbol with an interpretation that depends on the type of its arguments is commonly known as overloading . While some authors dismiss overloading as a purely syntactic convenience, this is certainly not the case in Haskell which has a flexible type system that supports both (parametric) polymorphism and overloading based on a system of type classes [13]. One of the most attractive features of this system is that, although each primitive overloaded operator will require a separate definition for each different argument type, there is no need for these to be in the same module. Type classes in Haskell can be thought of as sets of types. The standard example is the class Eq which includes precisely those types whose elements can be compared using the (==) function. A simple definition might be: class Eq a where (==) :: a → a → Bool The equality operator can then be treated as having any of the types in the set { a → a → Bool | a ∈ Eq }. The elements of a type class are defined by a collection of instance declarations which may be distributed across a number of distinct program modules. For the type class Eq , these would typically include definitions of equality for integers, characters, lists, pairs and user-defined datatypes. Only a single definition is required for functions defined either directly or indirectly in terms of overloaded primitives. For example, assuming a collection of instances as above, the member function defined by: member :: Eq a ⇒ a → [a] → Bool member x [ ] = False member x (y : ys) = x == y | | member x ys can be used to test for membership in a list of integers, characters, lists, pairs, etc. See [5, 13] for further details about the use of type classes. Unfortunately, the system of type classes is not sufficiently powerful to give a satisfactory treatment for the map function; to do so would require a class Map and a type expression m(t) involving the type variable t such that S = {m(t) | t ∈ Map } includes (at least) the types: (a → b) → ([a] → [b]) (a → b) → (Tree a → Tree b) (a → b) → (Opt a → Opt b) (for arbitrary types a and b). The only possibility is to take m(t) = t and choose Map as the set of types S for which the map function is required: class Map t where map :: t instance Map ((a → b) → ([a] → [b])) where . . . instance Map ((a → b) → (Tree a → Tree b)) where . . . instance Map ((a → b) → (Opt a → Opt b)) where . . . This syntax is not permitted in the current syntax of Haskell but even if it were, it does not give a sufficiently accurate characterization of the type of map. For example, the principal type of map j . map i would be (Map (a → c → e), Map (b → e → d)) ⇒ c → d where a and b are the types of i and j respectively. This is complicated and does not enforce the condition that i and j have function types. Furthermore, the type is ambiguous (the type variable e does not appear to the right of the ⇒ symbol or in the assumptions). Under these conditions, we cannot guarantee a well-defined semantics for this expression (see [8], for example). Other attempts to define the map function, for example using multiple parameter type classes, have also failed for essentially the same reasons. 1.2 A solution using constructor classes A much better approach is to notice that each of the types for which the map function is required is of the form: (a → b) → (f a → f b). The variables a and b here represent arbitrary types while f ranges over the set of type constructors for which a suitable map function has been defined. In particular, we would expect to include the list constructor (which we will write as List), Tree and Opt as elements of this set which, motivated by our earlier comments, we will call Functor . With only a small extension to the Haskell syntax for type classes this can be described by: class Functor f where map :: (a → b) → (f a → f b) instance Functor List where map f [ ] = [ ] map f (x : xs) = f x : map f xs instance Functor Tree where map f (Leaf x ) = Leaf (f x ) map f (l :ˆ: r) = map f l :ˆ: map f r instance Functor Opt where map f (Just x ) = Just (f x ) map f Nothing = Nothing Functor is our first example of a constructor class. The following extract (taken from a session with the Gofer system which includes support for constructor classes) illustrates how the definitions for Functor work in practice: ? map (1+) [1,2,3] [2, 3, 4] ? map (1+) (Leaf 1 :^: Leaf 2) Leaf 2 :^: Leaf 3 ? map (1+) (Just 1) Just 2 Furthermore, by specifying the type of map function more precisely, we avoid the ambiguity problems mentioned above. For example, the principal type of map j . map i is simply Functor f ⇒ f a → f c provided that i has type (a → b) and that j has type (b → c).