Type Constraints
Suppose we are trying to monitor readings from a sensor, which periodically generates a new scalar value. Our job is to determine the lowest and highest readings over a span of time. There's no reason to store all the numbers when we can just keep a running minimum and maximum with this generic Bounder
abstraction in Java:
public class Bounder<T> {
private T min;
private T max;
public Bounder() {
min = null;
max = null;
}
public void include(T value) {
if (min == null) {
min = max = value;
} else if (value.compareTo(min) < 0) {
min = value;
} else if (value.compareTo(min) > 0) {
max = value;
}
}
}
But this code doesn't typecheck. The compiler sees us calling compareTo
on a generic type T
. This is a problem because T
will be erased to Object
, and the Object
class makes no mention of a compareTo
method.
If this were Haskell, we'd fix this problem by imposing a type constraint on the include
function:
include :: Ord a => Bounder a -> a -> Bounder a
include Bounder value = ...
This function only works with bounds whose type parameter a
is a member of the Ord
typeclass. The compiler will ensure that the actual type parameters applied to the function are members of Ord
.
Java similarly lets us impose type constraints on type parameters. Its community calls them type bounds. We add the constraint in the declaration of the formal type parameter using the extends
keyword. After extends
we list one or more supertypes. The Bounder
class is fixed by constraining T
to be a subtype of the Comparable
interface:
public class Bounder<T extends Comparable<T>> {
// ...
}
The Comparable
interface is itself generic, so we qualify it with T
as well. If we need a formal type parameter to be a subtype of more than one supertype, we separate the constraints with &
. We may list many interfaces but at most one class since Java doesn't allow multiple inheritance.
Interestingly, the C++ compiler figures out the type constraints for us. This Bounder
class makes no explicit mention of T
needing to be comparable:
template<typename T>
class Bounder {
public:
void include(T value) {
if (value < min) {
min = value;
} else if (value > min) {
max = value;
}
}
private:
T min;
T max;
};
The compiler sees us comparing T
values with the <
and >
operators and ensures that these are legal operations for any actual type parameters it encounters. Stroustrup has long petitioned for a way to explicitly impose type constraints, and one has finally arrived in C++20 with a feature called concepts, which we will not discuss.
Like Java, Rust expects the programmer to explicitly declare type constraints. This Bounder
struct fails to compile because generic T
doesn't support the <
or >
operations:
struct Bounder<T> {
min: T,
max: T,
}
impl<T> Bounder<T> {
pub fn include(&mut self, value: T) {
if value < self.min {
self.min = value;
} else if value > self.max {
self.max = value;
}
}
}
And the same fix applies: we add a list of type constraints that any would-be actual type parameter must meet. Multiple constraints are separated by +
. The constraints may be added in two different places. If they are short and sweet, we append the constraints just after the declaration of the formal type parameter in the impl
block:
impl<T: Ord> Bounder<T> {
// ...
}
If the constraints are complex, we instead place them in a roomier where
clause just before the opening curly brace of the generic abstraction:
impl<T> Bounder<T>
where T: Ord
{
// ...
}
In Haskell, Ord
is a typeclass. In Java, Comparable
is an interface. In Rust, Ord
is a trait, which we'll examine next.