Type Constraints

Dear Computer

Chapter 12: Polymorphism Revisited

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:

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:

Haskell
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:

Java
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:

C++
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:

Rust
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:

Rust
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:

Rust
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.

← Parametric Polymorphism in RustTraits →