One Reason Typeclasses Are Useful
coalton-lang.github.io
coalton-lang.github.io
Not a lisp developer, but typeclasses have become a bread&butter feature of programming languages to me: if there is a new language and it doesn't support typeclasses, it's almost guaranteed that I'm not very interested in this language.
I hadn't seen a great type-safe way to support ad hoc polymorphism until I read about [modular implicits][1] in OCaml. In situations where there is a reasonable module that satisfies a specified signature for the type of an argument, the module can be inferred.
> I hadn't seen a great type-safe way to support ad hoc polymorphism
This is an oxymoron. Type classes were invented to create type-safe ad hoc polymorphism (as opposed to parametric polymorphism). You should check out the original paper that introduces type classes back in 1989—it literally had a title of "How to make ad-hoc polymorphism less ad hoc" by Wadler (who else?)
I only skimmed that paper about modular implicits, but it does seem inspired by Scala implicits, yet another earlier work to make ad hoc polymorphism useful.
toAdditive ((toMultiplicative a) <> (toMultiplicative x)) <> toAdditive b
or something similar. This is of course an insane way to program, and so really what is done is that we define two entirely different operations of addition and multiplication so that they can be used without newtypes.
What would be cool is if operations could ad-hoc be bound to instances of typeclasses, for instance you could accumulate a list of integers inside the (0, +) monoid, or inside the (1, *) monoid. Of course this is basically what the fold functions in Haskell do, but you could imagine being able to formalise this pattern at the type level in a less newtype-hellish way.
If you have a set with two binary operations, then a monoid isn't the correct abstraction.
How would you propose to define <> so that a<>x<>b means ax+b and not e.g. a+xb. And how would your proposal be materially different from newtype conversions?
I guess my point is that even if I used "the correct abstraction" here, meaning a ring or something, then the whole newtype setup still makes it annoying for me to split off one operation and feed it into a function expecting a monoid: the way that fold and friends work is much more natural, just taking a starting value 1 and an operation * for example, rather than some kind of "Multiplicative" newtype or whatever.
In general I see a lot of the abstract algebra type towers in Haskell and Purescript as being unhelpful, and making a huge assumption that (for example) there will rarely be two monoid instances on a type. In reality in abstract algebra we use tons of different operations on the same objects all the time, and I really just want to define those operations and then be able to use them.
For example, define some `instance Ord a => SomeClass a`, or more interestingly, `instance (SomeClass m, MonadTrans n) => SomeClass (n m)` without much problem, but if you do that, you almost certainly can not define any other generic instance for that class, and nothing you can do with newtypes will save you.
[1] O. Kieselyov, Increasing Haskell modularity. https://mail.haskell.org/pipermail/haskell-cafe/2014-October...
[2] P. Hudak, J. Hughes, S. Peyton Jones, P. Wadler, A History of Haskell: Being Lazy With Class.
[3] S. Kaes, Parametric overloading in polymorphic programming languages.
[4] P. Wadler, S. Blott, How to make ad-hoc polymorphism less ad hoc.
For example, consider the type `Data.MaxHeap a`, which is a persistent max-priority heap.
Max-heaps can be combined in logarithmic time, as long as they are ordered internally by the same ordering. Haskell provides `Data.Heap.union :: Ord a => MaxHeap a -> MaxHeap a -> MaxHeap a`.
This is only possible because the type guarantees that the ordering of the two MaxHeaps is the same (the one single implementation of `Ord a`). It also means that you don't have to store any v-table within the `MaxHeap` value, making lookup just a bit faster and making optimizations like monomorphization easier.
If you didn't have canonical implementations, you would have to give up being able to write safe & efficient data-structures like this.
You would need to be able to dynamically store orderings (wasting space), dynamically compare orderings (wasting time), and have redundant failure paths only for the extremely uncommon and undesirable case where you try to `union` two MaxHeaps with different internal orderings (MaxHeap a -> MaxHeap a -> Maybe (MaxHeap a)).
-----
An alternative might be dependent types, where you can instead insist that the ordering _values_ within the two heaps are compatible, gaining the efficiency (by using "ghost" variables) and safety of canonical implementations while avoiding the , but this is more complicated to use, and Haskell doesn't have proper dependent types (yet?).
----
However, in most situations where you might want to try violating canonical instances, defining a newtype is a perfectly satisfactory way to do it.
instance absOrd : Ord Int where
-- ...
union :: (ord : Ord a) => MaxHeap a ord -> MaxHeap a ord -> MaxHeap a ord
If it's still impossible to create instances at runtime, I don't think this is equivalent to proper dependent types, only DataKinds.With ML modules you'd define a Heap module that is parameterised by an Ord module. Different instantiations of the Heap module for the same type `a` but different instances of `Ord a` are different: heaps with Ord1 are of a different type than heaps of Ord2.
From this point of view, passing the Ord instance into each individual heap function call (such as union) is clearly not the right setup. The canonicity of type classes is merely a bandaid for that problem.
It comes down to ergonomics where OCaml’s approach tends to make local reasoning easier and Haskell’s approach makes it a little easier to transform structurally isomorphic types into one another by wrapping/unwrapping.
In Haskell the solution is to use different type parameters via newtypes.
In ML, you define a distinct instantiation of the MaxHeap.
So the difference (in Haskell terms) is something like
`HeapInstantiation1` vs `Heap Wrapped1`
Is this practically a big enough difference to call the Haskell approach wrong? Is it not useful to have a single instantiation able to handle all parameters?
But the main reason why I didn't like the Haskell approach isn't ergonomic but conceptual. To me, the type of the heap should depend on the Ord instance, because the invariant satisfied by the values of type `Heap a` depends on the Ord instance of `a`, not just on the type `a` itself. This invariant is not encoded in the type system in Haskell, but in a dependently typed language you could do that. But in order to even write down the invariant, you need access to the Ord instance.
So the type signature of union could be:
union : {a:Type} → {o:Ord a} → Heap o → Heap o → Heap o
In this case the problem of mixing up heaps with different orderings is ruled out by ordinary type checking, rather than via an extra meta-theoretical invariant satisfied by the language. Although ML modules don't let you encode the invariant either, they at least set it up in the same way, because the type Heap(o).t depends on the o and not just on o.t, and the type checker also views it that way and will say Heap(o).t ≠ Heap(o').t even when o.t = o'.t.In Haskell on the other hand, if we desugar type classes to passing around records, then we can suddenly break the invariant in type safe code.
Maybe I'm imposing values on Haskell that have no practical significance in that context, but to me the way Haskell does it feels like a hack.
No? Just make the type generic on the ordering.
Instead of MaxHeap(T), make it MaxHeap(T, Ord:(T,T)->bool) or something like that.
You need some way to enforce that the orderings are the _same_. A canonical ordering per type is how Haskell does it; dependent types are a more complicated alternative method.
> Instead of MaxHeap(T), make it MaxHeap(T, Ord:(T,T)->bool)
as saying the kind of MaxHeap should be, in Coq syntax, (forall (T : Set), (T -> T -> Bool)). I think this doesn't add any additional complexity vs DataKinds since the function doesn't need to be evaluable at typechecking time, just unified against at construction-time.
Just require that the Ord type is the same for all heaps ?
MaxHeap(MaxHeap(T, MyOrd), MyOrd) uses the same ordering, but MaxHeap(MaxHeap(T, Ord0), Ord1) does not.
MyNestedMaxHeap(T, Ord) = MaxHeap(MaxHeap(T, Ord), Ord)
such that when using MyNestedMaxHeap only one Ord type can be passed.
https://agda.readthedocs.io/en/v2.6.2.1/language/instance-ar...
(define (compress l)
(fold combine identity l))
The inferred type will be ((Transformation :t) => (List :t) -> :t)If you want to implement polymorphism at all you will have to do something that's more or less equivalent (e.g. an OO virtual function table is effectively the same thing as a typeclass instance - it's just that the language forces you to bundle the data members and the virtual function table together, whereas in Haskell they're decoupled and can be used separately). Or even function overloading requires a similar "the compiler chooses which one is actually called" concept. (Of course there are purist languages that insist on e.g. using different forms of + for different-sized integers, but they're very much a minority)