Alonso's blog

Thoughts on ad-hoc polymorphism

Generics functions are awesome. It 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. But if you don't know anything about the arguments, you can't do anything with them. Ad-hoc polymorphism fixes this, 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 functions 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 ensures an overload exists for the generic argument.

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

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

Simplest possible ad-hoc polymorphism

Probably the simplest way to achieve ad-hoc polymorphism is to have interfaces be prototypes that can be implemented for types.

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. In the paper they were introduced was said:

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

Nowadays, the closest to this is type classes. You can find similar constructs Rust's traits, or Swift's protocols.

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

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

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 were a little like multiple dispatch. That they allowed to extend operations for types, and that it could replace type classes. I thought it was an interesting take and worth trying. 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 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 Elixir's protocols.

Side note: It could be allowed to overload functions from outside the module, making this a suitable replacement to type classes. But this brings many complications. First, functions without type annotations would have to have a constraint for each function call inside their body, without them they wouldn't be generic. Second, possible ambiguities from overloading 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 deal in practice.

Advantages

Disadvantages