Search code examples
phpcovariance

PHP Covariance with inherited class - Declarations incompatibles


I would like create an abstract class with abstract method which allow abstract type in return type. In my final class, I would like override type returned with type which implement abstract type initially declared.

<?php

abstract class A {
    abstract public function test(A $foo): self;
}

class B extends A {
    public function test(B $foo): self
    {
        return $this;
    }
}

This compilation error is thrown:

Fatal error: Declaration of B::test(B $foo): B must be compatible with A::test(A $foo): A in ... on line 8

In documentation, covariance is explained with interface. But not with abstract class. More about PHP implementation, the documentation say:

In PHP 7.2.0, partial contravariance was introduced by removing type restrictions on parameters in a child method. As of PHP 7.4.0, full covariance and contravariance support was added.

I'm using PHP 7.4.


Solution

  • A quite core principle of object oriented programming is the Liskov substitution principle which essentially boils down to:

    if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program

    The way to achieve this is by having covariant method return types, contravariant method type arguments. Exceptions thrown kind of count as return types here so they also need to be covariant.

    What you need is covariance of type arguments which breaks this principle. The reason why can be seen by considering the example below:

    abstract class A {
        abstract public function test(A $foo): self;
    }
    
    class C extends A {
        public function test(C $foo): self {
            return $this;
        }
    }
    
    class B extends A {
        public function test(B $foo): self {
            return $this;
        }
    }
    
    $b = new B();
    $c = new C();
    
    $b->test($c); // Does not work
    ((A)$b)->test((A)$c); // Works
    
    
    

    In the example above, you don't allow B::test to accept any type other than B as a type argument. However since B itself is a child of A and C is also a child of A by simple down-casting (which is allowed) the restriction is bypassed. You could always disable down-casting but that's almost saying you're disabling inheritance which is a core principle of OOP.

    Now of course there are compelling reasons to allow covariance of type arguments, which is why some languages (such as e.g. Eiffel) allow it, however this is recognised to be a problem and even has been given the name CATcalling (CAT stands for Changed Availability or Type).

    In PHP you can attempt to do runtime checks to remedy this situation:

    abstract class A {
        public function test(A $foo) {
             // static keyword resolve to the current object type at runtime 
             if (!$foo instanceof static) { throw new Exception(); }  
        }
    }
    
    class C extends A {
        public function test(A $foo): self {
            parent::test($foo);
            return $this;
        }
    }
    
    class B extends A {
        public function test(A $foo): self {
            parent::test($foo);
            return $this;
        }
    }
    

    However this is a bit messy and possibly unnecessary.