-
-
Notifications
You must be signed in to change notification settings - Fork 946
Description
Introduction
PHP has type invariant type system and doesn't support type covariance and contravariance. That means a method of a subclass must have the same return type and same types of parameters as its superclass.
PHP is invariant (mostly)
class Foo
{
public function bar(string $baz): int {}
}
class Woo extends Foo
{
// $baz must be string, return type must be int
public function bar(string $baz): int {}
}PHP is invariant for both parameter and return types. Or, to be precise, there is kind of one-level return type covariance, if superclass doesn't specify a return type, and in PHP 7.2 is coming one-level parameter type contravariance (aka parameter type widening).
One-level covariance of return type in PHP
class Foo
{
// no return type here (implicitly mixed type)
public function bar() {}
}
class Woo extends Foo
{
// subclass can return more specific type
// it's type safe (int is subtype of mixed)
public function bar(): int {}
}
//...but all subclasses of Woo must be invariant, ie. return int
// therefore one-level covarianceOne-level contravariance of parametr type - PHP 7.2 parametr type widening
class Foo
{
// parameter $baz is string,
// according to parameter invariance, $bar in all subclasses should be also string...
public function bar(string $baz) {}
}
class Woo extends Foo
{
//...but PHP 7.2 allows to omit type, ie. make it implicitly mixed, ie. widen to any type
// this is also type-safe (mixed is supertype of string)
public function bar($baz): int {}
}
//...but all subclasses of Woo must be invariant, ie. type of $bar must be mixed, ie. be omitted
// therefore one-level contravarianceReal covariance and contravariance
To have a whole picture, quick examples of real covariance and contravariance. None of these will run, it's not supported by PHP:
class Animal {}
class Mammal extends Animal {}
class Dog extends Mammal {}
class Foo {
public function bar(Mammal $x): Mammal {}
}
// parameter type cotravariance
// more specific type accepts more general type - it's type safe
class Zoo1 extends Foo {
public function bar(Animal $x): Mammal {}
}
// return type covariance
// more specific type returns more specific type - it's type safe
class Zoo2 extends Foo {
public function bar(Mammal $x): Dog {}
}
// parameter type covariance
// more specific type accepts more specific type - it's NOT type safe
class Zoo3 extends Foo {
public function bar(Dog $x): Mammal {}
}
// return type cotravariance
// more specific type returns more general type - it's NOT type safe
class Zoo4 extends Foo {
public function bar(Mammal $x): Animal {}
}Why isn't parameter type covariance type safe?
// Let's add another subclass of Mammal, Cat:
class Cat extends Mammal {}
// if we have instantion of Zoo3, bar() method can accept only object of type Dog
$zoo3 = new Zoo3();
$zoo3->bar(new Dog());
//$zoo3->bar(new Cat()); // we can't do this, type Cat isn't compatible with Dog
// So far so good. But what happens if we up-cast Zoo3 to Foo? For example as a parameter of function:
function test(Foo $foo) {
// Foo::bar() accept all mammals, so this should be OK.
// But $foo can contain up-casted Zoo3. And then we have a problem:
// Zoo3::bar() does not accepts type Cat, only Dog...
$foo->baz(new Cat());
}
// ...so this will result in type error!
test($zoo3);
// Therefore, parameter type covariance is not type safe.A similar example could be created for return type contravariance.
Type system in PHPStan and polymorphism
Historically, PHP had poor or no support for parameter and return types. In order to document types and to get some comfort during development, such as code completion, developers came up with PHPDoc, which allowed us to annotate methods with expected parameter and return types. The stress is on the word expected, because technically, there wasn't any tool to verify these annotations nor to enforce some rules, such as invariance/covariance/contravariance. These textual type hints might make sense or not, didn't matter as long as the code run.
But now we have PHPStan. A tool that allows us to do static analysis and enforce some rules for our code and our type system. Question is, what are these rules? And how the type system looks like? Because this type system PHPStan operates on is not the same PHP has. As shown above, PHP is invariant for both parameter and return types. But with PHPDoc's tags @param and @return and PHPStan is easy (and common) to introduce covariance and contravariance - and not always in that good way. The @method annotation allows completely changing method signature. And also, these PHPDoc tag allows creating of some kind of method overloading, which doesn't exist in PHP itself (regardless its title, this is not method overloading).
So, again, what is PHPStan's type system? What is allowed in this type system? And what should be?
Let's take a few examples and examine some of the possible problems:
Parameter type covariance
PHP itself doesn't allow covariant parameter types, but with PHPDoc and PHPStan is really easy to create such situation:
class Animal {}
class Dog extends Animal {
public function bark() { echo 'Bark!';}
}
class Cat extends Animal {
public function meow() { echo 'Meow!';}
}
interface IFoo {
public function bar(Animal $animal);
}
class Doo implements IFoo {
// Here we have parameter type covariance. If it was supported by PHP, we would write:
// public function bar(Dog $dog) {}
// Type Animal must be here for PHP 7.1 or less. In 7.2 it could be omitted,
// but for this example doesn't really matter.
/** @param Dog $dog */
public function bar(Animal $dog) {
// We **expect** $dog is of type Dog.
$dog->bark();
}
}
function baz(IFoo $foo) {
$cat = new Cat();
// IFoo::bar() accepts any animal, so a cat sould be fine. Or not?
$foo->bar($cat);
}
$doo = new Doo();
// This works as expected. We can see "Bark!" in an output.
$doo->bar(new Dog());
// Doo implements IFoo, so we can pass it to baz()
baz($doo);
// Whoops! We tried to make a cat to bark.The code above is statically correct, PHPStan doesn't report any error.
But in the runtime, this code will fail with fatal error Call to undefined method Cat::bark().
It's not a bug in PHPStan per se. It's about PHPStan's type system which allows parameter type covariance and therefore isn't type safe.
Overriding methods with incompatible types / method overloading
Because parameter types in PHP are invariant, a subclass can not change types declared by a superclass. And, as mentioned before, PHP doesn't have real method overloading - ie. one class can't have more methods with the same name and different parameter type or a different number of parameters, as eg., a class in C# can.
Therefore, following isn't possible in PHP. This code will not run:
interface IFoo {
public function bar(int $baz);
}
class Foo implements IFoo {
public function bar(int $baz) {}
public function bar(\DateTime $baz) {}
}But with PHPDoc, we can create something very similar:
interface IFoo {
/** @param int $i */
public function bar($i): void;
}
class Foo implements IFoo {
/** @param \DateTime $dt */
public function bar($dt): void {
// What type $dt is? Is it \DateTime or int?
$this->test($dt);
}
private function test(\DateTime $t): void {}
}
// What types Foo:bar() accepts? Is it \DateTime or int?
$foo = new Foo();
$foo->bar(new \DateTime());
// And how about now when Foo is up-casted to IFoo?
/** @var IFoo $ifoo */
$ifoo = new Foo();
$ifoo->bar(123);This code passes analysis but fails in runtime with Argument 1 passed to Foo::test() must be an instance of DateTime, integer given.
It's very similar to the previous example with Dog and Cat. But the important difference here is that int and \DateTime are two incompatible types. (Something else would be, if IFoo:bar() didn't have any typehint, ie. accepted mixed, then we would have parameter covariance again.)
Currently, PHPStan allows to override a method with incompatible parameter type and that's not type safe. Another (probably better) option would be to disallow this altogether and simply report Foo::bar() as incompatible with IFoo:bar().
A third way would be to merge these incompatible types to union type int|\DateTime. In that case, a caller would know Foo::bar() accept both \DateTime and int (PHPStan would know, it wouldn't work in eg. PHPStorm unless explicitly typehinted as int|\DateTime thou). And inside of Foo::bar() method, we would know we need to deal with both types. Effectively, we would get type-safe method overloading.
Tag @method allows to completely change method signature
Recently added ability to override method definition with @method annotation is a useful feature, for sure. But AFAIK, currently, PHPStan doesn't do any check against native method and therefore is possible to create various invalid states similar to previous examples and even completely change method signature.
Moreover, with @method overriding comes great risk that native method will change but PHPStan will continue performing analysis using an obsolete definition from annotation instead of to warn about this change.
Conclusion
Purpose of this text is to address some issues related to polymorphism, particularly type variance and method overloading, in relation to PHPDoc and PHPStan.
Of course, this is only small part of bigger picture. The type system created by PHPStan is much more complex. Much more complex than shown here and much more complex than type system of PHP itself. Beside discussed polymorphism, it includes also union and composite types, typed arrays, and later possibly also generics. And probably more.
The more is for PHPStan important to define consistent type system with clear rules. Because, PHPStan has big potential to influence whole PHP community and even PHP language itself, I believe.
Personally, I would like, if PHPStan could analyse methods' types in the whole hierarchy so it would be possible identify various incompatibilities. I would preserve both parameter type cotravariance and return type covariance - they are useful and one day they can make it even into PHP. I would forbid return type cotravariance and definitely parameter type covariance. Thou, the last one might be a bit tricky, as this seems to me to be misused quite often. And finally, I would disallow overriding parameters with incompatible types and evaluate the possibility to treat it as method overloading instead.
Resources
- Wikipedia: Covariance and contravariance
- PHP RFC: Parameter Type Widening
- PHP RFC: Return Type Declarations
- PHP RFC: Scalar Type Declarations
- PHP RFC: Nullable Types
- PHP RFC: Object typehint
- PHP RFC: Mixed typehint
It's a bit longer… So thanks for reading!