Ad Hoc Polymorphism

Dear Computer

Chapter 12: Polymorphism Revisited

Ad Hoc Polymorphism

Subtype polymorphism and parametric polymorphism save a programmer from having to repeat code for different types. There's a third polymorphism that we briefly examined when discussing Ruby and Haskell: ad hoc polymorphism, which we also know as overloading. With ad hoc polymorphism, we write a different version of a function for each type that we want to serve. Overloading doesn't save us from writing code, but it does save us from having to come up with a new interface for similar operations. There are two kinds of abstractions that may be eligible for overloading: named functions like print and operators like +.

Suppose we create a new Ingredient type for a game. Players add Ingredient values together to craft items. In a language with ad hoc polymorphism for operators, we can overload the familiar + operator instead of developing a whole new vocabulary through named methods.

Programming languages vary in the degree to which they support ad hoc polymorphism. In Java, we can overload a method as long as each overload has a different signature, but we can't overload the builtin operators. In Ruby, we can overload methods and operators for our custom classes, but we can't have multiple implementations with different signatures. In C++, we can overload both methods and the builtin operators and have multiple implementations. Rust is like Haskell; we can overload functions and operators only if the overloaded interface is declared by a trait.

In Ruby, we overload the + operator for our new types by defining the + method. This Vector2 class supports addition:

Ruby
class Vector2
  attr_reader :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def +(other)
    Vector2.new(x + other.x, y + other.y)
  end
end

The Ruby interpreter translates an expression of the form a + b into the method call a.+(b). This script demonstrates how clients can use the existing interface of mathematical notation instead of using some new and verbose notation like a.addVector2(b):

Ruby
a = Vector2.new(1, 4)
b = Vector2.new(2, 0)
sum = a + b  # [3, 4]

In Rust, we overload a builtin operator by implementing a special trait. The + operator is associated with the Add trait, which is defined as this in the standard library:

Rust
pub trait Add<RHS = Self> {
  type Output;
  fn add(self, rhs: RHS) -> Self::Output;
}

This trait is hard to understand initially because it has been thoroughly abstracted. It could have been written more simply like this:

Rust
pub trait Add {
  fn add(self, other: Self) -> Self;
}

But this less abstract version only allows us to add a Vector2 to a Vector2 and get back a Vector2. The more abstract version allows us to add a value of some arbitrary type RHS to a Vector2 and get back a value of some other arbitrary type Output. That means we could support mixed mode arithmetic like Vector2::new(3, 4) + 19.

The official and abstract Add trait requires its implementing types to define the Output type and the add function. This implementation provides these definitions so that two Vector2 values may be added together to produce a new Vector2:

Rust
use std::ops::Add;

impl Add for Vector2 {
  type Output = Self;
  fn add(self, other: Self) -> Self::Output {
    Vector2 {
      x: self.x + other.x,
      y: self.y + other.y,
    }
  }
}

The Rust compiler turns the + operator into an add function call. The savings on interface is once again seen in client code:

Rust
let a = Vector2 {x: 1.0, y: 4.0};
let b = Vector2 {x: 2.0, y: 0.0};
let sum = a + b;

Rust defines traits for many of its builtin operators so that our types can use the exact same notation as the builtin types.

Summary

Types shape our code in statically-typed languages. Because each abstraction must identify what types it serves, we might find ourselves having to write many versions of the function. A more economical alternative is to loosen the way types are identified—to make the abstractions polymorphic. With parametric polymorphism, the type is left as a blank to be filled in by the compiler as it encounters uses of the abstraction. The compiler creates a tailored version of the abstraction for each unique filling of the blanks, which means the machine code will be fast. The blanks may be constrained to only accept types that meet certain criteria. With subtype polymorphism, the abstraction targets a supertype, which allows it to accept objects from across a whole hierarchy. The compiler can't determine ahead of time exactly what type of object is sent to an abstraction that uses subtype polymorphism. When it sees a virtual method call, it generates machine code instructions to look in the object's virtual table for the address of the method to jump to. Dynamic dispatch has a slight runtime cost, but its polymorphic flexibility makes it the foundation of object-oriented programming. Deciding what constitutes a subtype is a delicate business. Some languages support ad hoc polymorphism, which makes a name rather than a function reusable. Many overloaded versions of the function may be defined; they all have the same name but serve different types.

← Impossible ArraysLecture: Two Polymorphisms →