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
- Simple
- You can't have a type class that's too big
Disadvantages
- You can't define functions in terms of abstract types. Like example Number, Equatable, Stringable, etc... which can make generic code harder to read and reason about
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
You can define functions in terms of abstract types which can make generic code easier to read and reason about
If an operation is added to an interface, or an operation changes, the compiler will warn you about and tell you where you have to change the implementations
They also allow you to have a type for polymorphic literals:
function addOne(number: t): t where Number[t] return number + 1
In the above code the type of 1 can be any type that conforms to
Number
Disadvantages
- More complex
- If you make a type class too big you can't change it later
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
- Very simple, you don't even need the concept of interfaces for this
- You can't have a type class that's too big
Neutral
- This would force each type to has it's own module. This could be good or bad, depending how you feel about it. This removes options, encouraging more homogeneous code. But at the same time, it could mean unnescesary friction.
Disadvantages
The compiler can't warn you if a type no longer implements an interface correctly
You can't define functions in terms of abstract types. Like example Number, Equatable, Stringable, etc... which can makes generic code harder to read and reason about. It's worse than with interfaces as prototypes because the constraints need the full prototype. This is more verbose, and can lead also to weird stuff like this:
function doFooToStuff(stuff: t): None where t::foo():u result = foo(stuff) print(result)
A type variable, that it's not part of the function prototype is needed in the constraint. Though, maybe the weirdness can be fixed with wildcard *:
function doFooToStuff(stuff: t): None where t::foo():* result = foo(stuff) print(result)
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
- Allows module level overloading, allowing to type correctly more idioms already used in Elixir
Disadvantages
- Doesn't replace type classes