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:
ImplInv<Derived>
andImplCov<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, becauseImplCov<Base>
is covariant onT
, there cannot exist methods that can be passed aBase
instance and makev
a trueBase
.ImplCov
's methods cannot spawn anew Base()
either and assign it tov
because it doesn't know the eventual type ofT
.1Meanwhile, because casting
ImplCov<Derived> --> ImplCov<Base> --> ...
can only cause it to be less derived,ImplInv::violate(T)
is guaranteed to at worst setv
to a subtype of the eventualImplCov
'sT
, guaranteeing a valid cast to that eventualT
. The Derived ofImplInv<Derived>
can't be cast, so once parameterized, that type is set in stone.ImplInv<Derived>
andImplCov<Derived>
: these can coexist by merit ofT
being the same between them, the cast being only of the outermost type.ImplCov<Derived>
andImplCov<Base>
: these can coexist by the assumption thatImplCov
is validly covariant. Theprotected
visibility ofv
is indistinguishable fromprivate
, 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
.