Associated Types
An associated type is a type member of a trait that is specified by each trait instance. Associated types are often considered a more natural alternative to multi-parameter type classes.
We illustrate associated types with an example.
We can define a trait for types that can be added:
trait Addable[t] {
pub def add(x: t, y: t): t
}
We can implement multiple instances of the Addable
trait for types such as
floating-point numbers, integers, and strings. For example, here is the instance
for Int32
:
instance Addable[Int32] {
pub def add(x: Int32, y: Int32): Int32 = x + y
}
and here is one for String
:
instance Addable[String] {
pub def add(x: String, y: String): String = "${x}${y}"
}
But what if we wanted to add an element to a set?
Intuitively, we would like to write:
instance Addable[Set[a]] with Order[a] {
pub def add(s: Set[a], x: a): Set[a] = Set.insert(x, s)
}
But the signature of add
does not match the signature declared in Addable
.
We can overcome this problem and increase the flexibility of Addable
with an
associated type:
trait Addable[t] {
type Rhs
pub def add(x: t, y: Addable.Rhs[t]): t
}
The Addable
trait now has an associated type called Rhs
. We refer to it as
Addable.Rhs[t]
as seen in the signature of add
. Whenever we declare an
instance of Addable
, we must specify the associated type.
We can still implement instances for integers and strings, as before. For example:
instance Addable[Int32] {
type Rhs = Int32
pub def add(x: Int32, y: Int32): Int32 = x + y
}
But we can also implement an instance that allows adding an element to a set:
instance Addable[Set[a]] with Order[a] {
type Rhs = a
pub def add(s: Set[a], x: a): Set[a] = Set.insert(x, s)
}
The important point is that each trait instance specifies the associated type.
We might wonder if we can specify two instances for Set[a]
: (a) one for adding
an element to a set, as above, and (b) one for adding two sets:
instance Addable[Set[a]] with Order[a] {
type Rhs = Set[a]
pub def add(x: Set[a], y: Set[a]): Set[a] = Set.union(x, y)
}
But while each instance is valid on its own, we cannot have both:
❌ -- Instance Error --------------------------------------------------
>> Overlapping instances for 'Addable'.
...
If we had such overlapping instances, an expression like Addable.add(Set#{}, Set#{})
would become ambiguous: Are we adding two sets? Or are we adding the
empty set to a set?
Example: A ForEach
Trait
We can use associated types to define a trait for collections that have a
forEach
function:
trait ForEach[t] {
type Elm
pub def forEach(f: ForEach.Elm[t] -> Unit \ ef, x: t): Unit \ ef
}
Here t
is the type of the collection and the associated type Elm
is the type
of its elements. We can implement several instances for ForEach
. For example,
we can implement an instance for List[a]
:
instance ForEach[List[a]] {
type Elm = a
pub def forEach(f: a -> Unit \ ef, x: List[a]): Unit \ ef = List.forEach(f, x)
}
We can also implement an instance for Map[k, v]
:
instance ForEach[Map[k, v]] {
type Elm = (k, v)
pub def forEach(f: ((k, v)) -> Unit \ ef, x: Map[k, v]): Unit \ ef =
Map.forEach(k -> v -> f((k, v)), x)
}
What is interesting and useful is that we can define the element type to be
key-value pairs. We need extra parentheses around the argument to f
because we
want it to take a pair.
We can implement an instance for String
where we can iterate through each
individual character:
instance ForEach[String] {
type Elm = Char
pub def forEach(f: Char -> Unit \ ef, x: String): Unit \ ef =
x |> String.toList |> List.forEach(f)
}
Example: A Collection
Trait
As another example, we can define a trait for collections:
trait Collection[t] {
type Elm
pub def empty(): t
pub def insert(x: Collection.Elm[t], c: t): t
pub def toList(c: t): List[Collection.Elm[t]]
}
Here t
is the type of the collection and Elm
is the type of its elements.
Every collection must support three operations: empty
, insert
, and toList
.
We can implement an instance of Collection
for Vector[a]
:
instance Collection[Vector[a]] {
type Elm = a
pub def empty(): Vector[a] = Vector.empty()
pub def insert(x: a, c: Vector[a]): Vector[a] = Vector.append(c, Vector#{x})
pub def toList(c: Vector[a]): List[a] = Vector.toList(c)
}
And we can implement an instance of Collection
for Set[a]
:
instance Collection[Set[a]] with Order[a] {
type Elm = a
pub def empty(): Set[a] = Set.empty()
pub def insert(x: a, c: Set[a]): Set[a] = Set.insert(x, c)
pub def toList(c: Set[a]): List[a] = Set.toList(c)
}
Equality Constraints
We sometimes want to write polymorphic functions where we restrict an associated type.
For example, returning to the example of the Collection
trait, we can write a
function where we require that the element type is an Int32
. This allows us to
write a sum function:
def sum(c: t): Int32 with Collection[t] where Collection.Elm[t] ~ Int32 =
Collection.toList(c) |> List.sum
Here the where
clause contains a list of type equality constraints.
Specifically, the equality constraint Collection.Elm[t] ~ Int32
assert that
sum
can be used with any type t
for which there is an instance of
Collection
as long as the element type of that instance is equal to Int32
.
This restriction ensures that the elements of the collection are integers and
allows us to call List.sum
.
Default Types
We can define a default type for an associated type.
Returning to Addable
, we can define the associated type Rhs
with t
as its
default:
trait Addable[t] {
type Rhs = t // Associated type with default type.
pub def add(x: t, y: Addable.Rhs[t]): t
}
Here we specify that if Rhs
is not defined by an instance implementation then
it defaults to t
. The upshot is that we can define an instance for Int32
:
instance Addable[Int32] {
pub def add(x: Int32, y: Int32): Int32 = x + y
}
without having to explicit define type Rhs = Int32
.