Coined by Strachey1967, ad-hoc polymorphism occurs when a “function is defined over several different types, acting in a different way for each type.” While designing the Haskell type system, Wadler and Blott1989 introduced the mechanism of type classes to extend Hindley-Milner typing and unify parametric and ad-hoc polymorphism.
Modern programming languages have adopted type classes to again unify ad-hoc and parametric polymorphism. Languages such as Coq, Scala, Rust, Swift, Lean, and PureScript have followed suit with varying implementations of type classes—even if branded under a different name. The remainder of this section will introduce some terminology and ordinary use cases for type classes. For the remainder of the thesis, core examples are given using Rust traits. In this context “trait” and “type class” are synonymous. This introduction will put Rust and Haskell side-by-side for readers familiar with Haskell.
A Class to Stringify
The simplest of our examples defines a class to turn values into a string—a common operation. To start we need to define a trait with an appropriate name and trait methods.
trait ToString {
fn to_string(&self) -> String;
}class ToString a where
toString :: a -> StringA trait is hardly usefull without implementors.
impl ToString for char {
fn to_string(&self) -> String {
String::from(*self)
}
}
impl ToString for i32 {
fn to_string(&self) -> String {
format!("{}", self)
}
}instance ToString Char where
toString a = [a]
instance ToString Int where
toString a = show aWith some basic implementors we can already start to use the trait. Let’s write a simple function, print_ln, that will print a value to the console.
fn print_ln<T>(v: T)
where T: ToString {
println!("{}", v.to_string());
}printLn :: ToString t => t -> IO ()
printLn = putStrLn . toStringTraits abstract over shared behavior. The ToString trait declares the behavior of types that can be converted into a string, trait implementors provide this behavior by implementing concrete member function instances. Defining every type an implementor would be a tedious process and so far we haven’t seen wherein the power of traits lies. To demonstrate this, let’s consider how we might use our current ToString implementors to stringify a vector. A naïve developer might want to provide the separate implementations shown in
impl ToString for Vec<char> {
fn to_string(&self) -> String {
format!("[{}]",
self.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
)
}
}impl ToString for Vec<i32> {
fn to_string(&self) -> String {
format!("[{}]",
self.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
)
}
}Of course this is redundant, the implementations are equivalent. Any vector whose elements implement ToString can share the same implementation. Traits of course allow for this abstraction, implementations can be parametric, thus chaining implementation blocks together. The previous two declarations would canonically be implemented as in
impl<T> ToString for Vec<T>
where T: ToString, {
fn to_string(&self) -> String {
format!("[{}]",
self.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
)
}
}instance ToString a => ToString [a] where
toString a = "[" ++ listWSep ++ "]"
where
listOfStrs = map toString a
listWSep = intercalate ", " listOfStrsThe trait machinery of Rust will “figure out” which implementations to call when print_ln is invoked with vectors of characters or integers. The figuring out process in Rust is called trait resolution. A complex process and the topic of this work.
print_ln(vec!['a', 'b', 'c']) // "[a, b, c]"
print_ln(vec![1, 2, 3]) // "[1, 2, 3]"