Encapsulation is often called the core of object-oriented programming. Data is bundled with the functions that operate on that data. However, too much behavior can lead to monolithic, incohesive classes.
The type class pattern preserves a class’s core behavior but defines orthogonal behaviors externally. Type classes allow you to “extend” both user-defined and native classes with new functionality long after they were originally designed, and without modifying their source code. This results in a design based on powerful, generic algorithms written in terms of type classes.
Although type classes originated in functional programming languages, they have made their way into several mainstream object-based languages. In this post, we’ll take a look at these latest implementations.
Java’s Comparable
and Comparator
generic interfaces are used to support the comparison of objects. These interfaces are useful in generic collection algorithms such as searching, sorting, or finding the minimum or maximum value.
Comparable
defines a single method: compareTo(T o)
. This interface is used to compare the receiver with another object of the same class. The ordering implemented by this interface should be the class’s natural ordering. Comparator
defines a single method: compare(T o1, T o2)
. This interface is used to provide an alternative ordering of a class, or an ordering for a class that does not have a natural ordering.
An algorithm written in terms of Comparable
requires its parameters to have a subtype relationship via inheritance. This renders the algorithm useless for third-party or native classes that don’t have a natural ordering, i.e., they don’t already implement Comparable
. We’re also out of luck if we don’t have access to their source code. However, using Comparator
removes any subtype requirements. We also can provide Comparator
instances for native classes like String
. Comparator
is a type class.
The following example uses Comparator
to implement a comparison of a user-defined class. The Comparator
is then used by a generic sorting algorithm.
By externalizing the definition of comparison for Person
objects, we keep our Person
class small and cohesive.
In fact, this design is more encapsulated than an alternative in which Person
defines its comparison logic. Since PersonWeightComparator
is defined externally, it must go through Person
‘s public interface. So this new behavior has no dependency on the implementation details (data) of Person
. However, defining comparison logic directly on Person
, creates another method dependent on the implementation details of Person
.
This next example uses Comparator
to implement a length comparison for the native String
class. The Comparator
is then used in a generic algorithm to find the longest string in a collection of strings.
String
does in fact implement Comparable
, however it compares String
s lexicographically. This example demonstrates how a type class can be used to “extend” native classes, in this case, an alternative implementation of String
comparison.
Type classes in Scala are implemented almost identically to Java. However, Scala adds one significant improvement.
In our Java examples, we had to explicitly pass the type class instance, our Comparator
, to the various Collections
methods. In Scala, we can define generic algorithms that accept a type class instance as an implicit parameter. Clients are not required to supply an argument for an implicit parameter. Instead, it can be defined elsewhere and the compiler will find it and pass it for you. This results in much cleaner, natural looking client code.
The following example uses the Scala library’s Ordering type class to implement two different types of comparisons of a user-defined class. These type classes are then used as arguments for both implicit and explicit parameters of a generic sorting function: Sorting.quickSort.
In the first call to Sorting.quickSort
, the Scala compiler finds our implicit User.IdOrdering
type class and passes it as the second argument. It found User.IdOrdering
because its definition is preceded by the implicit
keyword. This implicitness makes the sort function call appear as if the User
class defined the ordering. We get straightforward client code and avoid polluting our User
class with comparison logic.
The next sorting call demonstrates how we can still explicitly pass an argument for an implicit parameter. This allows us to override the implicit User.IdOrdering
and provide another implementation of User
comparison.
Type classes appear to be another way of monkey-patching classes, sort of like re-opening classes in Ruby. However, monkey-patching changes the behavior of that class throughout the entire program and possibly, throughout the lifetime of the program. Type classes provide much more fine-grained control, allowing you to vary class behavior on a per-function basis.
A typical object-oriented design relies on abstract interfaces and run-time polymorphism. Type classes also use abstract interfaces, but trade run-time polymorphism for compile-type type checking. Scala, with its support for both object-oriented and functional programming, provides a great environment to experiment with introducing type classes into a traditional object-oriented design.