jam
jam

Reputation: 823

In Haskell, express constraints on class header or class methods?

I've recently realized this thing:

On the one hand:

Constraints specified on a class header, must be specified again on an instance of that class, but any use of that class as a constraint somewhere else does not need to reimport the class constraints. They are implicitly satisfied.

class (Ord a) => ClassA a where methodA :: a -> Bool -- i decided to put constraint (Ord a) in the class header
instance (Ord a) => ClassA a where methodA x = x <= x   -- compiler forces me to add (Ord a) => in the front
class OtherClassA a where otherMethodA :: a -> Bool 
instance (ClassA a) => OtherClassA a where otherMethodA x = x <= x && methodA x -- i don't need to specify (Ord a) so it must be brought implicitly in context

On the other hand:

A constraint specified in a class method, does not need to be specified again on an instance of that class, but any use of that class as a constraint somewhere else, needs to reimport specific constraints for method used.

class ClassB a where methodB :: (Ord a) => a -> Bool -- i decided to put constraint (Ord a) in the method
instance ClassB a where methodB x = x <= x  -- i don't need to specify (Ord a) so it must be implicitly in context
class OtherClassB a where otherMethodB :: a -> Bool 
instance (ClassB a, Ord a) => OtherClassB a where otherMethodB = methodB -- compiler forced me to add (Ord a)

What is the motivation for those behaviors? Would it not be preferrable to be explicit about constraints at all times?

More concretely when I have a set of conditions I know all methods in a type class should satisfy, should I write the conditions, in the type class header, or in front of each method? Should I write constraints in a type class definition at all?

Upvotes: 2

Views: 972

Answers (1)

K. A. Buhr
K. A. Buhr

Reputation: 50819

Short Answer

Here's my general advice on constraints in class declarations and instance definitions. See below for a longer explanation and a detailed description of your examples.

  1. If you have classes with a logical relationship such that it is logically impossible for a type to belong to class Base without belonging to class Super, use a constraint in the class declaration, like so:

     class Super a => Base a where ...
    

    Some example:

     -- all Applicatives are necessarily Functors
     class Functor f => Applicative f where ...
     -- All orderable types can also be tested for equality
     class Eq f => Ord f where ...
     -- Every HTMLDocument also supports Document methods
     class Document doc => HTMLDocument doc where ...
    
  2. Avoid writing instances that apply to all types, with or without constraints. With a few exceptions, these usually point to a design flaw:

     -- don't do this
     instance SomeClass1 a
     -- or this
     instance (Eq a) => SomeClass1 a
    

    Instances for higher-order types make sense though, and use whatever constraints are necessary for the instance to compile:

     instance (Ord a, Ord b) => Ord (a, b) where
       compare (x1,x2) (y1,y2) = case compare x1 x2 of
         LT -> LT
         GT -> GT
         EQ -> compare x2 y2
    
  3. Don't use constraints on class methods, except where a class should support different subsets of methods on different types, depending on the constraints available.

Long Answer

Constraints in class declarations and instance definitions have different meanings and different purposes. A constraint in a class declaration like:

class (Big a) => Small a

defines Big as a "superclass" of Small and represents a type-level claim of a logical necessity: any type of class Small is necessarily also a type of class Big. Having such a constraint improves type correctness (since any attempt to define a Small instance for a type a that doesn't also have a Big instance -- a logical inconsistency -- will be rejected by the compiler) and convenience, since a Small a constraint will automatically make available the Big class interface in addition to the Small interface.

As a concrete example, in modern Haskell, Functor is a superclass of Applicative which is a superclass of Monad. All Monads are Applicatives and all Applicatives are Functors, so this superclass relationship reflects a logical relationship between these collections of types, and also provides the convenience of being able to use the monad (do-notation, >>=, and return), applicative (pure and <*>) and functor (fmap or <$>) interfaces using only a Monad m constraint.

A consequence of this superclass relationship is that any Monad instance must also be accompanied by an Applicative and Functor instance to provide evidence to the compiler that the necessary superclass constraints are satisfied.

In contrast, a constraint in an instance definition introduces a dependency of the specific, defined instance on another instance. Most commonly, I see this used to define instances for classes of high-order types, like the Ord instance for lists:

instance Ord a => Ord [a] where ...

That is, an Ord [a] instance can be defined for any type a using lexicographic ordering of the list, provided that the type a itself can be ordered. The constraint here does not (and indeed could not) apply to all Ord types. Rather, the instance definition provides an instance for all lists by introducing a dependency on an instance for the element type -- it says that an Ord [a] instance is available for any type a that has an Ord a instance available.

Your examples are somewhat unusual, as one doesn't normally define an instance:

instance SomeClass a where ...

that applies to all types a, with or without additional constraints.

Nonetheless, what's happening is that:

class (Ord a) => ClassA a

is introducing a logical type-level fact, namely that all types of class ClassA are also of class Ord. Then, you are presenting an instance of ClassA applicable to all types:

instance ClassA a

But, this presents a problem to the compiler. Your class declaration has stated that is it logically necessary that all types of ClassA also belong to class Ord, and the compiler requires proof of the Ord a constraint for any instance ClassA a that you define. By writing instance ClassA a, you are making the bold claim that all types are of ClassA, but the compiler has no evidence that all classes have the necessary Ord a instances. For this reason, you must write:

instance (Ord a) => ClassA a

which, in words, says "all types a have an instance of ClassA provided an Ord a instance is also available". The compiler accepts this as proof that you are only defining instances for the those types a that have the requisite Ord a instance.

When you go on to define:

class OtherClassA a where
  otherMethodA :: a -> Bool
instance (ClassA a) => OtherClassA a where
  otherMethodA x = x <= x && methodA x

since OtherClassA has no superclass, there is no logical necessity that types of this class are also of class Ord, and the compiler will require no proof of this. In your instance definition however, you define an instance applicable to all types whose implementation requires Ord a, as well as ClassA a. Fortunately, you've provided a ClassA a constraint, and because Ord is a superclass of ClassA, it is a logical necessity that any a with a ClassA a constraint also has an Ord a constraint, so the compiler is satisfied that a has both required instances.

When you write:

class ClassB a where
  methodB :: (Ord a) => a -> Bool

you are doing something unusual, and the compiler tries to warn by refusing to compile this unless you enable the extension ConstrainedClassMethods. What this definition says is that there is no logical necessity that types of class ClassB also be of class Ord, so you're free to define instances that lack the require instance. For example:

instance ClassB (Int -> Int) where
  methodB _ = False

which defines an instance for functions Int -> Int (and this type has no Ord instance). However, any attempt to use methodB on such a type will demand an Ord instance:

> methodB (*(2::Int))
...  • No instance for (Ord (Int -> Int)) ...

This can be useful if there are several methods and only some of them require constraints. The GHC manual gives the following example:

class Seq s a where
  fromList :: [a] -> s a
  elem     :: Eq a => a -> s a -> Bool

You can define sequences Seq s a with no logical necessity that the elements a be comparable. But, without Eq a, you're only allowed to use a subset of the methods. If you try to use a method that needs Eq a with a type a that doesn't have such an instance, you'll get an error.

Anyway, your instance:

instance ClassB a where
  methodB x = x <= x

defines an instance for all types (without requiring any evidence of Ord a, since there's no logical necessity here), but you can only use methodB on the subset of types with an Ord instance.

In your final example:

class OtherClassB a where
  otherMethodB :: a -> Bool

there's no logical necessity that a type of class OtherClassB also be a type of class Ord, and there's no requirement that otherMethodB only be used with types having an Ord a instance. You could, if you wanted, define the instance:

instance OtherClassB a where
  otherMethodB _ = False

and it would compile fine. However, by defining the instance:

instance OtherClassB a where
  otherMethodB = methodB

you are providing an instance for all types whose implementation uses methodB and so requires ClassB. If you modify this to read:

instance (ClassB a) => OtherClassB a where
  otherMethodB = methodB

the compiler still isn't satified. The specific method methodB requires an Ord a instance, but since Ord is not a superclass of ClassB, there is no logical necessity that the constraint ClassB a implies Ord a, so you must provide additionl evidence to the compiler that an Ord a instance is available. By writing:

instance (ClassB a, Ord a) => OtherClassB a where
  otherMethodB = methodB

you are providing an instance that requires ClassB a (to run methodB) and Ord a (because methodB has it as an additional requirement), so you need to tell the compiler that this instance applies to all types a provided both ClassB a and Ord a instances are available. The compiler is satisfied with this.

You Don't Need Type Classes to Delay Concrete Types

From your examples and follow-up comments, it sounds like you're (mis)using type classes to support a particular style of programming that avoids committing to concrete types until absolutely necessary.

(As an aside, I used to think this style was a good idea, but I've gradually come around to thinking it's mostly pointless. Haskell's type system makes refactoring so easy that there's little risk to committing to concrete types, and concrete programs tend to be mostly easier to read and write than abstract programs. However, many people have used this style of programming profitably, and I can think of at least one high-quality library (lens) that takes it to absolute extremes very effectively. So, no judgement!)

Anyway, this style of programming is generally better supported by writing top-level polymorphic functions and placing the needed constraints on the functions. There is usually no need (and no point) in defining new type classes. This was what @duplode was saying in the comments. You can replace:

class (Ord a) => ClassA where method :: a -> Bool
instance (Ord a) => ClassA where methodA x = x <= x

with the simpler top-level function definition:

methodA :: (Ord a) => a -> Bool
methodA x = x <= x

because the class and instance serve no purpose. The main point of type classes is to provide ad hoc polymorphism, to allow you to have a single function (methodA) that has different implementations for different types. If there's only one implementation for all types, that's just a plain old parametric polymorphic function, and no type class is needed.

Nothing changes if there are multiple methods, and usually nothing changes if there are multiple constraints. If your philosophy is that data types should be characterized only by the properties they satisfy rather than by what they are, then the flip side of that is that functions should be typed to demand of their argument types only the properties that they need. If they demand more than they need, they are prematurely committing to a more concrete type than necessary.

So, a class for, say, an orderable numeric key type with a printable representation:

class (Ord a, Num a, Show a) => Key a where
  firstKey :: a
  nextKey :: a -> a
  sortKeys :: [a] -> [a]
  keyLength :: a -> Int

and a single instance:

instance (Ord a, Num a, Show a) => Key a where
  firstKey = 1
  nextKey x = x + 1
  sortKeys xs = sort xs
  keyLength k = length (show k)

is more idiomatically written as a set of functions that constrain the type only based on the properties they require:

firstKey :: (Num key) => key
firstKey = 1

nextKey :: (Num key) => key -> key
nextKey = (+1)

sortKeys :: (Ord key) => [key] -> [key]
sortKeys = sort

keyLength :: (Show key) => key -> Int
keyLength = length . show

On the other hand, if you find it helpful to have a formal "name" for an abstract type and prefer the compiler's help in enforcing use of this type instead of just using type variables like "key" with evocative names, I guess you can use type classes for this purpose. However, your type classes probably shouldn't have any methods. You want to write:

class (Ord a, Num a, Show a) => Key a

and then a bunch of top-level functions that use the type class.

firstKey :: (Key k) => k
firstKey = 1

nextKey :: (Key k) => k -> k
nextKey = (+1)

sortKeys :: (Key k) => [k] -> [k]
sortKeys = sort

keyLength :: (Show k) => k -> Int
keyLength = length . show

Your entire program can be written this way, and no instances are actually needed until you get down to choosing your concrete types and documenting them all in one place. For example, in your Main.hs program, you could commit to an Int key by giving an instance for a concrete type and using it:

instance Key Int
main = print (nextKey firstKey :: Int)

This concrete instance also avoids the need for extensions like undecidable instances and warning about fragile bindings.

Upvotes: 6

Related Questions