Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Superclass constraints vs. instance declarations in Haskell

It kind of seems like in Haskell one can use instance declarations similarly to superclass constraints. I'll give an example using Semigroup and Monoid from Prelude. Here's a simplified definition of Semigroup:

class Semigroup a where
        (<>) :: a -> a -> a

Here's a simplified version of Prelude's definition of Monoid which uses a superclass constraint:

class Semigroup a => Monoid a where
        mempty  :: a
        mappend :: a -> a -> a
        mappend = (<>)

If I'm not mistaken it seems the superclass constraint could be replaced by an instance declaration as follows:

class Monoid a where
        mempty  :: a
        mappend :: a -> a -> a

instance Monoid a => Semigroup a where
        (<>) = mappend

Is there a reason for Haskell to have superclass constraints when it seems one can do the same with instance declarations (with constraints)? Which one should be preferred? Is it superclass constraints whenever possible?

like image 651
QuantumWiz Avatar asked Jul 11 '26 11:07

QuantumWiz


1 Answers

There is a fundamental error in how you're reading the instance constraint. It is important (and not obvious at first) to remember that constraints on instances are checked after committing to the instance.

To see whether an instance matches a given type you only look at the stuff to the right of the => (the instance head). If it matches then the compiler commits to the instance and the constraints on the instance become additional requirements for the typechecker to consider. If those fail you will have a type error; it's too late to consider another instance1. The compiler will only consider other instances if the instance head does not match at all.

This means that instance Monoid a => Semigroup a does not mean "any type that is a Monoid is a Semigroup". Rather it means "every type a is a Semigroup, but to use any Semigroup methods with a requires you to prove Monoid a first".

As a consequence of these rules, there can be no other Semigroup instances (because instance ... => Semigroup a matches every type). Which means the implication of the instance you wrote is actually backwards from the original class constraint: class Semigroup a => Monoid a means "if a type is a Monoid then it is also a Semigroup", whereas instance Monoid a => Semigroup a effectively means "if a type is a Semigroup then it is also a Monoid".

That's all you really need to know. The answer to your question about why both features exist is trivially "they're not the same", and the answer to which should be preferred is pretty clear once you know what they both mean; since they mean different things you have to consider what you want to say and use the most appropriate feature on a case by case basis. The rest of this answer is trying to explain why the compiler works this way; I find it's more intuitive when you understand the problems the rules are trying to avoid.


The reason for these rules is basically to support separate compilation of modules while trying to retain coherence of the type class system (meaning that any time we resolve an instance for a given type we want to be able to assume it will be the exact same instance definition, consistently across an entire multi-package program, no matter how we found it; APIs like Data.Map fundamentally rely on this property). The compiler only processes one module at a time (and its imports, to a lesser degree), so it doesn't want to assume that it can see every single instance declaration that will be used in the final complete program. That means the compiler never wants to do anything that would only be correct if an instance does not exist just because it hasn't seen such an instance.

This is the guiding principle that explains everything: the compiler can only ever use positive information about instances (that a type does have an instance) to compile your program. It will only ever use negative information (that an instance appears to be missing) to reject your program, never to accept it.

So more concretely, if we have:

module Monoid where

class Monoid a where
  mappend :: a -> a -> a
  mempty :: a

class Semigroup a where
  (<>) :: a -> a -> a

instance Monoid a => Semigroup a where
  (<>) = mappend

and also

module DataDefinition where

data Example = Constructor

and finally

module DataUsage where

import DataDefinition
import Monoid

foo :: Example -> Example -> Example
foo x y = x <> y

The compiler will reject foo because of a missing Monoid instance for Example, not a missing Semigroup instance. It has to resolve the Semigroup Example constraint using the Monoid a => Semigroup a instance; because another module in the final program (importing both Monoid and DataDefinition, but not importing or imported by DataUsage) might theoretically have a Monoid Example instance and also use the Module a => Semigroup a instance for Example. If that happened then the only way to maintain the property that the entire program always uses the same Semigroup instance for T is that inside DataUsage we have to use the Monoid a => Semigroup a one as well. And since we can't rule out whether that happens in another module, we have to do that regardless of whether it actually does happen! But that then requires we have to look for a Monoid T instance, which we don't find, and we're allowed to use the non-existence of the Monoid instance to report an error.

If we wanted T to be a Semigroup only and add:

instance Semigroup T where
  C <> C = C

then we get an error about overlapping instances because instance Semigroup T and instance Monoid a => Semigroup a both match the usage in bar. The logic that we can't allow this is a bit more subtle, but it comes down to the same thing. Another module could conceivably be working with T without actually knowing it, via instantiation of a polymorphic type. That module wouldn't use the specific Semigroup T instance (because it's working with a polymorphic type, not T specifically). But it might use the Monoid a => Semigroup a instance, and if it was ever called on T (which would require there to be a Monoid T instance out there somewhere that isn't imported into the DataUsage module we're compiling right now) then it would use the general Semigroup instance with T, instead of using the T-specific one. So since we can't rule that out we can't use the specific one either! Since that reasoning would always apply when you have both a specific and a general instance that can match the same type, overlapping instances are simply reported as an error in themselves (although I believe only when trying to solve a related constraint; you might technically be able to include overlapping instances if you never attempt to use them with any types they both match).


There are compiler extensions in GHC that allow you to relax these rules, permitting multiple overlapping instances to be used by choosing the "best match" at each use site rather than requiring that there only ever be a single possible match. I'll leave it to the appropriate section in the GHC users guide to explain all the gory details.

This can be a powerful feature, but it can also be a bit fragile; if GHC picks a different instance from the available ones that match than you thought it would that can often cause subtle bugs that are difficult to diagnose. In my experience overlapping is more often used for special-purpose classes where a single library provides all of the instances that are expected to exist, rather than with general-purpose things like Semigroup that are expected to be widely instantiated across many packages.

I don't recommend you try using overlapping instances until you're really very familiar and comfortable with the normal instance resolution rules, and know that you're trying to do something that cannot be modelled well without overlapping instances. Especially I don't recommend seeing overlapping instances as a way to write "universal" instances like instance Monoid a => Semigroup a, just to avoid writing easy instances for each type. It looks tempting, but I don't think it's worth it.


1 Actually if there are other instances whose heads matched that in itself would be detected as an error!

like image 185
Ben Avatar answered Jul 14 '26 13:07

Ben



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!