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