Alonso's blog

Thoughts on ad-hoc polymorphism

Generics functions are awesome. Doesn't matter where or how you define a generic function. It just works. At some point, you will want to do things with the arguments. And, if you don't know anything about the arguments then you can't do anything with them. For this, you need ad-hoc polymorphism. Parametric and ad-hoc polymorphism are two sides of the same coin.

Unfortunately, ad-hoc polymorphism is messier than parametric polymorphism.

There is an asymmetry between generic and overloaded functions. Generic just work. On the other hand, overloaded functions only exist for the types they are defined. So, if you use an overloaded function inside a generic function you must add a constraint that assures the overloaded function exists for the generic type.

Another asymmetry is that with overloaded functions you can have problems of coherence. You can have multiple overloaded functions for the same type. This means for example, if we have a hashmap, it could behave differently depending in what module the hashmap was created. Also, it could work incorrectly when passed to another module because the hash function could be different. This is way in Rust exists the orphan rule.

Having said that, ad-hoc polymorphism is still useful. I will go about some approaches I have considered for the programming language I have been working on.

Simplest possible ad-hoc polymorphism

Probably the simplest way to implement ad-hoc polymorphism is to have an interface be a prototype that can be implemented for a type.

interface add[t](left: t, right: t): t

type Point
    x: Float
    y: Float

function add(left: Point, right: Point): Point
    return Point{x: left.x + right.x, y: left.y + right.y}

function genericAdd(left: t, right: t): t where add[t]
    return add(left, right)

Advantages

Disadvantages

Type classes

Introduced in "How to make ad-hoc polymorphism less ad hoc" by Philip Wadler and Stephen Blott. At the time, in the paper was said:

...there is no widely accepted approach to ad-hoc polymorphism...

Nowadays, that approach is type classes, or constructs similar to them. Like traits in Rust, or protocols in Swift.

interface Addable[t]
    function add(left: t, right: t): t

function genericAdd(left: t, right: t): t where Addable[t]
    return add(left, right)

type Point
    x: Float
    y: Float

instance Addable[Point]
    function add(left: Point, right: Point): Point
        return Point{x: left.x + right.x, y: left.y + right.y}

Advantages

Disadvantages

Module-based static dispatch

Add the ability to say I want to call this function from the module where is defined the type of the first argument. This appears to be a new approach being tried in Roc.

type Point
    x: Float
    y: Float

function add(left: Point, right: Point): Point
    return Point{x: left.x + right.x, y: left.y + right.y}
import point

function genericAdd(left: a, right: b): c where a::add(b): c
    return left::add(right)

Advantages

Neutral

Disadvantages

Set-theoretic types

A new type system has been in the works for Elixir, with set-theoretic types at its center.

When I started writing this, I thought set-theoretic types was a little like multiple dispatch. That it allowed to extend operations for types, and that it could replace type classes. I thought it was an interesting take and worth a try. Then, I found Elixir also has type classes (protocols), so I assume overloading is restricted to the module level and can't be extended from outside.

I am not sure how to describe them really. But as I understand, they allow to type overloading pretty decently. For example:

function foo(a: Int, b: Int): Int
    return a + b

function foo(a: Float, b: Float): Float
    return a + b

Would mean the function foo has the type (Int, Int): Int or (Float, Float): Float. What happens if you add another overload? Just add it to the possible types foo can have.

How do you type a function without type annotations?

function takesFloat(t: Float): None
    print(b)

function myFunction(a, b)
    result = foo(a, b) 
    takesFloat(b)
    return result

First, with the call to foo we would generate the constraints that the tuple of type variables (a, b) has type (Int, Int) or (Float, Float). Then, takesFloat can only receives floats so the tuple (a, b) can only have type (Float, Float).

The type of a function would be a set of all the possible types it can take.

Our previous example would look like this:

type Point
    x: Float
    y: Float

function add(left: Point, right: Point): Point
    return Point{x: left.x + right.x, y: left.y + right.y}
 
function genericAdd(left, right)
    return add(left, right)

But it wouldn't be equivalent to our previous examples. Because the genericAdd is not truly generic. Can't work with types defined outside the module. For this you would still need type classes.

Side note: It could be allowed to overload functions from outside the module, making this a suitable replacement to type classes. But it would bring many complications. First, functions without type annotations would have to have a constraint for each function call inside their body, for making them truly generic. Second, possible ambiguities from overloading (or multiple parameter type classes) now extend to the whole program. Maybe, this could make easy to have libraries that don't compose well, as I don't see a way to enforce an orphan rule. On the other hand, Elixir already doesn't count with an orphan rule, so maybe it's not a big problem in practice.

Advantages

Disadvantages