Search code examples
castingcovariancehacklang

Hacklang - Why are protected members invariant?


The public member case

With the carte blanche access the calling scope has to them, it's no surprise that public members are invariant:

<?hh // strict
class Foo<+T> {
  public function __construct(
    public T $v
  ) {}
}
class ViolateType {
  public static function violate(Foo<int> $foo): void {
    self::cast_and_set($foo);
    echo $foo->v + 1; // string + integer
  }
  public static function cast_and_set(Foo<arraykey> $foo): void {
    $foo->v = "Poof! The integer `violate()` expects is now a string.";
  }
}
// call ViolateType::foo(new Foo(1)); and watch the fireworks

The problem here is that both violate and cast_and_set can read and modify the same value (Foo->v) with different expectations of its type.

This problem, however, doesn't seem to exist for protected members.

Attempt to create a violation for protected members

Since the only distinction between private and protected is visibility to descendants, let's take a class (ImplCov) that, outside of some number of protected members, is otherwise validly covariant on a type, and extend it into a class (ImplInv) invariant on that type. Notably, being invariant on T allows me to expose a public setter — violate(T $v): T — where I'll try to break types.

<?hh // strict
// helper class hierarchy
class Base {}
class Derived extends Base {}

class ImplCov<+T> {
  public function __construct(
    protected T $v
  ) {}
}
class ImplInv<T> extends ImplCov<T> {
  public function violate(T $v): T {
    // Try to break types here
  }
}

With an instance of ImplInv<Derived>, I'm compelled to cast to an ImplCov<Derived>, then leverage covariance to cast to an ImplCov<Base>. It like the most dangerous thing to do, with all three types referring to the same object. Let's inspect the relationships between each type:

  1. ImplInv<Derived> and ImplCov<Base>: The violation in the public member case occured when the property was changed to a supertype (int->arraykey) or disjoint type with a common supertype (int->string). However, because ImplCov<Base> is covariant on T, there cannot exist methods that can be passed a Base instance and make v a true Base. ImplCov's methods cannot spawn a new Base() either and assign it to v because it doesn't know the eventual type of T.1

    Meanwhile, because casting ImplCov<Derived> --> ImplCov<Base> --> ... can only cause it to be less derived, ImplInv::violate(T) is guaranteed to at worst set v to a subtype of the eventual ImplCov's T, guaranteeing a valid cast to that eventual T. The Derived of ImplInv<Derived> can't be cast, so once parameterized, that type is set in stone.

  2. ImplInv<Derived> and ImplCov<Derived>: these can coexist by merit of T being the same between them, the cast being only of the outermost type.

  3. ImplCov<Derived> and ImplCov<Base>: these can coexist by the assumption that ImplCov is validly covariant. The protected visibility of v is indistinguishable from private, since they are the same class.

All of this seems to point to protected visibility being kosher for covariant types. Am I missing something?

1. We can actually spawn new Base() by introducing the super constraint: ImplCov<T super Base>, but this is even weaker, since by definition ImplInv has to parameterize ImplCov in the extends statement with a supertype, making ImplInv's operations with v safe. Plus, ImplCov can't assume anything about the members of T.


Solution

  • Remember that a subclass can modify a protected member of any object of that class, not just $this. So this slight modification of your example above uses a protected member to similarly break the type system -- all we have to do is make ViolateType a subclass of Foo (and it doesn't matter what we set T to, or if we make ViolateType generic or whatever).

    <?hh // strict
    
    class Foo<+T> {
        public function __construct(
            /* HH_IGNORE_ERROR[4120] */
            protected T $v
        ) {}
    }
    
    class ViolateType extends Foo<void> {
        public static function violate(Foo<int> $foo): void {
            self::cast_and_set($foo);
            echo $foo->v + 1; // string + integer
        }
    
        public static function cast_and_set(Foo<arraykey> $foo): void {
            $foo->v = "Poof! The integer `violate()` expects is now a string.";
        }
    }
    

    This passes the typechecker with only the one error suppression for the protected member -- so allowing protected members to be covariant would break the type system.