Reasoning about variance

Yesterday I gave a lunch-and-learn presentation at work about generics in Hack. I attempted to explain variance, and while I think I did an OK job, I sort of punted on explaining why covariant type parameters can't be used in function arguments, contravariant parameters can't be used in function return types, and vice versa. I'm writing this to hopefully rectify that.

Type Parameter Variance in Hack

By default, type parameters in Hack are invariant, but they can be denoted as either covariant or contravariant by adding a + (for covariant) or - (for contravariant) before the type parameter name.

For me, the most useful intuition for thinking about variance is that a covariant type is read-only--it can be set in a constructor, but it can't be used as an argument to a function, thus cannot be written after construction--while a contravariant type is write-only--because it can never be used as a return type, it must be used internally within the class and can't escape.


// Covariant
class Box<+T> {
  public function __construct(private T $value) {}

  public function unbox(): T {
    return $this->$value;
  }

  /**
   * This function cannot be written.
   * public function update(T $value): void {
   *   $this->value = $value;
   * }
   */
}

// Contravariant
class LogWriter<-T> {
  private vec<T> $logLines
  public function append(T $line): void {
    $this->logLines[] = $line;
  }

  public function flush(): void {
    doSomethingWithTheLogs($this->logLines);
  }

  /**
   * This function cannot be written.
   * public function getLogLines(): vec<T> {
   *   return $this->logLines;
   * }
   */
}
    

y tho?

Covariance with generic type parameters means that the supertype - subtype relationship between parameterized classes goes in the same direction as the supertype - subtype relationship of the parameter. If B is a subtype of A, then CoFoo<B> is a subtype of CoFoo<A>. Contravariance reverses this, so that if B is a subtype of A, then ContraFoo<B> is a supertype of ContraFoo<A>.

So how do we get from that to saying that function arguments are contravariant and function return types are covariant?

Imagine you have these three classes:


class Animal {}

class Dog extends Animal {}

class Labrador extends Dog {}
    

The relationship between these is clear: all labradors are dogs, all dogs are animals, but not all dogs are labradors, and not all animals are dogs. This is object-oriented programming 101.

Now imagine you have these nine functions that transform these three classes into one another:


function animal_to_animal(Animal $a): Animal;

function animal_to_dog(Animal $a): Dog;

function animal_to_labrador(Animal $a): Labrador;

function dog_to_dog(Dog $d): Dog;

function dog_to_labrador(Dog $d): Labrador;

function dog_to_animal(Dog $d): Animal;

function labrador_to_labrador(Labrador: $lab): Labrador;

function labrador_to_dog(Labrador $lab): Dog;

function labrador_to_animal(Labrador $lab): Animal;
    

What's the relationship between these nine functions? If you need a function that takes a Dog and returns a Dog, which of them are safe to use?

First let's look at the return type. We know that all labradors are dogs but not all dogs are animals, so it's safe to use any function that returns either a Dog or a Labrador, because those are all dogs. This is the same relationship as the classes themselves, so it's covariant.

For the arguments, though, it's a different situation. If we need a function that can take a Dog and we know that all dogs are animals then we know any function that can take an Animal is also a function that can take a Dog. The same is not true for functions that take a Labrador, though, because not all dogs are labradors. In this case the supertype-subtype relationship is backwards: the less specific function parameter is a subtype of the more specific function parameter. Thus function arguments are contravariant.

[ Home ]