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
- 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. 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
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 an easy type for polymorphic literals:
function addOne(number: t): t where Number[t] return number + 1In 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
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
- Very simple, modules just expose types and functions
- You can't have a type class that's too big
Neutral
- This force most types to be defined on their own module. This removes options, encouraging more homogeneous code. But, at the same time, it could mean unnecessary 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 require 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 is not part of the function prototype is needed in the constraint. However, 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 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
- They allow module level overloading, allowing to type correctly more idioms already used in Elixir
Disadvantages
- Doesn't replace type classes